Skip to content
Unit 15 of 28

Classes and objects

Contents · 20

Everything in Python is an object: numbers, strings, lists, functions, even modules. A class lets you define a new kind of object, with its own data and its own behaviour. You will use classes to keep related data and the functions that work on it together, to model things like cards, accounts and shapes, and to make your objects work with print, ==, +, len and for.

This unit starts with plain classes, then shows the tools that remove most of the boilerplate: dataclasses and enums.

You will be able to:

  • Define a class with an initialiser, instance attributes and methods
  • Tell instance attributes from class attributes and predict which one a lookup finds
  • Use properties, class methods and static methods for validation, alternative constructors and helpers
  • Build on other classes with inheritance and super(), and recognise when composition fits better
  • Make your objects work with print, ==, <, +, len, in and for through special methods
  • Replace boilerplate with dataclasses and enums, and describe interfaces with ABC and Protocol

Classes and instances

A class is a blueprint; an instance is one object built from it. Functions defined inside a class are methods. Python passes the instance to every method as the first argument, which by convention is named self:

Each instance keeps its own attributes in a dictionary, which vars() shows. Class names use CapWords (PEP 8).

__init__ is the initialiser: it fills in an object that already exists. Creating the object is the job of __new__, which Python calls first. So Pet("Misty", 3) runs Pet.__new__(Pet, "Misty", 3) to make an empty instance, then __init__ on it. People often call __init__ "the constructor", but the two steps are separate. You will rarely write __new__; it matters mainly for subclasses of immutable types such as int or tuple.

Instance attributes and class attributes

An attribute assigned in the class body is a class attribute, shared by all instances. An attribute assigned through self is an instance attribute. When you read obj.name, Python looks in the instance first, then in the class, then in its parent classes.

Predict the output before you read it:

What to notice:

  • Assigning a.created = 100 did not change the class attribute. It created a new instance attribute that hides it, for a only.
  • a.tags.append("x") did not assign anything. It changed the one list that a.tags, b.tags and Counter.tags all find on the class.

Pitfall. Never use a mutable class attribute as per instance storage. Create the list in __init__ (self.tags = []), so every instance gets its own.

Properties, class methods and static methods

@property: attributes with validation

A property looks like an attribute to the caller but runs a method behind the scenes. Use it to validate values on assignment, or to compute a value on demand:

The value lives in _target_c. The leading underscore says "internal": code outside the class should use the property. Because __init__ assigns through the property too, even the first value is validated. Start with plain attributes; you can turn one into a property later without changing any caller.

@classmethod and @staticmethod

KindFirst parameterTypical use
instance methodself, the instancework with one object's data
@classmethodcls, the class it was called onalternative constructors ("factories")
@staticmethodnonea helper that belongs with the class but needs neither

Because the factory calls cls(...) rather than Pet(...), Cat.from_csv builds a Cat. Self (from typing, since 3.11) says "an instance of whichever class this was called on". The factory parses the text, turns "yes"/"no" into a real bool for has_fur, and returns a ready object.

Pitfall. Pass arguments to cls(...) by keyword. A factory that takes (age, name, has_fur) and forwards them positionally to an initialiser that expects (age, has_fur, name) silently swaps two fields. Python cannot catch that; keywords make the mapping visible and fail loudly if a name is wrong.

Inheritance and super()

A subclass inherits every attribute and method of its base class and can override or extend them. super() gives you the next class in line, so you can reuse its version of a method:

Animal.introduce calls self.speak(), and each object answers with its own version. Cat has no __init__, so it inherits Animal's. Writing one that only calls super().__init__ with the same arguments adds nothing.

Method resolution order, briefly

With multiple inheritance, Python puts all the classes in one line called the method resolution order (MRO) and searches it from left to right. super() means "the next class in the MRO", which is not always the parent you see in the code:

Left's super() reached Right, not Base, and each class ran once.

Composition or inheritance?

Inheritance models "is a": a cat is an animal. Composition models "has a": you store another object in an attribute and delegate to it. Composition is usually the more flexible choice, because you can swap the part without touching a class hierarchy:

A rule of thumb: if you would subclass list or dict only to get storage, store a list or dict in an attribute instead.

The classes in this section as a class diagram (<|-- is inheritance, o-- is "has"):

Rendering diagram…

Special methods

Special methods, also called dunder methods because of their double underscores, let your objects work with Python's syntax and builtins. You rarely call them directly (super().__init__(...) is the main exception); Python calls them for you:

You writePython calls
repr(x), the REPL, inside containersx.__repr__()
str(x), print(x), f-stringsx.__str__() (falls back to __repr__)
x == y, x < yx.__eq__(y), x.__lt__(y)
x + yx.__add__(y)
len(x); also bool(x) when there is no __bool__x.__len__()
for item in xx.__iter__()
item in xx.__contains__(item)
abs(x)x.__abs__()

What to notice:

  • Return NotImplemented (not raise) for types you do not support. Python then tries the other operand, and finally raises a clear TypeError or, for ==, falls back to an identity check, which is why price == 19.99 is False.
  • sorted only needs __lt__.
  • Defining __eq__ makes instances unhashable unless you also define __hash__, because equal objects must have equal hashes. Frozen dataclasses (below) handle this for you.

Containers need __len__, __iter__ and __contains__:

bool() used __len__: an empty playlist is falsy, like an empty list. __iter__ returns an iterator, here the list's own; Iterators and generators shows how to write one yourself.

Underscores and name mangling

Python has no private keyword. It relies on naming conventions:

NameMeaning
namepublic
_nameinternal: not part of the public interface, but reachable
__namename mangled to _ClassName__name, to avoid clashes in subclasses
__name__reserved for Python's special names; do not invent your own

Mangling renames the attribute; it does not hide it. Anyone can still read obj._Base__token. Use a single underscore for "internal" and keep double underscores for the rare case of protecting a base class from name clashes.

No method overloading

Some languages let you define several methods with the same name and different parameter types. Python does not: a second def with the same name replaces the first. A parameter with a default value is not overloading either; it is one method with an optional argument.

When behaviour really depends on the argument's type, use functools.singledispatchmethod, which picks an implementation by the type of the first argument after self:

Dataclasses

Many classes exist only to hold data. @dataclass writes __init__, __repr__ and __eq__ for you from annotated class attributes:

field(default_factory=list) calls list() for each new object. A plain items: list[str] = [] is rejected with ValueError, which protects you from the shared list trap shown earlier.

Options passed to the decorator switch on more features:

OptionEffect
frozen=Truefields cannot be reassigned (FrozenInstanceError); instances become hashable
order=Trueadds <, <=, >, >=, comparing fields in order like a tuple
slots=Trueuses __slots__: less memory, and no new attributes by typo (3.10+)
kw_only=Trueall fields must be passed by keyword (3.10+)

Field by field ordering compares 9 < 10 as numbers, so 3.9 < 3.10 comes out right, unlike comparing the strings "3.9" and "3.10". For validation, add a __post_init__(self) method; the generated __init__ calls it after setting the fields.

typing.NamedTuple: a record that is also a tuple

A dataclass is the usual choice for a record. Sometimes you want a record that is still a tuple: immutable, easy to unpack, and accepted wherever a tuple is. typing.NamedTuple gives the namedtuple from Collections and comprehensions the class syntax and type hints of a dataclass:

Choose a NamedTuple when tuple behaviour helps, for example for a function that returns several values. Choose a dataclass when the record must change after it is created, or when you need field() options.

Matching objects with match

Control flow introduced class patterns such as case Point(x=0, y=y). Now that you can write classes, here they are at work:

A keyword pattern such as Point(x=0) checks the type and then the named attributes, so it works for every class. A positional pattern such as Point(0, y) needs to know which attribute each position means. A dataclass sets __match_args__ for you, in field order. For a class you write by hand, add __match_args__ = ("x", "y") yourself, or use keywords.

Enumerations

An enum is a fixed set of named constants. Use one instead of loose strings when a value can only be one of a few choices:

Look members up by value with Suit(...) and by name with Suit[...]. Each member is a singleton, so is works. A member is not equal to its raw value; if you need members that behave like strings or integers, use StrEnum (3.11+) or IntEnum. enum.auto() numbers members for you when the values do not matter.

Abstract base classes and protocols

An abstract base class (ABC) declares methods that subclasses must provide. Python refuses to create an instance until every abstract method has an implementation:

A protocol describes an interface by shape alone: any class with the right methods fits, with no inheritance needed. This is "duck typing" made visible to type checkers such as mypy. Those tools check protocols; the running program does not, unless you decorate the protocol with @typing.runtime_checkable, which lets isinstance() test that the methods exist:

Use an ABC when you own the hierarchy and want shared code in the base class. Use a Protocol when you only care that an object has certain methods.

Check yourself

  1. What is the difference between __new__ and __init__?
  2. A class has count = 0. You run a.count = 5 on one instance. What do b.count and the class's count show, and why?
  3. Why does a class method factory call cls(...) instead of the class name, and why should it pass keyword arguments?
  4. When does Python run a property's setter, and why do it in __init__ too?
  5. In the Child(Left, Right) example, why does super() inside Left reach Right?
  6. Which method does print(obj) use, and what happens if the class defines only __repr__?
  7. Does naming an attribute __secret make it private?
  8. When would you choose composition over inheritance?

Suggested answers

  1. __new__ creates and returns the new object; __init__ then initialises that existing object. You almost always write only __init__.
  2. Both stay 0. The assignment created an instance attribute on a that hides the class attribute; the class attribute did not change.
  3. cls is the class the method was called on, so subclasses get instances of themselves. Keywords stop arguments from landing in the wrong parameter when the factory's and the initialiser's parameter orders differ.
  4. On every assignment to the attribute, including self.x = ... in __init__. Assigning through the property there means even the first value is validated.
  5. super() follows the MRO of the instance's class (Child, Left, Right, Base, object), and Right comes after Left in that order.
  6. __str__. If the class has no __str__, Python falls back to __repr__.
  7. No. Python renames it to _ClassName__secret to avoid clashes in subclasses, and it remains reachable under that name.
  8. When the relationship is "has a" rather than "is a", or when you want to swap the part independently, for example a Car with an Engine, or a Deck that stores a list of cards.

Exercises

These exercises are optional practice. Pick the ones that help you.

  1. Cards and a deck. Write a frozen dataclass Card(rank: str, suit: str) whose str() is rank then suit, such as A♠. Write a dataclass Deck whose cards field defaults to all 52 cards (ranks 2 to 10, J, Q, K, A; suits ♣ ♦ ♥ ♠), suit by suit in that order, so that the last card of a fresh deck is A♠. Give it __len__, shuffle(rng: random.Random) and deal(count), which removes and returns count cards from the end of the list (the top of the deck) and raises ValueError when count is below 1 or more than the cards left. Shuffling two fresh decks with random.Random(7) must give the same order. Card returns in the Capstone: Blackjack simulator, where the deck becomes a Shoe.
  2. Bank account. Write BankAccount(owner, balance=0) that stores money as Decimal. balance is a property whose setter rejects negative values with ValueError. Amounts may be int, str or Decimal; a float raises TypeError because most cent values have no exact binary form. deposit(amount) and withdraw(amount) reject amounts of zero or less, and withdraw rejects amounts above the balance. repr() gives BankAccount(owner='Ana', balance=Decimal('12.50')).
  3. 2D vector. Write a class Vector(x, y) with + (returns a new vector), ==, abs() (the length) and a repr() such as Vector(4, 6). Adding a non vector raises TypeError. Example: Vector(1, 2) + Vector(3, 4) == Vector(4, 6) and abs(Vector(3, 4)) == 5.0.
  4. Shop errors. Create ShopError(Exception) with two subclasses: OutOfStockError, which stores item, requested and available, and PaymentDeclinedError, which stores reason. Write checkout(cart, stock, card_ok). It checks every item's stock first, then the payment, and only then subtracts the items from stock and returns the number of items bought. A failed checkout must leave stock unchanged. Example: with stock = {"tea": 5, "mug": 1}, checkout({"tea": 2}, stock, card_ok=True) returns 2 and leaves {"tea": 3, "mug": 1}. After that, checkout({"mug": 2}, stock, True) raises OutOfStockError and checkout({"tea": 1}, stock, False) raises PaymentDeclinedError, and neither changes stock.

Solutions

1. Cards and a deck.

frozen=True makes cards hashable, which is why set(deck.cards) works.

2. Bank account.

3. 2D vector.

A @dataclass(frozen=True) would give you __init__, __repr__ and __eq__ for free; you would then add only __add__ and __abs__.

4. Shop errors.