# OOP: Classes and Objects

## 1. Defining a Class

```python
class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y

    def distance_from_origin(self):
        return (self.x ** 2 + self.y ** 2) ** 0.5

p = Point(3, 4)
print(p.distance_from_origin())  # 5.0
```

`__init__` is the initializer, called automatically right after a new instance is created. `self` is the instance itself — Python passes it explicitly as the first parameter of every instance method (there's no implicit `this`).

## 2. Instance Attributes vs Class Attributes

```python
class Dog:
    species = "Canis familiaris"   # class attribute — shared by ALL instances

    def __init__(self, name):
        self.name = name             # instance attribute — unique per instance

d1 = Dog("Rex")
d2 = Dog("Fido")
print(d1.species, d2.species)   # both "Canis familiaris" — same shared object
Dog.species = "changed"
print(d1.species)                # "changed" — instances see class-level changes

d1.species = "just d1's"          # creates a NEW instance attribute, shadowing the class one
print(d1.species, d2.species)     # "just d1's" "changed"
```

A class attribute lives once on the class object; an instance attribute lives on that specific instance's `__dict__`. Reading `d1.species` looks in `d1.__dict__` first, and only falls back to `Dog.__dict__` if not found there — which is exactly why assigning `d1.species = ...` shadows rather than mutates the shared value.

## 3. Dunder (Magic) Methods

```python
class Money:
    def __init__(self, amount):
        self.amount = amount

    def __repr__(self):
        return f"Money({self.amount})"

    def __eq__(self, other):
        return isinstance(other, Money) and self.amount == other.amount

    def __add__(self, other):
        return Money(self.amount + other.amount)

    def __lt__(self, other):
        return self.amount < other.amount

a, b = Money(10), Money(5)
print(a + b)       # Money(15) — uses __add__
print(a == Money(10))  # True — uses __eq__, not identity
print(a > b)         # True — Python derives > from __lt__ via reflection when needed
```

Dunder methods are how Python objects hook into built-in syntax and functions: `+` calls `__add__`, `len()` calls `__len__`, `str()` calls `__str__`, `for` calls `__iter__`, `[]` calls `__getitem__`, and so on. Defining `__repr__` (unambiguous, for developers) and `__str__` (readable, for end users) is standard practice — `__repr__` is used as a fallback if `__str__` isn't defined.

## 4. Properties — Computed Attributes with Attribute Syntax

```python
class Circle:
    def __init__(self, radius):
        self._radius = radius

    @property
    def area(self):
        return 3.14159 * self._radius ** 2

    @property
    def radius(self):
        return self._radius

    @radius.setter
    def radius(self, value):
        if value < 0:
            raise ValueError("radius cannot be negative")
        self._radius = value

c = Circle(2)
print(c.area)     # 12.56636 — computed, called with NO parentheses
c.radius = 5        # goes through the setter, validated
c.radius = -1        # raises ValueError
```

`@property` lets a method be accessed with plain attribute syntax (`c.area`, not `c.area()`), and pairing it with `@<name>.setter` gives you validated, controlled attribute assignment — encapsulation without forcing callers to write explicit getter/setter method calls.

## 5. `__slots__` — Opting Out of Per-Instance `__dict__`

```python
class Point:
    __slots__ = ("x", "y")   # no per-instance __dict__ at all

    def __init__(self, x, y):
        self.x = x
        self.y = y

p = Point(1, 2)
p.z = 3   # AttributeError — __slots__ forbids attributes not listed
```

Declaring `__slots__` tells CPython to allocate fixed, typed storage slots for exactly those attribute names instead of giving every instance a general-purpose `__dict__`. This trades flexibility (no dynamically adding new attributes, more friction with multiple inheritance) for meaningfully lower per-instance memory use when creating many instances of a simple class.

## 6. Class Methods and Static Methods

```python
class Pizza:
    def __init__(self, toppings):
        self.toppings = toppings

    @classmethod
    def margherita(cls):
        return cls(["mozzarella", "tomato"])   # cls, not the hardcoded class name — subclass-friendly

    @staticmethod
    def is_valid_topping(name):
        return name in {"mozzarella", "tomato", "basil", "pepperoni"}

p = Pizza.margherita()
print(Pizza.is_valid_topping("basil"))  # True
```

`@classmethod` receives the class itself (`cls`) instead of an instance — commonly used for alternative constructors. `@staticmethod` receives neither `self` nor `cls`; it's a plain function namespaced under the class purely for organization.

## 7. Under the Hood: Instances, `__dict__`, and Attribute Lookup

### An "ordinary" instance is a thin wrapper around a `__dict__`

```python
class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y

p = Point(1, 2)
print(p.__dict__)   # {'x': 1, 'y': 2} — a REAL dict, same machinery as any other dict
```

By default, every instance carries its own `__dict__` — the same compact hash-table structure covered in the Data Structures topic. `self.x = x` inside `__init__` is, mechanically, a dict insertion into `p.__dict__`. This is why arbitrary attributes can be added to most Python objects at any time (`p.new_attr = 5` just works) — there's a real, general-purpose hash table backing it, not a fixed C-struct layout.

### Attribute lookup walks instance `__dict__`, then the type, through the descriptor protocol

```python
class Dog:
    species = "Canis familiaris"
    def bark(self):
        return "Woof"

d = Dog()
d.bark  # not in d.__dict__ — found on type(d).__dict__ instead, and BOUND on the way out
```

`d.bark` triggers `object.__getattribute__`, which checks `type(d).__dict__` for a **data descriptor** (something with both `__get__` and `__set__`, like a `property`) first, then `d.__dict__`, then `type(d).__dict__` again for a **non-data descriptor** (something with only `__get__`, like a plain function) or a plain class attribute. Functions implement `__get__`, which is precisely the mechanism that turns `Dog.bark` (a plain function) into a bound method when accessed through an instance (see the Functions topic's "bound methods" section for the full descriptor mechanics). `property` is implemented the same way — as a data descriptor — which is *why* a property takes priority over an instance attribute of the same name even though instance `__dict__` is normally checked first.

![Attribute lookup order: data descriptors on the type win first, then instance __dict__, then non-data descriptors/class attributes](diagrams/01-attribute-lookup-order.svg)

### `__slots__` trades the `__dict__` for fixed C-level slots

When a class defines `__slots__`, CPython does not allocate a `__dict__` for its instances at all (unless a base class still provides one). Instead, each named slot becomes a **descriptor** on the class that reads/writes a fixed-offset C struct member directly on the instance — closer to how a field works in a statically-typed language. This is strictly a memory/speed trade-off: no `__dict__` per instance means no hash table overhead per object (meaningful when instantiating millions of small objects), at the cost of losing dynamic attribute assignment and needing every base class in the hierarchy to cooperate with `__slots__` to fully avoid a `__dict__` appearing anyway.

## 8. Common Pitfalls

1. **Mutable class attributes shared unintentionally** — `class Foo: items = []` creates ONE list shared by every instance unless overridden in `__init__`; mutating it through one instance affects all of them. Initialize mutable state in `__init__` as `self.items = []`.
2. **Forgetting `self` isn't magic** — it's just the first parameter, conventionally named `self`; `def method(this): ...` works identically, but don't do it — every Python codebase expects `self`.
3. **Defining `__eq__` without `__hash__`** — Python automatically sets `__hash__ = None` when you define `__eq__` without `__hash__`, making instances unhashable (can't go in a `set` or be a `dict` key) unless you explicitly define `__hash__` too.
4. **Expecting `__slots__` to save memory when a base class doesn't also use it** — if any class in the MRO lacks `__slots__`, instances still get a `__dict__`, and the memory savings evaporate.

## 9. Quick Reference

```python
class C:
    class_attr = 0                  # shared across all instances
    def __init__(self, x):
        self.x = x                    # instance attribute, in self.__dict__
    def __repr__(self): ...            # repr(obj) / debugger display
    def __eq__(self, other): ...        # obj == other
    @property
    def computed(self): ...              # obj.computed — no parens
    @classmethod
    def from_x(cls, x): return cls(x)     # alternative constructor
    @staticmethod
    def helper(): ...                       # no self/cls at all
    __slots__ = ("x", "y")                    # no per-instance __dict__
```
