{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "a3c7cfd0",
   "metadata": {},
   "source": "# Class attributes, properties, classmethods, and staticmethods\n\nThe supporting cast. Once you have classes, instances, dunders, and maybe some inheritance, there's a handful of smaller features that round out what you can express: attributes that belong to the class rather than an instance, attributes that run code when read or written, and methods that don't take `self`.\n\nNone of these are showstoppers — you could write an entire application without reaching for any of them — but each one has a natural use case, and knowing when to reach for what keeps your classes readable."
  },
  {
   "cell_type": "markdown",
   "id": "b9aea44e",
   "metadata": {},
   "source": "## Class attributes vs instance attributes\n\nEverything set on `self` inside `__init__` is an *instance attribute* — each instance has its own. You can also set attributes on the class itself, outside any method. Those are *class attributes*, shared across all instances."
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "167c1902",
   "metadata": {},
   "outputs": [],
   "source": "class Circle:\n    pi = 3.14159        # class attribute — shared\n\n    def __init__(self, radius):\n        self.radius = radius   # instance attribute — per-instance\n\n    def area(self):\n        return Circle.pi * self.radius ** 2\n\nprint(Circle.pi)            # accessible on the class\nc = Circle(5)\nprint(c.pi)                 # also accessible via an instance\nprint(c.area())"
  },
  {
   "cell_type": "markdown",
   "id": "0d56b88b",
   "metadata": {},
   "source": "Two rules of thumb:\n\n- Use class attributes for true constants that belong to the type: a `DEFAULT_TIMEOUT`, a `MAX_RETRIES`, a lookup table. Anything that *every* instance will share.\n- Set everything per-instance in `__init__`. If two instances should hold different values, it must be an instance attribute."
  },
  {
   "cell_type": "markdown",
   "id": "dd804df2",
   "metadata": {},
   "source": "### The mutable class attribute trap\n\nThis is the single most common class-attribute bug. If the class attribute is a mutable object, every instance shares the *same* object:"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "262c66ae",
   "metadata": {},
   "outputs": [],
   "source": "class Basket:\n    items = []             # mutable class attribute — SHARED!\n\n    def add(self, item):\n        self.items.append(item)\n\na = Basket()\nb = Basket()\na.add(\"apple\")\nprint(b.items)             # ['apple'] — b sees a's items"
  },
  {
   "cell_type": "markdown",
   "id": "9e7ab5bc",
   "metadata": {},
   "source": "The fix is to set mutable attributes per-instance in `__init__`:"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "b02778ee",
   "metadata": {},
   "outputs": [],
   "source": "class Basket:\n    def __init__(self):\n        self.items = []    # per-instance\n\n    def add(self, item):\n        self.items.append(item)\n\na = Basket()\nb = Basket()\na.add(\"apple\")\nprint(a.items, b.items)"
  },
  {
   "cell_type": "markdown",
   "id": "ba50447a",
   "metadata": {},
   "source": "`@dataclass` with `field(default_factory=list)` — from the [data classes notebook](https://agilearn.co.uk/guides/classes-and-objects/learn/03-data-classes) — solves the same problem more elegantly when you're defining many fields."
  },
  {
   "cell_type": "markdown",
   "id": "151fb98f",
   "metadata": {},
   "source": "## `@property` — computed and validated attributes\n\n`@property` turns a method into something that looks like an attribute. Reading `c.diameter` runs the method; you don't type the parentheses."
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "745d3c86",
   "metadata": {},
   "outputs": [],
   "source": "class Circle:\n    def __init__(self, radius):\n        self.radius = radius\n\n    @property\n    def diameter(self):\n        return self.radius * 2\n\nc = Circle(5)\nprint(c.diameter)     # no parentheses — looks like an attribute"
  },
  {
   "cell_type": "markdown",
   "id": "58706653",
   "metadata": {},
   "source": "That's useful when a value is cheap to compute and always derivable from the state you already have. You save callers from having to remember whether it's an attribute or a method, and if the internal representation changes later, callers don't notice.\n\nAdd a *setter* and the attribute becomes writable, with your code running on every assignment. Use this for validation — enforce invariants even when callers assign directly:"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "46a601f3",
   "metadata": {},
   "outputs": [],
   "source": "class Circle:\n    def __init__(self, radius):\n        self.radius = radius      # triggers the setter\n\n    @property\n    def radius(self):\n        return self._radius\n\n    @radius.setter\n    def radius(self, value):\n        if value <= 0:\n            raise ValueError(\"radius must be positive\")\n        self._radius = value\n\nc = Circle(5)\nc.radius = 10                     # works\n\ntry:\n    c.radius = -1                 # caught by setter\nexcept ValueError as e:\n    print(f\"{type(e).__name__}: {e}\")"
  },
  {
   "cell_type": "markdown",
   "id": "136b89e2",
   "metadata": {},
   "source": "The underlying storage lives on `self._radius` (leading underscore by convention — \"don't poke at this directly\"). The public name `radius` is the property.\n\nResist the urge to add properties everywhere. If all your getter does is `return self._x` and all your setter does is `self._x = value`, that's just an attribute with extra ceremony. Add properties only when you need validation, lazy computation, or an interface-preserving façade over storage that's likely to change."
  },
  {
   "cell_type": "markdown",
   "id": "197c2e5e",
   "metadata": {},
   "source": "## `@classmethod` — alternative constructors and class-level operations\n\nA classmethod's first parameter is `cls`, the class itself, rather than `self`. The most common use is an alternative constructor — a factory method that builds an instance from some non-standard input."
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "c0e8b812",
   "metadata": {},
   "outputs": [],
   "source": "from dataclasses import dataclass\n\n@dataclass\nclass Date:\n    year: int\n    month: int\n    day: int\n\n    @classmethod\n    def from_iso(cls, s):\n        year, month, day = s.split(\"-\")\n        return cls(int(year), int(month), int(day))\n\nd = Date.from_iso(\"2026-04-21\")\nprint(d)"
  },
  {
   "cell_type": "markdown",
   "id": "b2524cf9",
   "metadata": {},
   "source": "`cls(...)` is how you construct an instance. Using `cls` (rather than hard-coding `Date(...)`) means subclasses get the right behaviour — if someone defines `class BusinessDate(Date)`, `BusinessDate.from_iso(\"...\")` returns a `BusinessDate`, not a `Date`."
  },
  {
   "cell_type": "markdown",
   "id": "c27cf2e7",
   "metadata": {},
   "source": "## `@staticmethod` — a function that happens to live in the class\n\nA staticmethod has neither `self` nor `cls`. It's a plain function — the only reason it lives on the class is namespacing."
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "ada70f41",
   "metadata": {},
   "outputs": [],
   "source": "class Temperature:\n    def __init__(self, celsius):\n        self.celsius = celsius\n\n    @staticmethod\n    def celsius_to_fahrenheit(c):\n        return c * 9 / 5 + 32\n\nprint(Temperature.celsius_to_fahrenheit(100))"
  },
  {
   "cell_type": "markdown",
   "id": "030c215b",
   "metadata": {},
   "source": "Staticmethods are useful for utility functions that are clearly associated with the class but don't need access to an instance or the class itself. If you find yourself reaching for `@staticmethod` frequently, consider whether a module-level function would be cleaner — classes aren't just namespaces."
  },
  {
   "cell_type": "markdown",
   "id": "286e2e81",
   "metadata": {},
   "source": "## Choosing between the three\n\n| Decorator | First arg | Use when |\n| --- | --- | --- |\n| plain method | `self` | You need the instance's state. |\n| `@classmethod` | `cls` | You need the class itself — typically for an alternative constructor. |\n| `@staticmethod` | none | The method belongs on the class by topic, not by needing any state. |\n| `@property` | `self` | You want attribute-style access to a computed or validated value. |"
  },
  {
   "cell_type": "markdown",
   "id": "34f63715",
   "metadata": {},
   "source": "## Exercise\n\nBuild an `Account` class with:\n\n- Class attribute `MINIMUM_BALANCE = 0` (overridable by subclasses).\n- `__init__(self, owner, balance=0)`.\n- A `balance` *property* with a setter that rejects values below `MINIMUM_BALANCE`.\n- A `classmethod` `from_dict(cls, data)` that builds an account from a dictionary like `{\"owner\": \"Ada\", \"balance\": 100}`.\n- A `staticmethod` `is_valid_owner_name(name)` that returns True if `name` is a non-empty string.\n\nTest each piece: create an account, try setting a negative balance, build one from a dict, validate a name."
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "6a12d5a6",
   "metadata": {},
   "outputs": [],
   "source": "# Your code here\n"
  },
  {
   "cell_type": "markdown",
   "id": "c38faa59",
   "metadata": {},
   "source": "<details>\n<summary>Solution</summary>\n\n```python\nclass Account:\n    MINIMUM_BALANCE = 0\n\n    def __init__(self, owner, balance=0):\n        self.owner = owner\n        self.balance = balance       # triggers the setter\n\n    @property\n    def balance(self):\n        return self._balance\n\n    @balance.setter\n    def balance(self, value):\n        if value < self.MINIMUM_BALANCE:\n            raise ValueError(\n                f\"balance cannot go below {self.MINIMUM_BALANCE}\"\n            )\n        self._balance = value\n\n    @classmethod\n    def from_dict(cls, data):\n        return cls(owner=data[\"owner\"], balance=data[\"balance\"])\n\n    @staticmethod\n    def is_valid_owner_name(name):\n        return isinstance(name, str) and len(name.strip()) > 0\n\na = Account.from_dict({\"owner\": \"Ada\", \"balance\": 100})\nprint(a.balance)\nprint(Account.is_valid_owner_name(\"Ada\"))\n```\n</details>"
  },
  {
   "cell_type": "markdown",
   "id": "b4d522d5",
   "metadata": {},
   "source": "## Recap\n\n- Class attributes are shared across instances. Use them for true constants. Avoid mutable ones — set mutable state per-instance in `__init__`.\n- `@property` lets a method pretend to be an attribute. Reach for it when you want computed values or validation on assignment.\n- `@classmethod` takes `cls` instead of `self`. The canonical use is alternative constructors.\n- `@staticmethod` takes neither. Use it sparingly — module-level functions are often cleaner.\n\nYou've now seen enough to write classes that feel at home in Python. The [Recipes](https://agilearn.co.uk/guides/classes-and-objects/recipes) section covers specific tasks in more depth, and the [Reference](https://agilearn.co.uk/guides/classes-and-objects/reference) section is the lookup for dunders and decorator options."
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "name": "python",
   "version": "3.10"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}