{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "7a46ccb3",
   "metadata": {},
   "source": [
    "# Iterators"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "ce3d3550",
   "metadata": {},
   "source": [
    "**Learning goals** — By the end of this section you will be able to:\n",
    "\n",
    "- Describe Python's **iterator protocol** and distinguish iterables from iterators\n",
    "- Use `iter()` and `next()` manually to step through any iterable\n",
    "- Use lazy built-in iterators: `enumerate()`, `zip()`, `map()`, `filter()`, `reversed()`\n",
    "- Implement a **custom iterator class** with `__iter__` and `__next__`\n",
    "- Raise `StopIteration` correctly to signal the end of a sequence"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "8d8ba868",
   "metadata": {},
   "source": [
    "## The Iterator Protocol\n",
    "\n",
    "Any object you can loop over is an **iterable**. Under the hood, `for` loops call two methods:\n",
    "\n",
    "| Method | Called on | Returns |\n",
    "|---|---|---|\n",
    "| `__iter__()` | the iterable | an **iterator** object |\n",
    "| `__next__()` | the iterator | the next value, or raises `StopIteration` |\n",
    "\n",
    "An object that implements *both* methods is its own iterator (e.g., file objects, generators). A list is iterable but *not* its own iterator — `iter(my_list)` creates a separate iterator object."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "8400d872",
   "metadata": {},
   "outputs": [],
   "source": [
    "# iter() creates an iterator from any iterable\n",
    "my_list = [10, 20, 30]\n",
    "it = iter(my_list)\n",
    "print(type(it))           # <class 'list_iterator'>\n",
    "\n",
    "# next() fetches values one at a time\n",
    "print(next(it))           # 10\n",
    "print(next(it))           # 20\n",
    "print(next(it))           # 30\n",
    "\n",
    "# The for loop does this for you automatically:\n",
    "# for item in my_list:  →  it = iter(my_list); next(it); next(it); ...\n",
    "\n",
    "# Calling next() past the end raises StopIteration\n",
    "try:\n",
    "    print(next(it))\n",
    "except StopIteration:\n",
    "    print(\"StopIteration raised — sequence exhausted\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "fd237fa7",
   "metadata": {},
   "source": [
    "## Built-in Lazy Iterators\n",
    "\n",
    "Python's standard library is full of iterators that produce values **on demand** — they don't build a list in memory first. This makes them memory-efficient for large or infinite sequences.\n",
    "\n",
    "| Iterator | What it produces |\n",
    "|---|---|\n",
    "| `enumerate(seq)` | `(index, value)` pairs |\n",
    "| `zip(a, b)` | `(a_item, b_item)` pairs, stops at the shorter |\n",
    "| `map(f, seq)` | `f(item)` for each item |\n",
    "| `filter(f, seq)` | items where `f(item)` is `True` |\n",
    "| `reversed(seq)` | items in reverse order |"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "c380cfd4",
   "metadata": {},
   "outputs": [],
   "source": [
    "fruits = ['apple', 'banana', 'cherry']\n",
    "\n",
    "# enumerate — index + value pairs\n",
    "for i, fruit in enumerate(fruits, start=1):\n",
    "    print(f\"{i}. {fruit}\")\n",
    "\n",
    "# zip — pair two sequences together\n",
    "prices = [1.20, 0.50, 2.00]\n",
    "for fruit, price in zip(fruits, prices):\n",
    "    print(f\"{fruit}: ${price:.2f}\")\n",
    "\n",
    "# map and filter return iterators (wrap in list() to inspect)\n",
    "lengths = list(map(len, fruits))           # [5, 6, 6]\n",
    "long_ones = list(filter(lambda f: len(f) > 5, fruits))  # ['banana', 'cherry']\n",
    "print(lengths)\n",
    "print(long_ones)\n",
    "\n",
    "# reversed\n",
    "for fruit in reversed(fruits):\n",
    "    print(fruit, end=' ')\n",
    "print()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "2c9cad21",
   "metadata": {},
   "source": [
    "## Custom Iterator Classes\n",
    "\n",
    "When you need iteration logic that doesn't map cleanly to a built-in or generator, implement the iterator protocol directly. Your class needs two methods:\n",
    "\n",
    "- **`__iter__(self)`** — returns `self` (the iterator is its own iterable)\n",
    "- **`__next__(self)`** — returns the next value, or raises `StopIteration`"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cac09288",
   "metadata": {},
   "outputs": [],
   "source": [
    "class Countdown:\n",
    "    \"\"\"Counts down from `start` to 1, then raises StopIteration.\"\"\"\n",
    "\n",
    "    def __init__(self, start):\n",
    "        self.current = start\n",
    "\n",
    "    def __iter__(self):\n",
    "        return self          # the iterator IS the object\n",
    "\n",
    "    def __next__(self):\n",
    "        if self.current <= 0:\n",
    "            raise StopIteration\n",
    "        value = self.current\n",
    "        self.current -= 1\n",
    "        return value\n",
    "\n",
    "\n",
    "# Works in a for loop\n",
    "for n in Countdown(5):\n",
    "    print(n, end=' ')\n",
    "print()   # 5 4 3 2 1\n",
    "\n",
    "# Also works with list(), sum(), max(), etc.\n",
    "print(list(Countdown(4)))    # [4, 3, 2, 1]\n",
    "print(sum(Countdown(10)))    # 55"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "3cfeae5d",
   "metadata": {},
   "outputs": [],
   "source": [
    "class NumberRange:\n",
    "    \"\"\"Iterates from `start` to `stop` (exclusive) by `step`.\"\"\"\n",
    "\n",
    "    def __init__(self, start, stop, step=1):\n",
    "        self.current = start\n",
    "        self.stop = stop\n",
    "        self.step = step\n",
    "\n",
    "    def __iter__(self):\n",
    "        return self\n",
    "\n",
    "    def __next__(self):\n",
    "        if (self.step > 0 and self.current >= self.stop) or \\\n",
    "           (self.step < 0 and self.current <= self.stop):\n",
    "            raise StopIteration\n",
    "        value = self.current\n",
    "        self.current += self.step\n",
    "        return value\n",
    "\n",
    "\n",
    "print(list(NumberRange(1, 10, 2)))    # [1, 3, 5, 7, 9]\n",
    "print(list(NumberRange(10, 0, -3)))   # [10, 7, 4, 1]"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "0d15e217",
   "metadata": {},
   "outputs": [],
   "source": [
    "### Exercise: Cycling Iterator\n",
    "#   Implement a `Cycle` iterator that repeatedly loops through a sequence forever.\n",
    "#   Example: Cycle(['R', 'G', 'B']) yields R, G, B, R, G, B, R, ...\n",
    "#   1. Store the sequence and the current index.\n",
    "#   2. `__next__` returns the item at the current index and advances; wraps with modulo.\n",
    "#   3. Test: collect the first 7 values from Cycle([1, 2, 3]) → [1, 2, 3, 1, 2, 3, 1].\n",
    "#   Hint: use `import itertools; list(itertools.islice(your_cycle, 7))` to cap it.\n",
    "### Your code starts here.\n",
    "\n",
    "\n",
    "\n",
    "\n",
    "### Your code ends here."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "5d4c0e83",
   "metadata": {},
   "outputs": [],
   "source": [
    "### Solution\n",
    "import itertools\n",
    "\n",
    "class Cycle:\n",
    "    def __init__(self, sequence):\n",
    "        self.sequence = list(sequence)\n",
    "        self.index = 0\n",
    "\n",
    "    def __iter__(self):\n",
    "        return self\n",
    "\n",
    "    def __next__(self):\n",
    "        if not self.sequence:\n",
    "            raise StopIteration\n",
    "        value = self.sequence[self.index]\n",
    "        self.index = (self.index + 1) % len(self.sequence)\n",
    "        return value\n",
    "\n",
    "# Collect the first 7 values\n",
    "print(list(itertools.islice(Cycle([1, 2, 3]), 7)))    # [1, 2, 3, 1, 2, 3, 1]\n",
    "print(list(itertools.islice(Cycle(['R', 'G', 'B']), 8)))  # ['R', 'G', 'B', ...]"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "b9946426",
   "metadata": {},
   "source": [
    "## Summary\n",
    "\n",
    "| Concept | Key idea |\n",
    "|---|---|\n",
    "| Iterable | Has `__iter__()` — can be passed to `for`, `list()`, `sum()`, etc. |\n",
    "| Iterator | Has `__next__()` — produces one value per call; raises `StopIteration` when done |\n",
    "| `iter(x)` | Returns an iterator from any iterable |\n",
    "| `next(it)` | Returns the next value from an iterator |\n",
    "| `enumerate(seq)` | Yields `(index, value)` pairs |\n",
    "| `zip(a, b)` | Pairs items from two iterables, stops at shortest |\n",
    "| Custom iterator | Class with `__iter__` returning `self` + `__next__` raising `StopIteration` |"
   ]
  }
 ],
 "metadata": {
  "language_info": {
   "name": "python"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
