{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "0cc7cc06",
   "metadata": {},
   "source": "# Dunder methods\n\nA *dunder method* (from \"double underscore\") is a method with a name like `__repr__` or `__eq__`. Python calls these on your behalf when you use built-in syntax or functions: `repr(x)` calls `x.__repr__()`, `x == y` calls `x.__eq__(y)`, `len(x)` calls `x.__len__()`, and so on.\n\nImplementing the right dunders is what makes a class feel *Pythonic* — your objects behave like built-in types, play well with `print`, `sorted`, `set`, `dict`, and debugging tools. This notebook covers the dunders you'll reach for ninety percent of the time. The [reference catalogue](https://agilearn.co.uk/guides/classes-and-objects/reference/dunder-methods-catalogue) is the place to look up the rest."
  },
  {
   "cell_type": "markdown",
   "id": "f03f35cd",
   "metadata": {},
   "source": "## `__repr__` — a useful string for debugging\n\nWithout `__repr__`, your class prints as that ugly memory-address string. Fix that first — it's the highest-leverage dunder by a long way."
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "d5ba5662",
   "metadata": {},
   "outputs": [],
   "source": "class Point:\n    def __init__(self, x, y):\n        self.x = x\n        self.y = y\n\np = Point(3, 4)\nprint(p)\nprint(repr(p))"
  },
  {
   "cell_type": "markdown",
   "id": "866ed5fe",
   "metadata": {},
   "source": "Both `print(p)` and `repr(p)` give the ugly form because there's no `__repr__` defined. Add one:"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "1660dfcb",
   "metadata": {},
   "outputs": [],
   "source": "class Point:\n    def __init__(self, x, y):\n        self.x = x\n        self.y = y\n\n    def __repr__(self):\n        return f\"Point(x={self.x}, y={self.y})\"\n\np = Point(3, 4)\nprint(p)\nprint(repr(p))\nprint([Point(1, 2), Point(3, 4)])   # containers call repr on their elements"
  },
  {
   "cell_type": "markdown",
   "id": "b1b75fe1",
   "metadata": {},
   "source": "**Convention**: `__repr__` should look like Python code that would reconstruct the object — `Point(x=3, y=4)` rather than `point at (3, 4)`. It doesn't have to actually be valid code, but it should look like it, so that when you see the string in a log or a traceback you know exactly what you're looking at.\n\nThe `print([Point(1, 2), Point(3, 4)])` call above is the reason this matters in practice: as soon as your objects end up in lists, dicts, or tracebacks, their `__repr__` is what you see."
  },
  {
   "cell_type": "markdown",
   "id": "6ea3526c",
   "metadata": {},
   "source": "## `__str__` — the user-facing version\n\n`__str__` is what `print(x)` and `str(x)` use. If you don't define it, Python falls back to `__repr__`, which is usually what you want. Only define a separate `__str__` when the user-facing form should differ from the debugging form."
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "896b37ac",
   "metadata": {},
   "outputs": [],
   "source": "class Temperature:\n    def __init__(self, celsius):\n        self.celsius = celsius\n\n    def __repr__(self):\n        return f\"Temperature(celsius={self.celsius})\"\n\n    def __str__(self):\n        return f\"{self.celsius}°C\"\n\nt = Temperature(22)\nprint(str(t))     # user-facing\nprint(repr(t))    # debugging"
  },
  {
   "cell_type": "markdown",
   "id": "651a0008",
   "metadata": {},
   "source": "Rule of thumb: always implement `__repr__`. Only implement `__str__` if there's a genuine reason the user-facing and debugging forms should differ."
  },
  {
   "cell_type": "markdown",
   "id": "209d7e43",
   "metadata": {},
   "source": "## `__eq__` — equality that compares values\n\nBy default, two instances are equal only if they're the same object. That's usually not what you want for value-like types."
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "702579a0",
   "metadata": {},
   "outputs": [],
   "source": "class Point:\n    def __init__(self, x, y):\n        self.x = x\n        self.y = y\n\na = Point(3, 4)\nb = Point(3, 4)\nprint(a == b)        # False — different objects\nprint(a == a)        # True  — same object"
  },
  {
   "cell_type": "markdown",
   "id": "a3ad0c95",
   "metadata": {},
   "source": "Implement `__eq__` to compare the things that actually matter:"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "a53bd6d2",
   "metadata": {},
   "outputs": [],
   "source": "class Point:\n    def __init__(self, x, y):\n        self.x = x\n        self.y = y\n\n    def __eq__(self, other):\n        if not isinstance(other, Point):\n            return NotImplemented\n        return self.x == other.x and self.y == other.y\n\na = Point(3, 4)\nb = Point(3, 4)\nprint(a == b)"
  },
  {
   "cell_type": "markdown",
   "id": "e6dab1db",
   "metadata": {},
   "source": "Two details worth pausing on:\n\n- Returning `NotImplemented` (the built-in sentinel, **not** raising `NotImplementedError`) when `other` isn't a compatible type is the right move. It tells Python to try `other.__eq__(self)` before giving up — so `Point(1, 2) == \"hello\"` returns `False` rather than crashing.\n- Never call `super().__eq__` or `object.__eq__` as your equality check. Default object equality is identity (`is`), so you'd be back where you started."
  },
  {
   "cell_type": "markdown",
   "id": "4c6fdcfd",
   "metadata": {},
   "source": "### The `__eq__` / `__hash__` pair\n\nDefining `__eq__` removes the default `__hash__`. Instances of the class become unhashable — they can't go in a `set` or be keys in a `dict`."
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "ebfc5cc7",
   "metadata": {},
   "outputs": [],
   "source": "a = Point(3, 4)\n\ntry:\n    {a}\nexcept TypeError as e:\n    print(f\"{type(e).__name__}: {e}\")"
  },
  {
   "cell_type": "markdown",
   "id": "605632a2",
   "metadata": {},
   "source": "The rule is that objects which compare equal must have the same hash. Python doesn't know how to satisfy that automatically once you override `__eq__`, so it removes `__hash__` to stop you from introducing a subtle bug.\n\nIf the class is logically immutable (its equality-relevant fields don't change), add `__hash__`:"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "d01f7bc2",
   "metadata": {},
   "outputs": [],
   "source": "class Point:\n    def __init__(self, x, y):\n        self.x = x\n        self.y = y\n\n    def __repr__(self):\n        return f\"Point({self.x}, {self.y})\"\n\n    def __eq__(self, other):\n        if not isinstance(other, Point):\n            return NotImplemented\n        return self.x == other.x and self.y == other.y\n\n    def __hash__(self):\n        return hash((self.x, self.y))\n\nprint({Point(3, 4), Point(3, 4), Point(1, 2)})   # one duplicate collapses"
  },
  {
   "cell_type": "markdown",
   "id": "2b3ac4b5",
   "metadata": {},
   "source": "**Don't** define `__hash__` on mutable classes. If an instance's hash can change while it's in a `set` or `dict`, you've broken the container's invariants — lookups will silently fail to find the object."
  },
  {
   "cell_type": "markdown",
   "id": "d705bc7b",
   "metadata": {},
   "source": "## Ordering — `__lt__` and `@total_ordering`\n\n`sorted()` and the `<` operator need `__lt__` (\"less than\"). Without it, you get a `TypeError`."
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "27397f4f",
   "metadata": {},
   "outputs": [],
   "source": "pts = [Point(3, 4), Point(1, 2), Point(5, 0)]\ntry:\n    sorted(pts)\nexcept TypeError as e:\n    print(f\"{type(e).__name__}: {e}\")"
  },
  {
   "cell_type": "markdown",
   "id": "f6f78fd1",
   "metadata": {},
   "source": "Add `__lt__` and sorting works:"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "21c23dec",
   "metadata": {},
   "outputs": [],
   "source": "class Point:\n    def __init__(self, x, y):\n        self.x = x\n        self.y = y\n\n    def __repr__(self):\n        return f\"Point({self.x}, {self.y})\"\n\n    def __lt__(self, other):\n        return (self.x, self.y) < (other.x, other.y)\n\npts = [Point(3, 4), Point(1, 2), Point(5, 0)]\nprint(sorted(pts))"
  },
  {
   "cell_type": "markdown",
   "id": "abaa2835",
   "metadata": {},
   "source": "That works, but `<` is only one of six ordering operators (`<`, `<=`, `>`, `>=`, `==`, `!=`). If you want all of them, either define them all — tedious — or use `functools.total_ordering`, which fills in the missing four from whichever one you provide plus `__eq__`:"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "d27ef9c1",
   "metadata": {},
   "outputs": [],
   "source": "from functools import total_ordering\n\n@total_ordering\nclass Priority:\n    def __init__(self, level):\n        self.level = level\n\n    def __eq__(self, other):\n        if not isinstance(other, Priority):\n            return NotImplemented\n        return self.level == other.level\n\n    def __lt__(self, other):\n        if not isinstance(other, Priority):\n            return NotImplemented\n        return self.level < other.level\n\np1, p2 = Priority(1), Priority(5)\nprint(p1 < p2, p1 <= p2, p1 >= p2, p1 > p2)"
  },
  {
   "cell_type": "markdown",
   "id": "9410850f",
   "metadata": {},
   "source": "`total_ordering` is convenient but adds a tiny runtime cost on each comparison. For hot code paths you might prefer to write all four out by hand; for almost everything else, reach for the decorator."
  },
  {
   "cell_type": "markdown",
   "id": "4da10e03",
   "metadata": {},
   "source": "## `__len__` and truthiness\n\nImplementing `__len__` makes `len(x)` work on your class. It also makes your class *truthy when non-empty, falsy when empty*, without any extra work — see the [conditional logic guide](https://agilearn.co.uk/guides/conditional-logic/reference/truthiness-rules) for the rules."
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "799c5c9e",
   "metadata": {},
   "outputs": [],
   "source": "class Inventory:\n    def __init__(self):\n        self.items = []\n\n    def add(self, item):\n        self.items.append(item)\n\n    def __len__(self):\n        return len(self.items)\n\ninv = Inventory()\nprint(len(inv), bool(inv))\n\ninv.add(\"widget\")\nprint(len(inv), bool(inv))"
  },
  {
   "cell_type": "markdown",
   "id": "80955d09",
   "metadata": {},
   "source": "## The other dunders, briefly\n\nThe dunders we've covered here are the ones you'll add to almost every class worth making. There are others, grouped by role:\n\n- **Container behaviour** — `__iter__`, `__getitem__`, `__setitem__`, `__contains__`, `__len__`. See [make a class iterable or container-like](https://agilearn.co.uk/guides/classes-and-objects/recipes/make-a-class-iterable).\n- **Arithmetic** — `__add__`, `__mul__`, `__neg__`, and the rest. Useful for numeric-like types.\n- **Context managers** — `__enter__` and `__exit__` for `with` blocks.\n- **Attribute access** — `__getattr__`, `__setattr__`, `__delattr__`, `__getattribute__`. Powerful but easy to misuse.\n- **Callable** — `__call__` lets you use an instance like a function.\n\nThe [dunder methods catalogue](https://agilearn.co.uk/guides/classes-and-objects/reference/dunder-methods-catalogue) has the full list with signatures and typical use cases."
  },
  {
   "cell_type": "markdown",
   "id": "bc3a69e6",
   "metadata": {},
   "source": "## Exercise\n\nBuild a `Money` class representing an amount in a currency (store the amount as an integer number of pennies to avoid float rounding). Give it:\n\n- `__init__(self, pennies, currency=\"GBP\")`.\n- A `__repr__` like `Money(pennies=150, currency='GBP')`.\n- A `__str__` like `£1.50`.\n- `__eq__` that compares amount *and* currency.\n- `__lt__` that compares amounts — but only when currencies match (raise `ValueError` otherwise).\n- Decorate with `@total_ordering` to get the full set of comparisons.\n\nTest that two `Money(150)` instances compare equal, that `Money(100) < Money(150)` is true, and that comparing `Money(100, \"GBP\")` with `Money(100, \"USD\")` raises."
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "7cfcb324",
   "metadata": {},
   "outputs": [],
   "source": "# Your code here\n"
  },
  {
   "cell_type": "markdown",
   "id": "291251d3",
   "metadata": {},
   "source": "<details>\n<summary>Solution</summary>\n\n```python\nfrom functools import total_ordering\n\nCURRENCY_SYMBOLS = {\"GBP\": \"£\", \"USD\": \"$\", \"EUR\": \"€\"}\n\n@total_ordering\nclass Money:\n    def __init__(self, pennies, currency=\"GBP\"):\n        self.pennies = pennies\n        self.currency = currency\n\n    def __repr__(self):\n        return f\"Money(pennies={self.pennies}, currency={self.currency!r})\"\n\n    def __str__(self):\n        symbol = CURRENCY_SYMBOLS.get(self.currency, self.currency + \" \")\n        return f\"{symbol}{self.pennies / 100:.2f}\"\n\n    def __eq__(self, other):\n        if not isinstance(other, Money):\n            return NotImplemented\n        return self.pennies == other.pennies and self.currency == other.currency\n\n    def __lt__(self, other):\n        if not isinstance(other, Money):\n            return NotImplemented\n        if self.currency != other.currency:\n            raise ValueError(\n                f\"cannot compare {self.currency} with {other.currency}\"\n            )\n        return self.pennies < other.pennies\n\n    def __hash__(self):\n        return hash((self.pennies, self.currency))\n```\n</details>"
  },
  {
   "cell_type": "markdown",
   "id": "79119321",
   "metadata": {},
   "source": "## Recap\n\n- **Always** implement `__repr__`. It's the single most useful dunder.\n- Implement `__str__` only if the user-facing form should differ from the debugging form.\n- `__eq__` makes value-equality work. Return `NotImplemented` for unknown types, not `False`.\n- Defining `__eq__` removes `__hash__` — add it back for immutable classes, leave it off for mutable ones.\n- For ordering, define `__lt__` plus `__eq__`, then apply `@functools.total_ordering`.\n- `__len__` makes `len()` work and gives you free truthiness.\n\nNext: [Data classes](https://agilearn.co.uk/guides/classes-and-objects/learn/03-data-classes), which generate most of these dunders automatically from a field list."
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "name": "python",
   "version": "3.10"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}