{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "9bfd34d6",
   "metadata": {},
   "source": [
    "# 05 · Object-Oriented Python\n",
    "\n",
    "Classes let you bundle data with the behaviour that belongs to it. Python's object model is flexible:\n",
    "almost everything, numbers and functions included, is an object, and your own classes can behave like\n",
    "built-in types. This module goes from the four classic pillars to the features professional code uses\n",
    "every day.\n",
    "\n",
    "**You will learn**\n",
    "1. The four pillars: encapsulation, inheritance, polymorphism, abstraction\n",
    "2. The data model: special (\"dunder\") methods that make objects printable, comparable, iterable and callable\n",
    "3. Properties, class methods and static methods\n",
    "4. `dataclasses`: classes for data with almost no boilerplate\n",
    "5. Abstract base classes versus `Protocol` (structural typing)\n",
    "6. Composition over inheritance, `super()` and the method resolution order\n",
    "7. `__slots__`: when memory per object matters"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "06057783",
   "metadata": {},
   "source": [
    "## 1. The four pillars\n",
    "**Encapsulation** keeps data and the rules that protect it together. Python has no truly private\n",
    "attributes; a leading underscore is the convention for \"internal, do not touch\"."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 1,
   "id": "f276b3cd",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-10-17T23:23:26.171370Z",
     "iopub.status.busy": "2026-10-17T23:23:26.170765Z",
     "iopub.status.idle": "2026-10-17T23:23:26.187380Z",
     "shell.execute_reply": "2026-10-17T23:23:26.185287Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "120\n",
      "cannot set it directly: property 'balance' of 'BankAccount' object has no setter\n"
     ]
    }
   ],
   "source": [
    "class BankAccount:\n",
    "    def __init__(self, owner: str, balance: float = 0.0):\n",
    "        self.owner = owner\n",
    "        self._balance = balance                     # internal by convention\n",
    "\n",
    "    def deposit(self, amount: float) -> None:\n",
    "        if amount <= 0:\n",
    "            raise ValueError(\"deposit must be positive\")\n",
    "        self._balance += amount\n",
    "\n",
    "    def withdraw(self, amount: float) -> None:\n",
    "        if amount > self._balance:\n",
    "            raise ValueError(\"insufficient funds\")\n",
    "        self._balance -= amount\n",
    "\n",
    "    @property\n",
    "    def balance(self) -> float:                      # read-only from outside\n",
    "        return self._balance\n",
    "\n",
    "acct = BankAccount(\"Alice\", 100)\n",
    "acct.deposit(50); acct.withdraw(30)\n",
    "print(acct.balance)\n",
    "try:\n",
    "    acct.balance = 1_000_000\n",
    "except AttributeError as e:\n",
    "    print(\"cannot set it directly:\", e)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "e1efdbcd",
   "metadata": {},
   "source": [
    "**Inheritance** reuses and extends a base class; **polymorphism** means code can call the same method on\n",
    "different types and get the right behaviour for each."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 2,
   "id": "c34519f8",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-10-17T23:23:26.192259Z",
     "iopub.status.busy": "2026-10-17T23:23:26.191730Z",
     "iopub.status.idle": "2026-10-17T23:23:26.204591Z",
     "shell.execute_reply": "2026-10-17T23:23:26.202398Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Rex: woof\n",
      "Tom: meow\n",
      "Fido: woof\n",
      "True True\n"
     ]
    }
   ],
   "source": [
    "class Animal:\n",
    "    def __init__(self, name: str):\n",
    "        self.name = name\n",
    "    def speak(self) -> str:\n",
    "        raise NotImplementedError\n",
    "\n",
    "class Dog(Animal):\n",
    "    def speak(self) -> str:\n",
    "        return f\"{self.name}: woof\"\n",
    "\n",
    "class Cat(Animal):\n",
    "    def speak(self) -> str:\n",
    "        return f\"{self.name}: meow\"\n",
    "\n",
    "for pet in [Dog(\"Rex\"), Cat(\"Tom\"), Dog(\"Fido\")]:\n",
    "    print(pet.speak())                    # one call, the right behaviour per type\n",
    "print(isinstance(Dog(\"x\"), Animal), issubclass(Cat, Animal))"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "2316b6f0",
   "metadata": {},
   "source": [
    "**Abstraction** defines an interface and hides the implementation. An abstract base class cannot be\n",
    "instantiated and forces subclasses to implement its abstract methods."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 3,
   "id": "acc957cb",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-10-17T23:23:26.209100Z",
     "iopub.status.busy": "2026-10-17T23:23:26.208734Z",
     "iopub.status.idle": "2026-10-17T23:23:26.218452Z",
     "shell.execute_reply": "2026-10-17T23:23:26.216838Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "['Circle with area 3.14', 'Square with area 4.00']\n",
      "abstract: Can't instantiate abstract class Shape without an implementation for abstract method 'area'\n"
     ]
    }
   ],
   "source": [
    "from abc import ABC, abstractmethod\n",
    "import math\n",
    "\n",
    "class Shape(ABC):\n",
    "    @abstractmethod\n",
    "    def area(self) -> float: ...\n",
    "\n",
    "    def describe(self) -> str:            # concrete method built on the abstract one\n",
    "        return f\"{type(self).__name__} with area {self.area():.2f}\"\n",
    "\n",
    "class Circle(Shape):\n",
    "    def __init__(self, r): self.r = r\n",
    "    def area(self): return math.pi * self.r ** 2\n",
    "\n",
    "class Square(Shape):\n",
    "    def __init__(self, side): self.side = side\n",
    "    def area(self): return self.side ** 2\n",
    "\n",
    "print([s.describe() for s in (Circle(1), Square(2))])\n",
    "try:\n",
    "    Shape()\n",
    "except TypeError as e:\n",
    "    print(\"abstract:\", e)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "a484000e",
   "metadata": {},
   "source": [
    "## 2. The data model: special methods\n",
    "Python calls specially named methods for built-in operations: `len(x)` calls `x.__len__()`, `a + b`\n",
    "calls `a.__add__(b)`, `for item in x` calls `x.__iter__()`. Implementing them makes your classes feel\n",
    "built in."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 4,
   "id": "d50ed1c3",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-10-17T23:23:26.222806Z",
     "iopub.status.busy": "2026-10-17T23:23:26.222448Z",
     "iopub.status.idle": "2026-10-17T23:23:26.234638Z",
     "shell.execute_reply": "2026-10-17T23:23:26.232896Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Vector(4, 5) (9, 12) 5.0 True [Vector(1, 1), Vector(3, 4)] 3 4 False\n",
      "{Vector(3, 4)}\n"
     ]
    }
   ],
   "source": [
    "from functools import total_ordering\n",
    "\n",
    "@total_ordering                          # derives <=, >, >= from __eq__ and __lt__\n",
    "class Vector:\n",
    "    def __init__(self, x: float, y: float):\n",
    "        self.x, self.y = x, y\n",
    "    def __repr__(self):  return f\"Vector({self.x}, {self.y})\"         # for developers\n",
    "    def __str__(self):   return f\"({self.x}, {self.y})\"                # for users\n",
    "    def __add__(self, o): return Vector(self.x + o.x, self.y + o.y)\n",
    "    def __mul__(self, k): return Vector(self.x * k, self.y * k)\n",
    "    __rmul__ = __mul__                                                  # so 3 * v works too\n",
    "    def __abs__(self):   return math.hypot(self.x, self.y)\n",
    "    def __eq__(self, o): return (self.x, self.y) == (o.x, o.y)\n",
    "    def __lt__(self, o): return abs(self) < abs(o)\n",
    "    def __hash__(self):  return hash((self.x, self.y))\n",
    "    def __iter__(self):  yield from (self.x, self.y)                   # enables unpacking\n",
    "    def __bool__(self):  return bool(abs(self))\n",
    "\n",
    "v, w = Vector(3, 4), Vector(1, 1)\n",
    "x, y = v\n",
    "print(repr(v + w), str(3 * v), abs(v), v > w, sorted([v, w]), x, y, bool(Vector(0, 0)))\n",
    "print({v, Vector(3, 4)})                 # equal objects collapse in a set thanks to __hash__"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 5,
   "id": "b17f8b5d",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-10-17T23:23:26.239733Z",
     "iopub.status.busy": "2026-10-17T23:23:26.239225Z",
     "iopub.status.idle": "2026-10-17T23:23:26.249076Z",
     "shell.execute_reply": "2026-10-17T23:23:26.247053Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "4 verse ['verse', 'chorus'] True ['INTRO', 'VERSE', 'CHORUS', 'OUTRO']\n",
      "42 [2, 4, 6]\n"
     ]
    }
   ],
   "source": [
    "class Playlist:\n",
    "    '''A container: supports len(), indexing, slicing, 'in' and iteration.'''\n",
    "    def __init__(self, songs): self._songs = list(songs)\n",
    "    def __len__(self): return len(self._songs)\n",
    "    def __getitem__(self, i): return self._songs[i]\n",
    "    def __contains__(self, song): return song in self._songs\n",
    "\n",
    "class Multiplier:\n",
    "    '''A callable object: instances can be used like functions.'''\n",
    "    def __init__(self, k): self.k = k\n",
    "    def __call__(self, x): return x * self.k\n",
    "\n",
    "p = Playlist([\"intro\", \"verse\", \"chorus\", \"outro\"])\n",
    "print(len(p), p[1], p[1:3], \"chorus\" in p, [s.upper() for s in p])\n",
    "double = Multiplier(2)\n",
    "print(double(21), list(map(double, [1, 2, 3])))"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "273fc4c2",
   "metadata": {},
   "source": [
    "## 3. Properties, class methods, static methods"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 6,
   "id": "9959775b",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-10-17T23:23:26.255314Z",
     "iopub.status.busy": "2026-10-17T23:23:26.254679Z",
     "iopub.status.idle": "2026-10-17T23:23:26.267467Z",
     "shell.execute_reply": "2026-10-17T23:23:26.265770Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "100.0 212.0 True\n",
      "below absolute zero\n"
     ]
    }
   ],
   "source": [
    "class Temperature:\n",
    "    def __init__(self, celsius: float):\n",
    "        self.celsius = celsius                 # goes through the setter below\n",
    "\n",
    "    @property\n",
    "    def celsius(self) -> float:\n",
    "        return self._c\n",
    "\n",
    "    @celsius.setter\n",
    "    def celsius(self, value: float) -> None:   # validation on every assignment\n",
    "        if value < -273.15:\n",
    "            raise ValueError(\"below absolute zero\")\n",
    "        self._c = value\n",
    "\n",
    "    @property\n",
    "    def fahrenheit(self) -> float:             # computed attribute\n",
    "        return self._c * 9 / 5 + 32\n",
    "\n",
    "    @classmethod\n",
    "    def from_fahrenheit(cls, f: float) -> \"Temperature\":   # alternative constructor\n",
    "        return cls((f - 32) * 5 / 9)\n",
    "\n",
    "    @staticmethod\n",
    "    def is_freezing(celsius: float) -> bool:   # related helper, needs neither instance nor class\n",
    "        return celsius <= 0\n",
    "\n",
    "t = Temperature.from_fahrenheit(212)\n",
    "print(t.celsius, t.fahrenheit, Temperature.is_freezing(-3))\n",
    "try:\n",
    "    t.celsius = -300\n",
    "except ValueError as e:\n",
    "    print(e)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "5654343c",
   "metadata": {},
   "source": [
    "## 4. Dataclasses\n",
    "Most classes mainly hold data. `@dataclass` writes `__init__`, `__repr__` and `__eq__` for you, and\n",
    "optionally ordering, immutability (`frozen=True`) and memory-saving slots."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 7,
   "id": "4d28bb93",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-10-17T23:23:26.272179Z",
     "iopub.status.busy": "2026-10-17T23:23:26.271664Z",
     "iopub.status.idle": "2026-10-17T23:23:26.289548Z",
     "shell.execute_reply": "2026-10-17T23:23:26.287586Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "[Version(major=1, minor=9, patch=3), Version(major=2, minor=0, patch=5), Version(major=2, minor=1, patch=0)]\n",
      "Experiment(name='baseline', learning_rate=0.001, tags=['cpu']) {'name': 'baseline', 'learning_rate': 0.001, 'tags': ['cpu']} Experiment(name='baseline', learning_rate=0.01, tags=['cpu'])\n"
     ]
    }
   ],
   "source": [
    "from dataclasses import dataclass, field, asdict, replace\n",
    "\n",
    "@dataclass(frozen=True, order=True)\n",
    "class Version:\n",
    "    major: int\n",
    "    minor: int = 0\n",
    "    patch: int = 0\n",
    "    def __str__(self): return f\"{self.major}.{self.minor}.{self.patch}\"\n",
    "\n",
    "@dataclass\n",
    "class Experiment:\n",
    "    name: str\n",
    "    learning_rate: float = 1e-3\n",
    "    tags: list[str] = field(default_factory=list)      # never a mutable default directly\n",
    "    def __post_init__(self):\n",
    "        if self.learning_rate <= 0:\n",
    "            raise ValueError(\"learning_rate must be positive\")\n",
    "\n",
    "print(sorted([Version(2, 1), Version(1, 9, 3), Version(2, 0, 5)]))\n",
    "e = Experiment(\"baseline\", tags=[\"cpu\"])\n",
    "print(e, asdict(e), replace(e, learning_rate=0.01))"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "bb472962",
   "metadata": {},
   "source": [
    "## 5. Abstract base classes versus Protocols\n",
    "An ABC requires classes to **inherit** from it (nominal typing). A `Protocol` only requires them to **have\n",
    "the right methods** (structural typing, \"duck typing\" that type checkers understand). Protocols let you\n",
    "describe what you need from objects you do not control."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 8,
   "id": "7605103a",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-10-17T23:23:26.293926Z",
     "iopub.status.busy": "2026-10-17T23:23:26.293548Z",
     "iopub.status.idle": "2026-10-17T23:23:26.304524Z",
     "shell.execute_reply": "2026-10-17T23:23:26.302253Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "13.141592653589793 True\n"
     ]
    }
   ],
   "source": [
    "from typing import Protocol, runtime_checkable\n",
    "\n",
    "@runtime_checkable\n",
    "class SupportsArea(Protocol):\n",
    "    def area(self) -> float: ...\n",
    "\n",
    "class Rectangle:                      # does NOT inherit from anything\n",
    "    def __init__(self, w, h): self.w, self.h = w, h\n",
    "    def area(self): return self.w * self.h\n",
    "\n",
    "def total_area(items: list[SupportsArea]) -> float:\n",
    "    return sum(i.area() for i in items)\n",
    "\n",
    "shapes = [Rectangle(2, 3), Circle(1), Square(2)]\n",
    "print(total_area(shapes), all(isinstance(s, SupportsArea) for s in shapes))"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "39f76910",
   "metadata": {},
   "source": [
    "## 6. Composition over inheritance, and `super()`\n",
    "Inheritance models \"is a\"; composition models \"has a\". Deep inheritance trees become hard to change, so\n",
    "prefer giving an object the parts it needs."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 9,
   "id": "12cb07e7",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-10-17T23:23:26.309400Z",
     "iopub.status.busy": "2026-10-17T23:23:26.309075Z",
     "iopub.status.idle": "2026-10-17T23:23:26.318511Z",
     "shell.execute_reply": "2026-10-17T23:23:26.316418Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "engine (150 hp) started; routing to Madrid\n"
     ]
    }
   ],
   "source": [
    "class Engine:\n",
    "    def __init__(self, hp): self.hp = hp\n",
    "    def start(self): return f\"engine ({self.hp} hp) started\"\n",
    "\n",
    "class GPS:\n",
    "    def route(self, to): return f\"routing to {to}\"\n",
    "\n",
    "class Car:                                  # a car HAS an engine and a GPS\n",
    "    def __init__(self, engine: Engine, gps: GPS | None = None):\n",
    "        self.engine, self.gps = engine, gps\n",
    "    def drive(self, to):\n",
    "        steps = [self.engine.start()]\n",
    "        if self.gps:\n",
    "            steps.append(self.gps.route(to))\n",
    "        return \"; \".join(steps)\n",
    "\n",
    "print(Car(Engine(150), GPS()).drive(\"Madrid\"))"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 10,
   "id": "9cf0a135",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-10-17T23:23:26.323578Z",
     "iopub.status.busy": "2026-10-17T23:23:26.323094Z",
     "iopub.status.idle": "2026-10-17T23:23:26.331984Z",
     "shell.execute_reply": "2026-10-17T23:23:26.330362Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "['Service', 'Logging', 'Caching', 'Base', 'object']\n",
      "['Base', 'Caching', 'Logging', 'Service']\n"
     ]
    }
   ],
   "source": [
    "class Base:\n",
    "    def setup(self): return [\"Base\"]\n",
    "class Logging(Base):\n",
    "    def setup(self): return super().setup() + [\"Logging\"]\n",
    "class Caching(Base):\n",
    "    def setup(self): return super().setup() + [\"Caching\"]\n",
    "class Service(Logging, Caching):           # multiple inheritance\n",
    "    def setup(self): return super().setup() + [\"Service\"]\n",
    "\n",
    "print([c.__name__ for c in Service.__mro__])   # method resolution order\n",
    "print(Service().setup())                        # super() follows the MRO: every class runs once"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "df414f57",
   "metadata": {},
   "source": [
    "## 7. `__slots__`: memory per object\n",
    "Each normal object carries a `__dict__` for its attributes. With `__slots__` the attributes are stored in\n",
    "fixed slots instead, which saves memory when you create millions of small objects."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 11,
   "id": "57535694",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-10-17T23:23:26.337562Z",
     "iopub.status.busy": "2026-10-17T23:23:26.336912Z",
     "iopub.status.idle": "2026-10-17T23:23:28.016727Z",
     "shell.execute_reply": "2026-10-17T23:23:28.014421Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "per object: regular 120 bytes, with __slots__ 88 bytes\n"
     ]
    }
   ],
   "source": [
    "import tracemalloc\n",
    "\n",
    "class PointDict:\n",
    "    def __init__(self, x, y): self.x, self.y = x, y\n",
    "\n",
    "class PointSlots:\n",
    "    __slots__ = (\"x\", \"y\")\n",
    "    def __init__(self, x, y): self.x, self.y = x, y\n",
    "\n",
    "def measure(cls, n=200_000):\n",
    "    tracemalloc.start()\n",
    "    objs = [cls(i, i) for i in range(n)]\n",
    "    current, _ = tracemalloc.get_traced_memory()\n",
    "    tracemalloc.stop()\n",
    "    return current / n\n",
    "\n",
    "print(f\"per object: regular {measure(PointDict):.0f} bytes, with __slots__ {measure(PointSlots):.0f} bytes\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "f77c4a3a",
   "metadata": {},
   "source": [
    "## Summary\n",
    "| Tool | Reach for it when |\n",
    "|---|---|\n",
    "| plain class | behaviour plus state with rules (an account, a connection) |\n",
    "| `@dataclass` | mostly data (records, configuration, results) |\n",
    "| `@property` | validation or computed attributes behind attribute syntax |\n",
    "| `@classmethod` | alternative constructors (`from_json`, `from_fahrenheit`) |\n",
    "| ABC | a family of classes you own that must implement an interface |\n",
    "| `Protocol` | describing what you need from any object, including third-party ones |\n",
    "| composition | combining capabilities without deep inheritance |\n",
    "| `__slots__` | millions of small objects |\n",
    "\n",
    "## Exercises\n",
    "1. Make `Vector` support subtraction and the dot product (`v @ w` calls `__matmul__`).\n",
    "2. Write a frozen dataclass `Money(amount, currency)` whose `+` raises an error for different currencies.\n",
    "3. Replace the `Animal` hierarchy with a `Protocol` and check that a class not inheriting from anything still works."
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "codemirror_mode": {
    "name": "ipython",
    "version": 3
   },
   "file_extension": ".py",
   "mimetype": "text/x-python",
   "name": "python",
   "nbconvert_exporter": "python",
   "pygments_lexer": "ipython3",
   "version": "3.12.3"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
