{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "ac0aec78",
   "metadata": {},
   "source": "# Classes, instances, and `__init__`\n\nA class is a blueprint. It says \"objects of this kind have these attributes and these methods.\" An *instance* is a specific object built from that blueprint. Every time you call the class — `Counter()`, `Account()`, `MyType()` — Python builds a fresh instance.\n\nThis first notebook covers just enough to make a useful class: the `class` keyword, `__init__`, what `self` actually is, and how methods work. Later notebooks layer on dunder methods, data classes, and inheritance."
  },
  {
   "cell_type": "markdown",
   "id": "f03cf64f",
   "metadata": {},
   "source": "## A minimal class\n\nThe smallest possible class definition is a name and `pass`. It does nothing, but it gives you a type you can create instances of."
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cbf41cad",
   "metadata": {},
   "outputs": [],
   "source": "class Counter:\n    pass\n\nc = Counter()\nprint(c)\nprint(type(c))"
  },
  {
   "cell_type": "markdown",
   "id": "82dbca14",
   "metadata": {},
   "source": "Two things happened. The `class Counter:` block defined a new type called `Counter`. Then `Counter()` called that type like a function and gave us back a new instance.\n\nThe instance prints as something like `<__main__.Counter object at 0x...>`. That hex address is the memory location — useful for distinguishing two instances apart, useless for anything else. We'll fix the ugly representation in the [dunder methods](https://agilearn.co.uk/guides/classes-and-objects/learn/02-dunder-methods) notebook with `__repr__`."
  },
  {
   "cell_type": "markdown",
   "id": "8ce449bb",
   "metadata": {},
   "source": "## Instances hold attributes\n\nAn attribute is just a value attached to an instance under a name. You can attach attributes from outside the class with normal assignment:"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "a683c806",
   "metadata": {},
   "outputs": [],
   "source": "c.count = 0\nc.count += 1\nc.count += 1\nprint(c.count)"
  },
  {
   "cell_type": "markdown",
   "id": "747519d1",
   "metadata": {},
   "source": "That works, but it's a bad pattern in real code. The class itself should set up its attributes when an instance is created — otherwise every caller has to remember to initialise them, and you end up with instances in inconsistent states. The mechanism for this is `__init__`."
  },
  {
   "cell_type": "markdown",
   "id": "22c8fb42",
   "metadata": {},
   "source": "## `__init__` and `self`\n\n`__init__` is a method that Python calls automatically every time you create an instance. Inside it, you set up the instance's attributes. The first parameter is conventionally called `self` and refers to the instance being built."
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "0ef5a18d",
   "metadata": {},
   "outputs": [],
   "source": "class Counter:\n    def __init__(self, start=0):\n        self.count = start\n\nc1 = Counter()\nc2 = Counter(start=10)\nprint(c1.count, c2.count)"
  },
  {
   "cell_type": "markdown",
   "id": "7a7da750",
   "metadata": {},
   "source": "Notice that you call `Counter(start=10)` — you don't pass `self`. Python passes the new instance as `self` for you. The arguments you pass in the call go to the *other* parameters of `__init__`."
  },
  {
   "cell_type": "markdown",
   "id": "f7921e10",
   "metadata": {},
   "source": "### What `self` actually is\n\nThere's no magic in `self`. It's just a normal parameter; Python passes the instance as the first argument when you call a method via the dot syntax. These two calls are equivalent:"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "ff9eeb69",
   "metadata": {},
   "outputs": [],
   "source": "class Greeter:\n    def __init__(self, name):\n        self.name = name\n    def hello(self):\n        return f\"Hello, {self.name}!\"\n\ng = Greeter(\"Ada\")\n\nprint(g.hello())\nprint(Greeter.hello(g))"
  },
  {
   "cell_type": "markdown",
   "id": "55135ec6",
   "metadata": {},
   "source": "Both call the same method with the same instance. The dot form `g.hello()` is the one you'll always use; the explicit form `Greeter.hello(g)` shows that `self` is just the first positional argument. The name `self` is convention only — Python doesn't care, but every Python programmer expects it, so use it."
  },
  {
   "cell_type": "markdown",
   "id": "33e00746",
   "metadata": {},
   "source": "## Methods\n\nMethods are functions defined inside a class. They take `self` as their first parameter and access instance attributes through it."
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "e72187c7",
   "metadata": {},
   "outputs": [],
   "source": "class Counter:\n    def __init__(self, start=0):\n        self.count = start\n\n    def increment(self, by=1):\n        self.count += by\n\n    def reset(self):\n        self.count = 0\n\nc = Counter()\nc.increment()\nc.increment(by=5)\nprint(c.count)\nc.reset()\nprint(c.count)"
  },
  {
   "cell_type": "markdown",
   "id": "c0a2158b",
   "metadata": {},
   "source": "Each method modifies `self.count` directly. Notice that `increment` and `reset` don't return anything — they mutate the instance in place. That's fine for methods whose whole job is to change state. Methods that compute a value should return it explicitly."
  },
  {
   "cell_type": "markdown",
   "id": "26ab33c2",
   "metadata": {},
   "source": "## A more realistic example\n\nHere's a small bank account class — enough state and behaviour to feel like real code, small enough to fit in your head."
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "5ccf3b8b",
   "metadata": {},
   "outputs": [],
   "source": "class Account:\n    def __init__(self, owner, opening_balance=0):\n        self.owner = owner\n        self.balance = opening_balance\n\n    def deposit(self, amount):\n        if amount <= 0:\n            raise ValueError(\"deposit amount must be positive\")\n        self.balance += amount\n\n    def withdraw(self, amount):\n        if amount <= 0:\n            raise ValueError(\"withdrawal amount must be positive\")\n        if amount > self.balance:\n            raise ValueError(\"insufficient funds\")\n        self.balance -= amount\n\nacct = Account(\"Ada\", opening_balance=100)\nacct.deposit(50)\nacct.withdraw(30)\nprint(f\"{acct.owner}: £{acct.balance}\")"
  },
  {
   "cell_type": "markdown",
   "id": "cf868c39",
   "metadata": {},
   "source": "Two things to notice:\n\n1. The class enforces invariants. Outside code can't set the balance to a negative number through the documented interface — `withdraw` raises rather than allowing it. (We'll see in [validate attributes on assignment](https://agilearn.co.uk/guides/classes-and-objects/recipes/validate-attributes-on-assignment) how to enforce this even when callers set `acct.balance` directly.)\n2. The data and behaviour live together. To find out what an `Account` can do, you read one class. To achieve the same with separate functions and dicts, you'd hunt through several modules."
  },
  {
   "cell_type": "markdown",
   "id": "636ffea1",
   "metadata": {},
   "source": "## Attributes can be set anywhere — but shouldn't be\n\nPython lets you add attributes to an instance at any time. This is sometimes useful but usually a mistake — it makes the class's interface fuzzy and hard to reason about."
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "7762d20d",
   "metadata": {},
   "outputs": [],
   "source": "acct.nickname = \"Rainy day fund\"   # works, but where is this documented?\nprint(acct.nickname)"
  },
  {
   "cell_type": "markdown",
   "id": "78e07dfb",
   "metadata": {},
   "source": "Convention: set every attribute in `__init__`, even if its initial value is `None`. That way, reading the class definition tells you everything an instance carries. Data classes (notebook [03](https://agilearn.co.uk/guides/classes-and-objects/learn/03-data-classes)) make this even more explicit."
  },
  {
   "cell_type": "markdown",
   "id": "0144eb32",
   "metadata": {},
   "source": "## Exercise\n\nDefine a `Rectangle` class with:\n\n- An `__init__` that takes `width` and `height`.\n- An `area()` method that returns width times height.\n- A `scale(factor)` method that multiplies both sides by `factor` in place.\n\nTest it by creating a 3×4 rectangle, printing its area, scaling by 2, and printing the area again."
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "766dcb6e",
   "metadata": {},
   "outputs": [],
   "source": "# Your code here\n"
  },
  {
   "cell_type": "markdown",
   "id": "dba4c49f",
   "metadata": {},
   "source": "<details>\n<summary>Solution</summary>\n\n```python\nclass Rectangle:\n    def __init__(self, width, height):\n        self.width = width\n        self.height = height\n\n    def area(self):\n        return self.width * self.height\n\n    def scale(self, factor):\n        self.width *= factor\n        self.height *= factor\n\nr = Rectangle(3, 4)\nprint(r.area())   # 12\nr.scale(2)\nprint(r.area())   # 48\n```\n</details>"
  },
  {
   "cell_type": "markdown",
   "id": "7d2ed259",
   "metadata": {},
   "source": "## Recap\n\n- A `class` defines a type; calling it builds an instance.\n- `__init__` runs automatically when an instance is created. Use it to set up every attribute the instance will need.\n- `self` is the instance — Python passes it as the first argument to methods automatically.\n- Methods are just functions that take `self` and operate on it.\n\nNext: [Dunder methods](https://agilearn.co.uk/guides/classes-and-objects/learn/02-dunder-methods), where we make a class feel like a built-in by implementing `__repr__`, `__eq__`, ordering, and friends."
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "name": "python",
   "version": "3.10"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}