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,inandforthrough special methods - Replace boilerplate with dataclasses and enums, and describe interfaces with
ABCandProtocol
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 = 100did not change the class attribute. It created a new instance attribute that hides it, foraonly. a.tags.append("x")did not assign anything. It changed the one list thata.tags,b.tagsandCounter.tagsall 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
| Kind | First parameter | Typical use |
|---|---|---|
| instance method | self, the instance | work with one object's data |
@classmethod | cls, the class it was called on | alternative constructors ("factories") |
@staticmethod | none | a 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"):
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 write | Python calls |
|---|---|
repr(x), the REPL, inside containers | x.__repr__() |
str(x), print(x), f-strings | x.__str__() (falls back to __repr__) |
x == y, x < y | x.__eq__(y), x.__lt__(y) |
x + y | x.__add__(y) |
len(x); also bool(x) when there is no __bool__ | x.__len__() |
for item in x | x.__iter__() |
item in x | x.__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 clearTypeErroror, for==, falls back to an identity check, which is whyprice == 19.99isFalse. sortedonly 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:
| Name | Meaning |
|---|---|
name | public |
_name | internal: not part of the public interface, but reachable |
__name | name 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:
| Option | Effect |
|---|---|
frozen=True | fields cannot be reassigned (FrozenInstanceError); instances become hashable |
order=True | adds <, <=, >, >=, comparing fields in order like a tuple |
slots=True | uses __slots__: less memory, and no new attributes by typo (3.10+) |
kw_only=True | all 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
- What is the difference between
__new__and__init__? - A class has
count = 0. You runa.count = 5on one instance. What dob.countand the class'scountshow, and why? - Why does a class method factory call
cls(...)instead of the class name, and why should it pass keyword arguments? - When does Python run a property's setter, and why do it in
__init__too? - In the
Child(Left, Right)example, why doessuper()insideLeftreachRight? - Which method does
print(obj)use, and what happens if the class defines only__repr__? - Does naming an attribute
__secretmake it private? - When would you choose composition over inheritance?
Suggested answers
__new__creates and returns the new object;__init__then initialises that existing object. You almost always write only__init__.- Both stay
0. The assignment created an instance attribute onathat hides the class attribute; the class attribute did not change. clsis 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.- On every assignment to the attribute, including
self.x = ...in__init__. Assigning through the property there means even the first value is validated. super()follows the MRO of the instance's class (Child, Left, Right, Base, object), andRightcomes afterLeftin that order.__str__. If the class has no__str__, Python falls back to__repr__.- No. Python renames it to
_ClassName__secretto avoid clashes in subclasses, and it remains reachable under that name. - When the relationship is "has a" rather than "is a", or when you want to swap the part independently, for example a
Carwith anEngine, or aDeckthat stores a list of cards.
Exercises
These exercises are optional practice. Pick the ones that help you.
- Cards and a deck. Write a frozen dataclass
Card(rank: str, suit: str)whosestr()is rank then suit, such asA♠. Write a dataclassDeckwhosecardsfield defaults to all 52 cards (ranks2to10,J,Q,K,A; suits♣ ♦ ♥ ♠), suit by suit in that order, so that the last card of a fresh deck isA♠. Give it__len__,shuffle(rng: random.Random)anddeal(count), which removes and returnscountcards from the end of the list (the top of the deck) and raisesValueErrorwhencountis below 1 or more than the cards left. Shuffling two fresh decks withrandom.Random(7)must give the same order.Cardreturns in the Capstone: Blackjack simulator, where the deck becomes aShoe. - Bank account. Write
BankAccount(owner, balance=0)that stores money asDecimal.balanceis a property whose setter rejects negative values withValueError. Amounts may beint,strorDecimal; afloatraisesTypeErrorbecause most cent values have no exact binary form.deposit(amount)andwithdraw(amount)reject amounts of zero or less, andwithdrawrejects amounts above the balance.repr()givesBankAccount(owner='Ana', balance=Decimal('12.50')). - 2D vector. Write a class
Vector(x, y)with+(returns a new vector),==,abs()(the length) and arepr()such asVector(4, 6). Adding a non vector raisesTypeError. Example:Vector(1, 2) + Vector(3, 4) == Vector(4, 6)andabs(Vector(3, 4)) == 5.0. - Shop errors. Create
ShopError(Exception)with two subclasses:OutOfStockError, which storesitem,requestedandavailable, andPaymentDeclinedError, which storesreason. Writecheckout(cart, stock, card_ok). It checks every item's stock first, then the payment, and only then subtracts the items fromstockand returns the number of items bought. A failed checkout must leavestockunchanged. Example: withstock = {"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)raisesOutOfStockErrorandcheckout({"tea": 1}, stock, False)raisesPaymentDeclinedError, and neither changesstock.
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.