{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "cfdabd08",
   "metadata": {},
   "source": "# The iteration protocol\n\nA `for` loop in Python looks simple, but under the hood it's powered by a small, explicit contract between two kinds of objects. Once you see that contract, you can make *any* object work with `for` loops, list comprehensions, `sum()`, `max()`, unpacking, `itertools` \u2014 all of it.\n\nThis notebook covers that contract: the **iterator protocol**."
  },
  {
   "cell_type": "markdown",
   "id": "16e6a3e0",
   "metadata": {},
   "source": "## What `for` really does\n\nWhen you write:\n\n```python\nfor x in things:\n    ...\n```\n\nPython doesn't just \"loop over `things`\". It does something quite specific:\n\n1. Call `iter(things)` to get an **iterator**.\n2. Repeatedly call `next(iterator)` to get the next value.\n3. When `next()` raises `StopIteration`, stop.\n\nLet's do that by hand."
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "26d6f990",
   "metadata": {},
   "outputs": [],
   "source": "numbers = [10, 20, 30]\n\nit = iter(numbers)        # step 1: get an iterator\nprint(next(it))           # step 2: get values one at a time\nprint(next(it))\nprint(next(it))"
  },
  {
   "cell_type": "markdown",
   "id": "38c2455f",
   "metadata": {},
   "source": "The list is *iterable* \u2014 it knows how to produce an iterator. The thing `iter()` returned is the iterator itself. They are not the same object."
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "5848765f",
   "metadata": {},
   "outputs": [],
   "source": "print(type(numbers))   # list \u2014 the iterable\nprint(type(it))        # list_iterator \u2014 the iterator"
  },
  {
   "cell_type": "markdown",
   "id": "423212b9",
   "metadata": {},
   "source": "One more `next()` call and the iterator is exhausted. Python signals that by raising `StopIteration`."
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "768c1640",
   "metadata": {},
   "outputs": [],
   "source": "try:\n    next(it)\nexcept StopIteration:\n    print('done \u2014 no more values')"
  },
  {
   "cell_type": "markdown",
   "id": "8467e360",
   "metadata": {},
   "source": "## Iterable vs iterator \u2014 the distinction that matters\n\n- **Iterable**: an object you can get an iterator from. Lists, tuples, strings, sets, dicts, files, ranges, generators \u2014 all iterable. The test is whether `iter(obj)` works.\n- **Iterator**: the stateful thing that actually produces values via `next()`. It remembers how far through the sequence you are.\n\nOne iterable can produce many *independent* iterators. A single iterator is consumed once \u2014 after it's exhausted, it stays exhausted."
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "b3018a78",
   "metadata": {},
   "outputs": [],
   "source": "xs = [1, 2, 3]\na = iter(xs)\nb = iter(xs)             # a and b are independent\n\nprint(next(a), next(a))  # 1 2\nprint(next(b))           # 1 \u2014 b has its own position"
  },
  {
   "cell_type": "markdown",
   "id": "3d05a34b",
   "metadata": {},
   "source": "This distinction explains a common beginner trap: iterating over an iterator twice."
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "ac495ddd",
   "metadata": {},
   "outputs": [],
   "source": "it = iter([1, 2, 3])\n\nfirst_sum = sum(it)      # consumes the iterator completely\nsecond_sum = sum(it)     # iterator is now empty\n\nprint(first_sum, second_sum)"
  },
  {
   "cell_type": "markdown",
   "id": "3710ccaa",
   "metadata": {},
   "source": "The second `sum(it)` returns `0`. It isn't a bug \u2014 it's the protocol working as specified. If you need to iterate twice, either keep the *iterable* around (re-call `iter()` each time) or materialise the values into a list."
  },
  {
   "cell_type": "markdown",
   "id": "2f7330d7",
   "metadata": {},
   "source": "## The dunder methods\n\nAn iterable defines `__iter__`. An iterator defines both `__iter__` and `__next__`. An iterator's `__iter__` returns `self` \u2014 that's how `for` loops transparently accept both iterables *and* iterators.\n\nWe'll build a tiny iterator from scratch to make this concrete."
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "34e0493e",
   "metadata": {},
   "outputs": [],
   "source": "class Countdown:\n    '''Iterable. Each iter() call produces a fresh iterator.'''\n    def __init__(self, start):\n        self.start = start\n\n    def __iter__(self):\n        return CountdownIterator(self.start)\n\n\nclass CountdownIterator:\n    '''Iterator. Stateful; consumed once.'''\n    def __init__(self, current):\n        self.current = current\n\n    def __iter__(self):\n        return self           # iterators are their own iterator\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\nfor n in Countdown(3):\n    print(n)"
  },
  {
   "cell_type": "markdown",
   "id": "7736ae0e",
   "metadata": {},
   "source": "Two independent iterations of the same `Countdown` both work, because each `for` loop calls `iter()` and gets a fresh `CountdownIterator`."
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "23401d1a",
   "metadata": {},
   "outputs": [],
   "source": "c = Countdown(3)\nprint(list(c))   # first pass\nprint(list(c))   # second pass \u2014 still works"
  },
  {
   "cell_type": "markdown",
   "id": "6f430242",
   "metadata": {},
   "source": "Whereas a bare `CountdownIterator` is used up after one pass:"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "e9e79c6a",
   "metadata": {},
   "outputs": [],
   "source": "ci = CountdownIterator(3)\nprint(list(ci))   # first pass consumes it\nprint(list(ci))   # empty"
  },
  {
   "cell_type": "markdown",
   "id": "a71f69c8",
   "metadata": {},
   "source": "## Anything that obeys the protocol plugs into everything\n\n`for` is just one consumer. Any function or construct that iterates uses the same protocol. That's why a custom iterator works with `list()`, `sum()`, `max()`, `tuple()`, unpacking, comprehensions, `in`, and the `itertools` module, all without extra work on your part."
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "8c8a1bb0",
   "metadata": {},
   "outputs": [],
   "source": "c = Countdown(5)\n\nprint(list(c))                 # list() calls iter/next\nprint(sum(Countdown(5)))       # 5+4+3+2+1\nprint(max(Countdown(5)))       # 5\nprint(tuple(Countdown(3)))     # (3, 2, 1)\n\na, b, c_ = Countdown(3)        # unpacking uses the protocol\nprint(a, b, c_)\n\nprint(2 in Countdown(5))       # 'in' iterates until it finds a match"
  },
  {
   "cell_type": "markdown",
   "id": "08e4de6b",
   "metadata": {},
   "source": "This is the payoff. Implement `__iter__` once and your type slots into every iteration-aware tool Python has. We'll lean on this throughout the guide \u2014 it's why generators (next notebook) and `itertools` (notebook 3) feel so composable."
  },
  {
   "cell_type": "markdown",
   "id": "937e77ef",
   "metadata": {},
   "source": "## Quick check \u2014 implement an iterator\n\nBuild an iterable `Repeat(value, n)` that yields the same value `n` times. The class should work with a `for` loop *and* with `list()` called twice on the same instance.\n\nHints:\n\n- You'll need two classes (one iterable, one iterator) or one class whose `__iter__` returns a *new* iterator each call.\n- The iterator's `__next__` should count down how many yields remain."
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "47c08005",
   "metadata": {},
   "outputs": [],
   "source": "# Your turn \u2014 fill these in:\n\nclass Repeat:\n    def __init__(self, value, n):\n        ...\n\n    def __iter__(self):\n        ...\n\n\n# Expected behaviour (uncomment once implemented):\n# r = Repeat('hi', 3)\n# print(list(r))   # ['hi', 'hi', 'hi']\n# print(list(r))   # ['hi', 'hi', 'hi']  \u2014 independent iteration\n# print(sum(Repeat(5, 4)))  # 20"
  },
  {
   "cell_type": "markdown",
   "id": "f64633df",
   "metadata": {},
   "source": "### One working solution"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "f3c485d6",
   "metadata": {},
   "outputs": [],
   "source": "class Repeat:\n    def __init__(self, value, n):\n        self.value = value\n        self.n = n\n\n    def __iter__(self):\n        return _RepeatIterator(self.value, self.n)\n\n\nclass _RepeatIterator:\n    def __init__(self, value, remaining):\n        self.value = value\n        self.remaining = remaining\n\n    def __iter__(self):\n        return self\n\n    def __next__(self):\n        if self.remaining <= 0:\n            raise StopIteration\n        self.remaining -= 1\n        return self.value\n\n\nr = Repeat('hi', 3)\nprint(list(r))\nprint(list(r))\nprint(sum(Repeat(5, 4)))"
  },
  {
   "cell_type": "markdown",
   "id": "42955c14",
   "metadata": {},
   "source": "## Summary\n\n- `for x in obj:` calls `iter(obj)` then `next(...)` repeatedly, stopping on `StopIteration`.\n- *Iterable* and *iterator* are different. An iterable creates iterators; an iterator is the one-shot state.\n- Implementing `__iter__` on your class lets it plug into every iteration-aware tool in the standard library.\n- Because iterators are consumed, iterating the same iterator twice silently yields nothing the second time \u2014 one of the most common iteration bugs.\n\nNext up: **generator functions**. Writing iterator classes by hand is rare in practice, because `yield` gives you the same behaviour in a few lines."
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "name": "python",
   "version": "3.10"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}