{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "febe7131",
   "metadata": {},
   "source": "# Inheritance and composition\n\nInheritance is the thing *other* languages spend half the object-orientation course on. Python has it, but Python programmers reach for it less often than you might expect. This notebook covers the mechanics — subclassing, `super()`, the MRO — and then spends at least as much time on *when not to use it*. The short form: prefer composition unless inheritance really does express what you mean.\n\nThe [composition over inheritance concept essay](https://agilearn.co.uk/guides/classes-and-objects/concepts/composition-over-inheritance) goes deeper on the philosophy. Here we focus on the mechanics and the common traps."
  },
  {
   "cell_type": "markdown",
   "id": "c60d06af",
   "metadata": {},
   "source": "## Basic subclassing\n\nA subclass inherits everything the parent class defines — attributes, methods, and any dunders. Listing the parent in parentheses is enough."
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "6909f840",
   "metadata": {},
   "outputs": [],
   "source": "class Animal:\n    def __init__(self, name):\n        self.name = name\n\n    def describe(self):\n        return f\"{self.name} is an animal\"\n\nclass Dog(Animal):\n    def bark(self):\n        return f\"{self.name} says woof\"\n\nd = Dog(\"Rex\")\nprint(d.describe())   # inherited from Animal\nprint(d.bark())       # defined on Dog"
  },
  {
   "cell_type": "markdown",
   "id": "2b895725",
   "metadata": {},
   "source": "## `super()` — calling the parent's methods\n\nWhen a subclass defines its own `__init__`, Python doesn't call the parent's `__init__` automatically. If you want the parent to do its setup work, call `super().__init__(...)` explicitly."
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "9d11ac49",
   "metadata": {},
   "outputs": [],
   "source": "class Vehicle:\n    def __init__(self, make, model):\n        self.make = make\n        self.model = model\n\nclass Car(Vehicle):\n    def __init__(self, make, model, num_doors):\n        super().__init__(make, model)\n        self.num_doors = num_doors\n\nc = Car(\"Honda\", \"Civic\", num_doors=4)\nprint(c.make, c.model, c.num_doors)"
  },
  {
   "cell_type": "markdown",
   "id": "db771ace",
   "metadata": {},
   "source": "The same pattern works for any method. Call `super().method_name(...)` to delegate to the parent's version — useful when you want to extend rather than replace the parent's behaviour:"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "9cc6d7d1",
   "metadata": {},
   "outputs": [],
   "source": "class LoudCar(Car):\n    def __init__(self, make, model, num_doors):\n        super().__init__(make, model, num_doors)\n        print(f\"VROOM — new {make} {model} arrived\")\n\nlc = LoudCar(\"Ford\", \"Mustang\", num_doors=2)"
  },
  {
   "cell_type": "markdown",
   "id": "c4f5f157",
   "metadata": {},
   "source": "## Method resolution order\n\nWhen you look up an attribute on an instance and multiple classes in the hierarchy could provide it, Python follows a deterministic path called the *method resolution order* (MRO). For single inheritance it's simply child-then-parent. For multiple inheritance it's more involved, computed by the C3 linearisation algorithm.\n\nYou can inspect it:"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "d7154f39",
   "metadata": {},
   "outputs": [],
   "source": "class A:\n    def who(self): return \"A\"\n\nclass B(A):\n    def who(self): return \"B\"\n\nclass C(A):\n    def who(self): return \"C\"\n\nclass D(B, C):\n    pass\n\nprint([cls.__name__ for cls in D.__mro__])\nprint(D().who())"
  },
  {
   "cell_type": "markdown",
   "id": "bbba6949",
   "metadata": {},
   "source": "Reading the MRO from left to right: a `D` instance looks for `who` on `D`, then `B`, then `C`, then `A`, then `object`. It finds `who` on `B` and stops. That's C3 linearisation in action.\n\nIn practice, if you find yourself reasoning carefully about MRO to understand your own code, that's a strong signal the design has got too clever. Stick to single inheritance plus small mixins, and the MRO stays easy to reason about."
  },
  {
   "cell_type": "markdown",
   "id": "07117045",
   "metadata": {},
   "source": "## The is-a / has-a trap\n\nThe classic inheritance mistake: reaching for `class Child(Parent)` when the relationship is actually \"has a\" rather than \"is a\". Here's a concrete example — a `Stack` that inherits from `list`:"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "6b4f09fa",
   "metadata": {},
   "outputs": [],
   "source": "class Stack(list):\n    def push(self, item):\n        self.append(item)\n\n    def pop_top(self):\n        return self.pop()\n\ns = Stack()\ns.push(1)\ns.push(2)\nprint(s.pop_top())"
  },
  {
   "cell_type": "markdown",
   "id": "427acd50",
   "metadata": {},
   "source": "That works, but it's wrong. A stack should only allow access at the top — push and pop. Our `Stack` inherited the full `list` interface, so callers can reach past the abstraction:"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "55549455",
   "metadata": {},
   "outputs": [],
   "source": "s = Stack()\ns.push(1)\ns.push(2)\ns.insert(0, 99)      # legal on a list, violates stack semantics\ns[0] = 42            # also legal, also wrong\nprint(s)"
  },
  {
   "cell_type": "markdown",
   "id": "e3ebb947",
   "metadata": {},
   "source": "The fix is composition: a `Stack` *has a* list internally, and exposes only the operations that preserve its invariants."
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "ec9245b5",
   "metadata": {},
   "outputs": [],
   "source": "class Stack:\n    def __init__(self):\n        self._items = []\n\n    def push(self, item):\n        self._items.append(item)\n\n    def pop(self):\n        return self._items.pop()\n\n    def __len__(self):\n        return len(self._items)\n\n    def __repr__(self):\n        return f\"Stack({self._items!r})\"\n\ns = Stack()\ns.push(1)\ns.push(2)\nprint(s, len(s))\n\ntry:\n    s.insert(0, 99)   # no such method — the abstraction holds\nexcept AttributeError as e:\n    print(f\"{type(e).__name__}: {e}\")"
  },
  {
   "cell_type": "markdown",
   "id": "99dc1f0e",
   "metadata": {},
   "source": "The test for whether inheritance is appropriate: can the subclass be used anywhere the parent is expected *without surprising the caller*? If `Stack` inherits from `list`, callers who receive a `Stack` reasonably expect `.insert()` to work like it does on a list — but your whole point in making a Stack was to disallow that. The inheritance relationship is lying."
  },
  {
   "cell_type": "markdown",
   "id": "75e66f76",
   "metadata": {},
   "source": "## Where inheritance earns its place\n\nInheritance is genuinely useful in a handful of situations. The most common is exception hierarchies:"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "a08c764c",
   "metadata": {},
   "outputs": [],
   "source": "class AppError(Exception):\n    \"\"\"Base exception for this application.\"\"\"\n\nclass ValidationError(AppError):\n    \"\"\"User-supplied data was malformed.\"\"\"\n\nclass AuthError(AppError):\n    \"\"\"User was not permitted.\"\"\"\n\ntry:\n    raise ValidationError(\"email missing @\")\nexcept AppError as e:\n    print(f\"caught {type(e).__name__}: {e}\")"
  },
  {
   "cell_type": "markdown",
   "id": "3f01dedf",
   "metadata": {},
   "source": "The hierarchy lets callers catch broadly (`except AppError`) or specifically (`except ValidationError`) as they prefer. That's a clean fit for inheritance: subclasses are genuinely specialisations of their parent, all exception-shaped.\n\nOther good fits:\n\n- **Framework extension points** — subclassing Django's `View`, PyTorch's `nn.Module`, or scikit-learn's `BaseEstimator`. The framework expects specific hooks and inheritance is how you provide them.\n- **Abstract base classes** (briefly below) — declaring an interface that subclasses must implement.\n- **Small mixins** — a class whose sole job is adding one capability, combined with the main class through multiple inheritance. Use sparingly."
  },
  {
   "cell_type": "markdown",
   "id": "140d6731",
   "metadata": {},
   "source": "## Abstract base classes\n\n`abc.ABC` lets you declare methods that subclasses *must* implement. Python enforces this at instantiation time — you can't make an instance of a class that still has unimplemented abstract methods."
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "60b87372",
   "metadata": {},
   "outputs": [],
   "source": "from abc import ABC, abstractmethod\n\nclass Shape(ABC):\n    @abstractmethod\n    def area(self):\n        ...\n\nclass Circle(Shape):\n    def __init__(self, radius):\n        self.radius = radius\n\n    def area(self):\n        return 3.14159 * self.radius ** 2\n\nprint(Circle(5).area())\n\ntry:\n    Shape()               # can't instantiate — area is abstract\nexcept TypeError as e:\n    print(f\"{type(e).__name__}: {e}\")"
  },
  {
   "cell_type": "markdown",
   "id": "c5f3c8ae",
   "metadata": {},
   "source": "ABCs are useful when you're building a framework and want to document contracts for user code. For smaller internal projects they're often more ceremony than they earn — `typing.Protocol` (covered in the [type hints guide](https://agilearn.co.uk/guides/type-hints)) gives you structural typing without requiring the inheritance relationship."
  },
  {
   "cell_type": "markdown",
   "id": "c52bfbcc",
   "metadata": {},
   "source": "## Exercise\n\nYou have a `Logger` class:\n\n```python\nclass Logger:\n    def __init__(self, name):\n        self.name = name\n    def log(self, level, message):\n        print(f\"[{level.upper()}] {self.name}: {message}\")\n```\n\nYou want to add a `TimestampedLogger` that prepends the current time to each message. Implement it as a subclass that overrides `log` and calls `super().log(...)` for the actual output.\n\nThen — *as a separate exercise* — implement a `MetricsReporter` that writes metrics somewhere. The catch: `MetricsReporter` needs logging, but it isn't itself a kind of logger. Should it inherit from `Logger` or compose one as a field? Justify your choice in a comment, then write the version you think is right."
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "21307602",
   "metadata": {},
   "outputs": [],
   "source": "# Your code here\n"
  },
  {
   "cell_type": "markdown",
   "id": "8f11d24a",
   "metadata": {},
   "source": "<details>\n<summary>Solution</summary>\n\n```python\nfrom datetime import datetime\n\nclass Logger:\n    def __init__(self, name):\n        self.name = name\n    def log(self, level, message):\n        print(f\"[{level.upper()}] {self.name}: {message}\")\n\n# Inheritance is appropriate: TimestampedLogger IS a Logger —\n# it supports the same interface and the same call sites work unchanged.\nclass TimestampedLogger(Logger):\n    def log(self, level, message):\n        ts = datetime.now().isoformat(timespec=\"seconds\")\n        super().log(level, f\"[{ts}] {message}\")\n\n# Composition is appropriate: MetricsReporter HAS-A Logger.\n# A MetricsReporter is not a kind of logger — it's a thing that\n# happens to use logging. Inheriting from Logger would let callers\n# treat a MetricsReporter as a drop-in Logger, which is wrong.\nclass MetricsReporter:\n    def __init__(self, name):\n        self.logger = Logger(name)\n\n    def report(self, metric, value):\n        # ... send metric to metrics backend ...\n        self.logger.log(\"info\", f\"reported {metric}={value}\")\n```\n</details>"
  },
  {
   "cell_type": "markdown",
   "id": "ec2af545",
   "metadata": {},
   "source": "## Recap\n\n- Subclassing inherits the parent's attributes and methods. `class Child(Parent):` is all it takes.\n- `super()` calls the parent's version of a method. Use it in overrides and in `__init__`.\n- The MRO determines which method wins in multi-inheritance. Keep hierarchies shallow and you'll rarely need to think about it.\n- Favour composition over inheritance. Reach for inheritance only when the subclass genuinely **is a** kind of the parent, usable anywhere the parent is expected.\n- Good fits for inheritance: exception hierarchies, framework extension points, abstract base classes, small mixins.\n\nNext: [Class attributes, properties, classmethods, and staticmethods](https://agilearn.co.uk/guides/classes-and-objects/learn/05-class-attributes-and-properties) — the supporting cast that rounds out a class definition."
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "name": "python",
   "version": "3.10"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}