9  Classes

NoteCore idea

A class is a blueprint for objects. It bundles data (attributes) and behavior (methods). Use a class when you need to keep state — data that lives across method calls — together with the operations on it. If you don’t need state, a function is simpler.

In this chapter you will learn to:

  1. Define a class with __init__, methods, and a self parameter.
  2. Implement special methods (__repr__, __str__, __eq__, __add__, …) to make your class feel native.
  3. Use @property, @classmethod, and @staticmethod.
  4. Inherit from a parent class and call back to it with super().
  5. Recognize @dataclass and abc.ABC as light alternatives to handwritten classes.

9.1 A first class

Imagine tracking a bank account: an owner, a balance, and operations (deposit, withdraw) that have to keep the balance consistent. Separate variables and helper functions work for one account; for ten thousand, they break down. A class is the natural unit that bundles the data and the rules that govern it. We’ll build BankAccount in three small steps — data, then printable form, then behavior.

Step 1: data attached in __init__. The minimum class is a constructor that stores values on the new instance — nothing else.

class BankAccount:
    def __init__(self, owner, balance=0.0):
        self.owner = owner
        self.balance = balance

alice = BankAccount("Alice", 1000.0)
[alice.owner, alice.balance]
['Alice', 1000.0]
  • class BankAccount: declares a new type. Everything indented under it is the class body.
  • __init__(self, owner, balance=0.0) is the constructor, run when you write BankAccount("Alice", 1000.0). The first parameter self is the new instance Python is building; you never pass it explicitly.
  • self.owner = owner attaches a value to that specific instance. After __init__ returns, the object remembers it.
  • The default balance=0.0 makes that argument optional.

Step 2: tell developers and users how to read the object. Every class should be inspectable. Two dunder methods control what people see:

class BankAccount:
    def __init__(self, owner, balance=0.0):
        self.owner = owner
        self.balance = balance

    def __repr__(self):
        return f"BankAccount({self.owner!r}, {self.balance!r})"

    def __str__(self):
        return f"{self.owner}'s account: ${self.balance:.2f}"

alice = BankAccount("Alice", 1000.0)
[repr(alice), str(alice)]
["BankAccount('Alice', 1000.0)", "Alice's account: $1000.00"]
  • __repr__ is for developers — what tracebacks and the REPL print. Convention: it should look like the call that would recreate the object.
  • __str__ is for users — what print(obj) and str(obj) show. Friendlier, less precise.
  • Always implement __repr__. __str__ is optional; if you skip it, str(obj) falls back to __repr__.

Step 3: behavior — methods that operate on self. Now add the deposit and withdraw rules. Each method takes self first; validation guards the invariant “balance is non-negative”:

class BankAccount:
    def __init__(self, owner, balance=0.0):
        self.owner = owner
        self.balance = balance

    def __repr__(self):
        return f"BankAccount({self.owner!r}, {self.balance!r})"

    def deposit(self, amount):
        if amount <= 0:
            raise ValueError(f"deposit must be positive, got {amount}")
        self.balance += amount

    def withdraw(self, amount):
        if amount <= 0:
            raise ValueError("withdraw must be positive")
        if amount > self.balance:
            raise ValueError(f"insufficient funds: {amount} > {self.balance}")
        self.balance -= amount

alice = BankAccount("Alice", 1000.0)
alice.deposit(500)
alice.withdraw(200)
[repr(alice), alice.balance]
["BankAccount('Alice', 1300.0)", 1300.0]
  • deposit and withdraw are instance methods. Each takes self first — the object the call operates on. When you write alice.deposit(500), Python looks up deposit on the class and passes alice in as self automatically.
  • The raise ValueError(...) lines protect the invariant — the rule that must always hold. Validation in methods is the main reason to use a class instead of a plain dict.

That’s the universal shape of a Python class: data attached in __init__, dunder methods for native-feeling output, behavior in methods that take self first. Every class you’ll write follows it.

9.2 Special methods make a class feel native

Suppose Money(10) + Money(2) should return Money(12) and Money(10) == Money(10) should be True. With a plain class you’d write m1.add(m2) and m1.equals(m2) — fine, but un-Pythonic. Python lets you wire +, ==, <, len, in, for, and the rest directly to your class by implementing special methods (a.k.a. dunder methods — double-underscore on each side).

One convention to know before reading the code: every binary-operator dunder takes two parameters — self and a second one for the right operand. When Python sees a == b, it calls a.__eq__(b) — so inside the method, self is the left operand (a) and the second parameter is the right operand (b). The same shape applies to + (__add__), < (__lt__), * (__mul__), and the rest. The conventional name for the right operand is other; you’ll see it everywhere. When the right operand has a clearly different role, codebases rename it to something more descriptive — below, __mul__ calls it factor because price * 2 multiplies by a number, not by another Money.

class Money:
    def __init__(self, amount, currency="GBP"):
        self.amount = amount
        self.currency = currency

    def __repr__(self):
        return f"Money({self.amount!r}, {self.currency!r})"

    def __str__(self):
        return f"{self.currency} {self.amount:.2f}"

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

    def __lt__(self, other):
        if self.currency != other.currency:
            raise ValueError("cannot compare different currencies")
        return self.amount < other.amount

    def __add__(self, other):
        if self.currency != other.currency:
            raise ValueError("cannot add different currencies")
        return Money(self.amount + other.amount, self.currency)

    def __mul__(self, factor):
        return Money(self.amount * factor, self.currency)

    def __bool__(self):
        return self.amount != 0

price = Money(10.00)
tax = Money(0.80)
total = price + tax
[str(total), total == Money(10.80), total > tax, str(total * 2), bool(total), bool(Money(0))]
['GBP 10.80', True, True, 'GBP 21.60', True, False]

Walking through the special methods one at a time:

  • __eq__(self, other) is what == calls. We check isinstance(other, Money) first so Money(10) == 10 is False instead of crashing on a missing .currency.
  • __lt__(self, other) is <. Comparing 10 GBP to 10 USD is a category error, not a numeric one — we refuse it with ValueError. Defining __lt__ is enough for < and >; for the full set (<=, >=) use @functools.total_ordering (see Chapter 28).
  • __add__(self, other) is +. It returns a new Money — never mutates either operand. That’s the convention for arithmetic operators.
  • __mul__(self, factor) is *. Writing price * 2 calls price.__mul__(2), so factor is 2 — a number, not a Money (multiplying two prices makes no sense). The parameter is named factor instead of other to make that role explicit.
  • __bool__(self) is what if money: and bool(money) call. By default any instance is truthy; here we make zero-amount money falsy.

The general pattern: each operator and built-in has a corresponding dunder. Implementing the dunder lets your users write idiomatic Python (a + b, if a:, len(a)) instead of method calls. The complete map is in Chapter 13 and Chapter 28.

9.3 @property — smart attributes

You shipped Circle with a plain radius attribute. Months later, you discover users have been creating circles with negative radii. You want to add validation — but circle.radius = 5 is written all over the codebase. Changing it to circle.set_radius(5) would break every caller. @property is the escape hatch: it lets you replace a plain attribute with a method-backed one, without changing the calling syntax.

Two terms to know first:

  • A getter is a method that runs when someone reads an attribute (c.radius).
  • A setter is a method that runs when someone writes one (c.radius = 5).

With a plain attribute, Python stashes the value in the instance’s __dict__ (a per-instance dict that maps attribute names to their values — every regular Python object has one) and reads it back unchanged — no methods involved. With @property, Python routes every read through your getter and every write through your setter. The calling syntax stays the same (c.radius to read, c.radius = 5 to write) but now validation, computation, or logging can sit in between. The hooks fire on the instance (c, an object built from the class), not on the class Circle itself. Build the pattern in three small steps.

Step 1: turn a method into a read-only attribute. Decorating a method with @property lets the caller use it as an attribute — no parentheses needed:

import math

class Circle:
    def __init__(self, radius):
        self._radius = radius      # store directly into the underscore name

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

c = Circle(5)
c.radius                            # not c.radius() — no parens
5
  • _radius (with underscore) is the storage — the actual data attribute on the instance. The leading underscore is a Python convention that says “this is internal; use the public form instead.”
  • radius (no underscore) is a method, but @property registers it as a property on the class. When the user writes c.radius, Python notices the property and calls the method behind your back — that’s why you get 5 (the return value) instead of a method object.
  • We get a read-only attribute. Try c.radius = 10 at this point and Python raises AttributeError because we haven’t given it a way to write.

Step 2: add a setter to validate writes. Once you’ve used @property, the name radius on the class no longer refers to the original method — it refers to a property object that Python created. That object has a .setter decorator, used to register the write hook:

class Circle:
    def __init__(self, radius):
        self.radius = radius        # goes through the setter — validates here too

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

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

c = Circle(5)
c.radius
5
c.radius = -1
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
Cell In[7], line 1
----> 1 c.radius = -1

Cell In[6], line 12, in Circle.radius(self, value)
      9     @radius.setter
     10     def radius(self, value):
     11         if value < 0:
---> 12             raise ValueError(f"radius cannot be negative: {value}")
     13         self._radius = value

ValueError: radius cannot be negative: -1
  • @radius.setter reads as “use the setter decorator method on the property object named radius.” The radius here is the property we created in step 1, not an integer. Python’s decorator syntax lets you reuse the name to attach a second method to the same property.
  • The setter receives the value being assigned. It validates first, then stores in self._radius — never self.radius, which would re-enter the setter and recurse forever.
  • __init__ now does self.radius = radius. That assignment goes through the setter too, so Circle(-1) fails immediately instead of letting a bad value into the instance.
  • Try c.radius = -1 after construction and the same setter rejects it. The calling code never had to learn about validation — it just sees AttributeError-like behavior on bad values.

Step 3: a computed property — read with no storage. Sometimes the value you expose is derived from other state, not stored. Define @property with a getter only — there’s nothing to write, so omit the setter:

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

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

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

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

c = Circle(5)
[c.radius, c.area]
[5, 78.53981633974483]
c.area = 50
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
Cell In[9], line 1
----> 1 c.area = 50

AttributeError: property 'area' of 'Circle' object has no setter
  • area has a getter and no setter — read-only by construction. The user reads c.area like any attribute and gets the freshly-computed value.
  • c.area = 50 raises AttributeError because no setter was defined. That’s exactly right: the area is derived from the radius, so assigning to it directly would be incoherent.
  • Computed properties keep area, circumference, diameter, and so on always in sync with radius — change radius once and every derived value updates on next read.

The general pattern: start with a plain attribute. The day you need validation, computation, or logging, add @property — and not a single caller has to change. This is one of Python’s quietest superpowers. The full machinery (it’s all descriptors) is in Chapter 35.

9.4 @classmethod and @staticmethod

What if you want to construct a Person from a date string like "1985-03-15", not a year integer? You could write a second __init__ — except Python only allows one. The standard answer is an alternative constructor: a @classmethod named like a factory (from_birth_date, from_dict, from_csv).

Three method types live inside a class. The difference is what gets passed as the first argument:

Method type First arg What it gets Use case
Instance method self The instance you called it on Operates on the instance’s data
@classmethod cls The class itself Alternative constructor; class-level state
@staticmethod none nothing automatic Utility function namespaced inside the class

Build them up one at a time.

Step 1: a plain class with one __init__. This is the case we already know — instance methods take self:

from datetime import date

class Person:
    def __init__(self, name, birth_year):
        self.name = name
        self.birth_year = birth_year

p = Person("Bob", 1985)
[p.name, p.birth_year]
['Bob', 1985]

But suppose callers have ISO date strings ("1985-03-15"), not integers. They’d have to parse manually before every call: Person("Bob", int("1985-03-15".split("-")[0])). We’d rather expose a second way to build a Person — without rewriting __init__.

Step 2: @classmethod as an alternative constructor. A class method receives the class itself as cls. Calling cls(...) inside the body builds an instance — going through the normal __init__:

class Person:
    def __init__(self, name, birth_year):
        self.name = name
        self.birth_year = birth_year

    @classmethod
    def from_birth_date(cls, name, iso_date):
        year = int(iso_date.split("-")[0])
        return cls(name, year)

bob = Person.from_birth_date("Bob", "1985-03-15")
[bob.name, bob.birth_year]
['Bob', 1985]

Read this one step at a time:

  1. @classmethod tells Python “when this method is called, pass the class itself as the first argument.” So writing Person.from_birth_date("Bob", "1985-03-15") is really Person.from_birth_date(Person, "Bob", "1985-03-15") — Python inserts Person as the first argument silently. Inside the body, cls is bound to Person. cls is just a parameter name, the way self is — the convention is cls because it points to the class, not an instance.
  2. cls(name, year) is therefore literally Person(name, year) — the normal constructor call, going through __init__ like any other. from_birth_date parses the ISO string into a year, then constructs via cls.
  3. Why write cls(...) instead of Person(...) directly? So that subclasses get instances of themselves, not of the parent. To see this, imagine someone extends PersonEmployee is a natural subclass, since every employee is also a person:
class Employee(Person):           # Employee IS-A Person, with nothing extra here
    pass

e = Employee.from_birth_date("Alice", "1990-07-04")
[type(e).__name__, e.name, e.birth_year]
['Employee', 'Alice', 1990]
  • class Employee(Person): declares Employee as a child class of Person — same name, same birth_year, same from_birth_date, all inherited. The body is just pass because we only need to demonstrate the inheritance mechanics; in real code Employee would add fields like salary or department, but those aren’t relevant here.
  • We did not redefine from_birth_date on Employee. The inherited method runs unchanged.
  • But when you call Employee.from_birth_date(...), Python now passes Employee as cls. So inside the method, cls(name, year) is Employee(name, year) — and the result is an Employee, as type(e).__name__ confirms.
  • If we’d hard-coded Person(name, year) in from_birth_date, Employee.from_birth_date(...) would silently return a Person, ignoring the subclass entirely. That’s the inheritance bug to avoid.

The naming convention is from_xxxfrom_birth_date, from_dict, from_csv. The standard library uses it everywhere: dict.fromkeys, datetime.fromisoformat, Path.cwd. Read the call site Person.from_birth_date(...) aloud — it tells you exactly what’s happening.

Step 3: @staticmethod for utilities that need neither self nor cls. Sometimes a helper is logically grouped with a class — it makes sense to find it inside Person — but doesn’t read the instance or the class. It’s a plain function that happens to live in the class’s namespace:

class Person:
    def __init__(self, name, birth_year):
        self.name = name
        self.birth_year = birth_year

    @staticmethod
    def is_adult(age):
        return age >= 18

[Person.is_adult(20), Person.is_adult(15)]
[True, False]
  • @staticmethod says “no automatic first argument.” is_adult takes only age — no self, no cls.
  • Call it on the class: Person.is_adult(20). You can also call it on an instance, but the class form reads better — it makes clear the function uses no instance state.
  • Use sparingly. If a helper needs neither self nor cls, ask whether it should be a class method at all — often it’s just a module-level function in disguise. @staticmethod is the right answer when the strong association with the class outweighs the cost of indirection.

Putting all three together — the same Person with an instance attribute, a computed property, an alternative constructor, and a utility:

class Person:
    def __init__(self, name, birth_year):
        self.name = name
        self.birth_year = birth_year

    @property
    def age(self):
        return date.today().year - self.birth_year

    @classmethod
    def from_birth_date(cls, name, iso_date):
        year = int(iso_date.split("-")[0])
        return cls(name, year)

    @staticmethod
    def is_adult(age):
        return age >= 18

bob = Person.from_birth_date("Bob", "1985-03-15")
[bob.name, bob.age, Person.is_adult(bob.age)]
['Bob', 41, True]

The general pattern: instance methods for instance state; @classmethod for alternative constructors and class-level state; @staticmethod for closely-related utilities. The first parameter tells you which is which.

9.5 Inheritance and super()

When two classes share most of their behavior — say Dog and Cat both have a name, both can be printed, but each makes a different sound — you don’t write the shared parts twice. The child class inherits from the parent, reusing what’s the same and overriding what’s different. Build it in three small steps.

Step 1: a base class and two subclasses that override one method. Define Animal with the parts every animal shares; let each subclass override only what differs:

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

    def __repr__(self):
        return f"{type(self).__name__}({self.name!r})"

    def speak(self):
        raise NotImplementedError(f"{type(self).__name__} must implement speak()")

class Dog(Animal):
    def speak(self):
        return "Woof!"

class Cat(Animal):
    def speak(self):
        return "Meow!"

rex, whiskers = Dog("Rex"), Cat("Whiskers")
[repr(rex), repr(whiskers), rex.speak(), whiskers.speak()]
["Dog('Rex')", "Cat('Whiskers')", 'Woof!', 'Meow!']
  • Animal is the base class. __init__ stores name; __repr__ prints Dog('Rex') (using type(self).__name__ so subclasses report their own type, not Animal); speak raises — it’s a placeholder meant to be overridden.
  • class Dog(Animal): declares Dog as a child of Animal. Every Dog instance gets __init__, __repr__, and the name attribute for free from Animal. Dog only overrides speak.
  • Cat does the same with a different speak. Both subclasses share Animal’s __init__ and __repr__ — that’s the shared-behavior payoff.

Step 2: super() to extend a parent’s method. A subclass that needs more state or more behavior calls super() to delegate to the parent first, then adds its own bit:

class GuideDog(Dog):
    def __init__(self, name, owner):
        super().__init__(name)               # delegates to Animal.__init__
        self.owner = owner

    def speak(self):
        return super().speak() + " (guide dog)"

g = GuideDog("Lassie", "Alice")
[repr(g), g.owner, g.speak()]
["GuideDog('Lassie')", 'Alice', 'Woof! (guide dog)']
  • super().__init__(name) calls the parent chain’s __init__ — here, Animal.__init__(name), which does self.name = name. Without this we’d have to repeat that line in every subclass.
  • After delegating, the subclass adds its own field: self.owner = owner.
  • super().speak() calls Dog.speak() to get "Woof!", and the subclass tacks " (guide dog)" on the end. That’s the extend pattern — let the parent do its work, then build on top.
  • Always use super() — never Animal.__init__(self, name) directly. The direct form works for single inheritance but breaks silently the moment a class has multiple parents. super() follows the MRO (method resolution order), the canonical lookup chain.

Step 3: how Python finds a method. When you call g.speak(), Python does not magically pick the right method. It walks a fixed chain of classes — the instance’s class first, then each parent in order — and stops at the first one that defines speak:

flowchart LR
    G[GuideDog] --> D[Dog] --> A[Animal] --> O[object]

For g.speak():

  • Look on GuideDogspeak is defined here → stop, call it.

For g.__repr__(), the walk goes further:

  • GuideDog — no __repr__.
  • Dog — no __repr__.
  • Animal — found → stop, call it.

That chain is the MRO (method resolution order). You can inspect it directly:

[c.__name__ for c in GuideDog.__mro__]
['GuideDog', 'Dog', 'Animal', 'object']

The output ['GuideDog', 'Dog', 'Animal', 'object'] is the exact lookup order Python walks for any attribute access on a GuideDog instance. Notice object at the end — every class in Python ultimately inherits from object, which is why every instance has __repr__, __hash__, __eq__ (the default identity ones) for free. The list comprehension [c.__name__ for c in GuideDog.__mro__] just turns the tuple of class objects into their names for readable output.

isinstance uses the same chain — an object “is-a” any class in its MRO:

[isinstance(g, GuideDog), isinstance(g, Dog), isinstance(g, Animal)]
[True, True, True]

All three return True. g is a GuideDog, but a GuideDog is-a Dog (because Dog is in GuideDog’s MRO), and is-a Animal (same reason). This is what “subtype” means at runtime: isinstance(x, T) is true exactly when T is in type(x).__mro__. It’s also why subclass instances pass type checks intended for the parent — exactly the polymorphism point.

The full descriptor and MRO story is in Chapter 13 and Chapter 26.

9.6 A glimpse of @dataclass

So far you’ve written every method of every class by hand. For most code that’s fine — but one pattern shows up constantly: a class whose only job is to bundle a few named values together. A 2-D point. A config struct. A row of a record. For those, __init__ / __repr__ / __eq__ are always the same shape, just with different field names. @dataclass is a decorator from the standard library that reads the class’s type annotations and writes those three methods for you. Three small steps make the saving visible.

Step 1: the boilerplate, written by hand. Two fields, three dunder methods — and we’re already typing the same code we’d write a hundred times in a real codebase:

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

    def __repr__(self):
        return f"Point(x={self.x!r}, y={self.y!r})"

    def __eq__(self, other):
        return isinstance(other, Point) and self.x == other.x and self.y == other.y

p = Point(3.0, 4.0)
[p, p == Point(3.0, 4.0), p == Point(3.0, 5.0)]
[Point(x=3.0, y=4.0), True, False]
  • __init__ assigns each argument to self. Mechanical.
  • __repr__ returns Point(x=3.0, y=4.0). Mechanical.
  • __eq__ compares the two instances field by field. Mechanical.
  • Imagine the class had ten fields. All three methods grow linearly with the field list — a recipe for typos and forgotten edits.

Step 2: @dataclass writes the boilerplate for you. Annotate the fields in the class body, decorate, done:

from dataclasses import dataclass

@dataclass
class Point:
    x: float
    y: float

p = Point(3.0, 4.0)
[p, p == Point(3.0, 4.0), p == Point(3.0, 5.0)]
[Point(x=3.0, y=4.0), True, False]
  • x: float and y: float are type-annotated names — @dataclass reads them and uses the names (the types are documentation; @dataclass doesn’t enforce them at runtime).
  • The decorator generates __init__(self, x: float, y: float), __repr__, and __eq__ automatically. You don’t write them.
  • The class behaves identically to the hand-written one in step 1 — same constructor, same repr, same equality. The generated code is real, not magic; you can see it via inspect.getsource(Point.__init__).
  • Reach for @dataclass whenever a class is mostly data. Reach for a hand-written class when you need real custom behavior. The full treatment (frozen=True, field(default_factory=...), the four data-class builders) is Chapter 17.

Step 3: two modern flags worth knowing. slots=True and kw_only=True (both 3.10+) are opt-in upgrades. slots shrinks per-instance memory; kw_only forces keyword-only construction so reordered fields don’t silently change call meanings:

from typing import Self

@dataclass(slots=True, kw_only=True)
class User:
    name: str
    age: int

    def renamed(self, name: str) -> Self:
        return type(self)(name=name, age=self.age)

u = User(name="Alice", age=30)
[u, u.renamed("Bob"), User.__slots__]
[User(name='Alice', age=30), User(name='Bob', age=30), ('name', 'age')]
  • slots=True adds __slots__ = ('name', 'age') to the class — Python pre-allocates exactly those attribute slots and skips the per-instance __dict__. Smaller memory and faster attribute access, but u.email = "..." raises AttributeError. Worth it for classes you create at scale.
  • kw_only=True makes every field keyword-only at the constructor — User("Alice", 30) raises TypeError, only User(name="Alice", age=30) is allowed. No more positional confusion when fields get reordered or added.
  • Self (3.11+) is the right return type for “an instance of the same class.” On a User subclass, it tightens to the subclass automatically — no TypeVar boilerplate. type(self)(...) constructs an instance of whatever self’s actual class is, so subclasses get the right return type at runtime.

The general guidance: reach for slots=True when memory matters, kw_only=True when the field list is non-trivial, and Self whenever a method returns “another one of these.” The full story is Chapter 17 and Chapter 27.

9.7 A glimpse of abstract base classes

Sometimes you want a base class that says “every subclass must provide these methods” — and refuses to be instantiated itself. A bare Animal doesn’t have a meaningful speak; only concrete animals do. You’d like Python to fail loudly the moment someone writes Animal("Rex") instead of letting it slip through and crashing later when something tries to call speak. That’s an abstract base class (ABC). Two pieces of machinery make a class abstract: it inherits from abc.ABC, and at least one method is decorated with @abstractmethod. Build it in two steps.

Step 1: a minimum ABC. One abstract method, no concrete subclass yet. Trying to instantiate the abstract class itself raises TypeError:

from abc import ABC, abstractmethod

class Animal(ABC):
    @abstractmethod
    def speak(self): ...
Animal()
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[23], line 1
----> 1 Animal()

TypeError: Can't instantiate abstract class Animal without an implementation for abstract method 'speak'
  • class Animal(ABC): inherits from abc.ABC. Python now tracks any methods marked @abstractmethod and refuses to construct an instance until every one of them has been overridden.
  • @abstractmethod declares that speak is required. The body ... is just a placeholder — Python never runs it, since concrete subclasses are required to provide the real implementation.
  • Animal() fails at construction with TypeError: Can't instantiate abstract class .... That’s the safety: a half-built type can’t enter the wild.

Step 2: a concrete subclass — and the enforcement is per-method. Provide the implementation in a subclass and the same constructor works. Adding a second abstract method shows that the enforcement is per-method — a subclass that overrides only one is still abstract:

class Shape(ABC):
    @abstractmethod
    def area(self): ...

    @abstractmethod
    def perimeter(self): ...

class Rectangle(Shape):
    def __init__(self, w, h):
        self.w, self.h = w, h
    def area(self):
        return self.w * self.h
    def perimeter(self):
        return 2 * (self.w + self.h)

r = Rectangle(3, 4)
[r.area(), r.perimeter()]
[12, 14]
class HalfBaked(Shape):
    def area(self):              # overrides area but not perimeter
        return 0
HalfBaked()
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[25], line 4
      1 class HalfBaked(Shape):
      2     def area(self):              # overrides area but not perimeter
      3         return 0
----> 4 HalfBaked()

TypeError: Can't instantiate abstract class HalfBaked without an implementation for abstract method 'perimeter'
  • Rectangle(Shape) overrides both abstract methods, so it’s concrete — instantiable like any class.
  • HalfBaked only implements area. Shape.perimeter is still abstract, so HalfBaked() raises TypeError — and the error message names the method that’s still missing. Diagnostics for free.

The general pattern: ABCs are how you write contracts in Python. They’re useful when you have a base class that genuinely shouldn’t be instantiated, and you want a missed override to fail at construction (loud) instead of at first call (quiet). The deep treatment, including the related but more flexible Protocol for structural typing, is Chapter 25.

TipWhy this matters

Every class in Python participates in the same protocol-driven data model. Implementing __add__ makes + work on your class. Implementing __iter__ makes for x in your_obj work. Once you’ve seen this once, the rest of the library stops being magic — it’s just classes that implement the right special methods.

9.8 Going deeper

This chapter is a working introduction. The deep dives:

  • The full data model and special methodsChapter 13.
  • Pythonic objects, __hash__, copy, equality semanticsChapter 23.
  • Inheritance, the MRO, multiple inheritanceChapter 26.
  • @dataclass and the three other data-class buildersChapter 17.
  • Protocols, ABCs, and structural typingChapter 25.

9.9 Build: a 2-D Vector class

A 2-D vector is the textbook small example for tying the whole chapter together: it has data (x, y), special methods (+, *, ==), a derived value (magnitude), and a natural alternative constructor (from a tuple). We’ll build it in three steps that each pull in a different chapter section.

Step 1: hand-written, with the special methods we’ve seen.

import math

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

    def __repr__(self):
        return f"Vector2D({self.x!r}, {self.y!r})"

    def __eq__(self, other):
        return isinstance(other, Vector2D) and self.x == other.x and self.y == other.y

    def __add__(self, other):
        return Vector2D(self.x + other.x, self.y + other.y)

    def __mul__(self, scalar):
        return Vector2D(self.x * scalar, self.y * scalar)

v1 = Vector2D(3, 4)
v2 = Vector2D(1, 2)
[v1 + v2, v1 * 3, v1 == Vector2D(3, 4)]
[Vector2D(4, 6), Vector2D(9, 12), True]

The shape from the special-methods section: __init__ stores the data, __repr__ prints it back, the operator dunders return new vectors instead of mutating either operand. __mul__ takes scalar (a number), not other, because multiplying two vectors would need a different operation (dot product) we haven’t defined.

Step 2: replace the boilerplate with @dataclass, add a computed property for magnitude. Step 1’s __init__, __repr__, and __eq__ are exactly what @dataclass writes for you. The magnitude is derived from x and y, so it’s a getter-only @property:

from dataclasses import dataclass

@dataclass(frozen=True, slots=True)
class Vector2D:
    x: float
    y: float

    @property
    def magnitude(self):
        return math.hypot(self.x, self.y)

    def __add__(self, other):
        return Vector2D(self.x + other.x, self.y + other.y)

    def __mul__(self, scalar):
        return Vector2D(self.x * scalar, self.y * scalar)

v = Vector2D(3, 4)
[v, v.magnitude, v == Vector2D(3, 4)]
[Vector2D(x=3, y=4), 5.0, True]

frozen=True makes the instance immutable — v.x = 99 raises FrozenInstanceError. That’s the right setting for a value object: equal vectors should stay equal forever. slots=True skips the per-instance __dict__ — smaller memory and faster attribute access. We kept __add__ and __mul__ because @dataclass doesn’t know about arithmetic — it only generates the data-shape methods (__init__, __repr__, __eq__).

Step 3: an alternative constructor with @classmethod. Callers often have an (x, y) tuple in hand (from a CSV row, a JSON record, a math library). @classmethod is the right way to expose a second build path without a second __init__:

@dataclass(frozen=True, slots=True)
class Vector2D:
    x: float
    y: float

    @property
    def magnitude(self):
        return math.hypot(self.x, self.y)

    @classmethod
    def from_tuple(cls, pair):
        x, y = pair
        return cls(x, y)

    def __add__(self, other):
        return Vector2D(self.x + other.x, self.y + other.y)

    def __mul__(self, scalar):
        return Vector2D(self.x * scalar, self.y * scalar)

v = Vector2D.from_tuple((3, 4))
[v, v.magnitude]
[Vector2D(x=3, y=4), 5.0]

from_tuple follows the from_xxx naming convention from the @classmethod section. The cls(x, y) call goes through __init__ like any other construction — and crucially, if anyone subclasses Vector2D, Subclass.from_tuple((3, 4)) returns an instance of the subclass, not of the parent.

The build exercises every section: a basic class with data and methods (Step 1), special methods for natural operator syntax (Step 1), @dataclass for the boilerplate three (Step 2), @property for derived values (Step 2), and @classmethod for an alternative constructor (Step 3). About thirty lines of code and a value class that behaves like a built-in.

9.10 Exercises

  1. __repr__ for debugging. Add __repr__ to BankAccount and use it in a list of accounts. Compare the output with and without __repr__.

  2. @property validation. Add an @property for email to Person, with a setter that raises ValueError on a missing @.

  3. @classmethod constructor. Add Money.from_string("GBP 10.50") as a @classmethod that splits the string and constructs a Money.

  4. Inheritance and super(). Subclass BankAccount to make SavingsAccount that adds an interest_rate. Override deposit to log a message, then call super().deposit(amount).

  5. Composition over inheritance. Rewrite SavingsAccount to contain a BankAccount plus the rate, instead of inheriting. When is composition the better choice?

9.11 Summary

A class bundles state and behavior. Special methods integrate your class into the language. @property, @classmethod, @staticmethod, inheritance, @dataclass, and abstract base classes round out the toolkit. The next chapter, Chapter 10, takes a step up — organizing classes and functions across files into reusable modules and packages.