12 — Classes & Objects

Production Data Classes — @dataclass with Validation and __slots__

python
# ── Production: a validated, memory-efficient data class ──
# Combines @dataclass (auto __init__/__repr__/__eq__) with __slots__ (no __dict__)
# and __post_init__ for validation that can't be expressed in type hints alone.

from dataclasses import dataclass, field
from datetime import datetime

@dataclass(frozen=True, slots=True)
class User:
    """
    Immutable user record — frozen=True makes it hashable (can be a dict key/set member),
    slots=True eliminates __dict__ overhead (~40% memory reduction per instance).
    """
    id: int
    email: str
    name: str
    created_at: datetime = field(default_factory=datetime.now)  # mutable default → factory
    roles: frozenset[str] = field(default_factory=frozenset)     # frozenset: immutable + hashable

    def __post_init__(self):
        # frozen=True means attributes can't be set normally — use object.__setattr__
        # to work around the frozen restriction for validation-driven adjustments
        if not self.email or "@" not in self.email:
            raise ValueError(f"invalid email: {self.email!r}")
        if self.id <= 0:
            raise ValueError(f"id must be positive, got {self.id}")

# Frozen + slots dataclass is hashable → usable as dict keys and set members
user = User(id=1, email="ada@example.com", "name"="Ada")
print(user)   # User(id=1, email='ada@example.com', name='Ada', created_at=..., roles=frozenset())
# user.email = "x"  # FrozenInstanceError — can't mutate a frozen dataclass

# Can be used as a dict key (hashable because frozen + all fields hashable)
user_index = {user: "admin"}

# ── Memory comparison: __dict__ vs __slots__ ──
import sys

@dataclass
class UserDict:
    id: int
    email: str
    name: str

@dataclass(slots=True)
class UserSlots:
    id: int
    email: str
    name: str

ud = UserDict(1, "a@b.com", "Ada")
us = UserSlots(1, "a@b.com", "Ada")
print(f"with __dict__:    {sys.getsizeof(ud) + sys.getsizeof(ud.__dict__)} bytes")
print(f"with __slots__:   {sys.getsizeof(us)} bytes")
# Typical: ~232 bytes vs ~64 bytes — 3.6× smaller, compounds at millions of instances

self Is Not Magic

self is just the first parameter of an instance method, conventionally named self — Python passes the instance automatically when you call obj.method(...), which is sugar for ClassName.method(obj, ...).

python
class Counter:
    def __init__(self):
        self.count = 0

    def increment(self):
        self.count += 1
        return self.count

c = Counter()
print(c.increment())        # 1 — sugar for Counter.increment(c)
print(Counter.increment(c))   # 2 — identical, explicit form

There is nothing special about the name self — it's a convention so strong that violating it (naming it this or s) is considered a serious style violation, but the language itself doesn't enforce it.

Instance Attributes vs Class Attributes

python
class Employee:
    company = "Acme Corp"          # CLASS attribute — shared by ALL instances

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

alice = Employee("Alice")
bob = Employee("Bob")

print(alice.company, bob.company)   # Acme Corp Acme Corp — same object, shared
print(Employee.company)               # Acme Corp

Employee.company = "NewCo"              # changes it for ALL instances (and future ones)
print(alice.company, bob.company)         # NewCo NewCo

alice.company = "Alice's Startup"           # creates a NEW instance attribute on alice,
print(alice.company, bob.company)             # SHADOWING the class attribute — doesn't touch it
# Alice's Startup NewCo

Assigning alice.company = ... never modifies Employee.company — it creates a new entry in alice's own __dict__ that shadows the class attribute for lookups on alice specifically. This is the same lookup chain (instance __dict__ first, then class, then base classes via MRO) that chapter 13 covers for inheritance.

The mutable class attribute trap

python
# WRONG — a mutable class attribute is SHARED and mutated in place by every instance
class ShoppingCart:
    items = []                    # class attribute — ONE list shared by all carts!

    def add(self, item):
        self.items.append(item)     # mutates the SHARED list, doesn't create a new one

cart_a = ShoppingCart()
cart_b = ShoppingCart()
cart_a.add("apple")
print(cart_b.items)   # ['apple'] — leaked into cart_b, which never touched cart_a!

# RIGHT — initialize mutable state in __init__, so each instance gets its own
class ShoppingCartFixed:
    def __init__(self):
        self.items = []              # instance attribute — fresh list per instance

cart_c = ShoppingCartFixed()
cart_d = ShoppingCartFixed()
cart_c.add = lambda item: cart_c.items.append(item)
cart_c.add("banana")
print(cart_d.items)   # [] — correctly isolated

This is the class-level cousin of the mutable-default-argument trap from chapter 05 — the underlying mechanism is identical: an object created once, at definition time, and then shared everywhere it's referenced instead of being freshly created per use.

Instance Methods, Class Methods, and Static Methods

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

    def describe(self):                       # instance method — needs an instance
        return f"Pizza with {', '.join(self.toppings)}"

    @classmethod
    def margherita(cls):                       # classmethod — receives the CLASS, not an instance
        return cls(["mozzarella", "basil"])       # cls(...) so subclasses construct correctly

    @staticmethod
    def is_valid_topping(topping):                # staticmethod — no self, no cls, just a namespaced function
        return topping.lower() not in {"pineapple"}

p = Pizza.margherita()
print(p.describe())                    # Pizza with mozzarella, basil
print(Pizza.is_valid_topping("ham"))     # True
print(Pizza.is_valid_topping("pineapple"))  # False (a controversial but valid design opinion)

@classmethod is the idiomatic way to write alternative constructors (from_json, from_config, margherita) — using cls(...) instead of hardcoding Pizza(...) means subclasses inherit a correctly-typed constructor for free:

python
class StuffedCrustPizza(Pizza):
    pass

sc = StuffedCrustPizza.margherita()
print(type(sc))   # <class '__main__.StuffedCrustPizza'> — NOT Pizza, thanks to cls(...)

Every Instance Has a __dict__ (Usually)

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}

p.z = 99            # you can add attributes NOT declared in __init__ at all
print(p.__dict__)     # {'x': 1, 'y': 2, 'z': 99}

Unlike statically-typed languages, Python objects are open by default — any code holding a reference to an instance can add, remove, or overwrite attributes freely, unless the class opts out with __slots__ (chapter 15), which trades this flexibility for memory savings and typo protection.

Dunder Methods: A First Look

Chapter 14 covers these in depth; a brief overview here connects classes to the operators and built-ins you already know.

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

    def __repr__(self):                        # unambiguous, developer-facing representation
        return f"Vector({self.x}, {self.y})"

    def __eq__(self, other):                   # powers ==
        return isinstance(other, Vector) and self.x == other.x and self.y == other.y

    def __add__(self, other):                  # powers +
        return Vector(self.x + other.x, self.y + other.y)

v1 = Vector(1, 2)
v2 = Vector(3, 4)
print(v1 + v2)          # Vector(4, 6) — uses __add__ and __repr__
print(v1 == Vector(1, 2))   # True — uses __eq__
print([v1, v2])                # [Vector(1, 2), Vector(3, 4)] — repr used inside containers

Without __repr__, printing an instance falls back to the unhelpful default <__main__.Vector object at 0x7f...>; without __eq__, == falls back to identity comparison (is), so two "equal" vectors with different x/y values but the same memory address would never occur, but two vectors with identical field values would incorrectly compare unequal.

Checking Types: isinstance vs type() ==

python
class Animal:
    pass

class Dog(Animal):
    pass

d = Dog()

print(isinstance(d, Dog))       # True
print(isinstance(d, Animal))      # True — isinstance respects inheritance
print(type(d) == Animal)            # False — exact type check, ignores inheritance
print(type(d) == Dog)                 # True

Best practice: use isinstance(), not type() ==, for type checks in almost all application code — it respects polymorphism and works correctly with subclasses, which is the entire point of inheritance (chapter 13). Reach for exact type() comparison only in rare cases (like preventing a subclass from being treated identically to its parent in a serializer).

Class Bodies Execute Immediately, Once, at Definition Time

python
print("before class")

class Demo:
    print("inside class body")     # runs ONCE, when the class statement executes
    x = 1 + 1                        # a normal assignment — becomes a class attribute
    print(f"x is now {x}")

print("after class")
print(Demo.x)

# Output:
# before class
# inside class body
# x is now 2
# after class
# 2

This surprises newcomers: a class block isn't a template that's "filled in" per instance — it's regular Python code that executes top-to-bottom exactly once, building a namespace that becomes the class object. This is precisely why a mutable default like items = [] inside the class body is a single, shared object, not a per-instance template — it's evaluated once, just like a default argument.

💡 Tips & Tricks

  • Idiom: use @classmethod for alternate constructors (from_dict, from_file) instead of overloading __init__ with optional-argument juggling — cls(...) also makes the constructor subclass-correct automatically.
  • Debug: vars(obj) is equivalent to obj.__dict__ and is the fastest way to inspect exactly what an instance holds, without any class-level attributes mixed in.
  • Idiom: dataclasses.dataclass (standard library, 3.7+) auto-generates __init__, __repr__, and __eq__ for simple data-holding classes — reach for it before hand-writing boilerplate for a class that's mostly fields.
  • Performance: attribute lookup on an instance without __slots__ goes through a dict, which is fast but not free — for classes instantiated millions of times (rows, particles, graph nodes), __slots__ (chapter 15) meaningfully reduces both memory and lookup overhead.
  • Debug: obj.__class__ and type(obj) are almost always equivalent, but __class__ can be reassigned at runtime to change an object's class in place — a rarely-used but real technique for state-machine-like objects that "become" a different type.

⚠️ Edge Cases & Gotchas

  • A mutable class attribute (list, dict, set) is shared by every instance, since it's created once at class-definition time, not per instance — mutating it through one instance (self.items.append(...)) affects all instances; always initialize mutable state inside __init__ as an instance attribute instead.
  • Assigning to self.attr = value never mutates a same-named class attribute — it always creates or overwrites an instance attribute that shadows it — this can mask bugs where code intends to update shared state but silently creates per-instance state instead.
  • Python objects have no true privacy — a leading underscore (_x) is convention only, and even "name-mangled" double-underscore attributes (__x) are just renamed to _ClassName__x, still fully accessible — don't rely on attribute naming for security or correctness; it communicates intent to other developers, nothing more.
  • The class body runs exactly once at definition time — code inside it (loops, conditionals, print statements, function calls) executes immediately, not per instantiation — this is why conditional class attributes (x = compute_default() if condition else other_default()) are evaluated once at import time, not refreshed on each ClassName() call.
  • isinstance(True, int) is True, because bool is a subclass of int — a type check meant to exclude booleans (e.g., validating "this must be an integer count, not a flag") must explicitly guard with type(x) is bool in addition to the isinstance(x, int) check, or a stray True/False will silently pass validation as 1/0.

🧠 Spot the Bug

A game inventory system tracks items per player. Every new player mysteriously starts with items already in their inventory. Find the bug.

python
class Player:
    inventory = []

    def __init__(self, name):
        self.name = name

    def pick_up(self, item):
        self.inventory.append(item)

alice = Player("Alice")
alice.pick_up("sword")

bob = Player("Bob")
print(bob.inventory)
Answer

Prints ['sword'] — Bob starts with Alice's sword. inventory = [] is a class attribute, created exactly once when the Player class body executes, and shared by every instance that hasn't shadowed it with its own instance attribute. self.inventory.append(item) doesn't create a new list on self — it looks up inventory (finds the shared class attribute, since no instance attribute exists yet), and mutates that single shared list in place. Every Player instance is looking at the exact same list object.

The fix is to make inventory an instance attribute, created fresh in __init__:

python
class Player:
    def __init__(self, name):
        self.name = name
        self.inventory = []

    def pick_up(self, item):
        self.inventory.append(item)

The lesson: mutable class attributes are shared, in-place-mutable state across every instance — any field meant to be per-instance and mutable (lists, dicts, sets, and most custom objects) belongs in __init__ as self.attr = ..., never as a bare class-body assignment.

Key Takeaways

  • __init__ initializes an already-created instance; self is just the conventional name for the instance, passed automatically by the obj.method() calling convention.
  • Class attributes are shared across all instances and created once at class-definition time; instance attributes (assigned via self.x = ...) are per-object and shadow same-named class attributes on lookup.
  • Mutable class attributes are a classic bug source — the fix is always to initialize mutable state inside __init__ instead of the class body.
  • @classmethod (receives cls) is the idiomatic pattern for alternate constructors; @staticmethod (receives neither self nor cls) is just a regularly-namespaced function living on the class.
  • Prefer isinstance() over type() == for type checks so subclasses are correctly recognized — but remember bool is a subtype of int, which can silently defeat naive numeric validation.
  • A class body executes top-to-bottom exactly once at definition time, building the class's namespace — it is not a per-instance template.