Object-Oriented Design in Python
A class defines a type and its behavior. Calling the class creates an instance;
__init__ initializes that already-created instance. It is therefore more
precise to call __init__ an initializer rather than the constructor itself.
class Account:
institution = "Example Credit Union" # shared class attribute
def __init__(self, owner: str, balance: int = 0) -> None:
if balance < 0:
raise ValueError("opening balance cannot be negative")
self.owner = owner # instance attributes
self._balance = balance
def deposit(self, amount: int) -> None:
if amount <= 0:
raise ValueError("amount must be positive")
self._balance += amount
@property
def balance(self) -> int:
return self._balance
An instance method is a function stored on the class. Attribute lookup binds the
instance as the first argument, conventionally named self.
State and invariants
Keep instance state on the instance. A mutable class attribute is shared by all instances and is a common source of accidental global state.
Methods are useful when they preserve an invariant: an Account can reject an
invalid deposit instead of exposing arbitrary balance mutation. Properties can
add validation or computation while retaining attribute syntax, but plain public
attributes are appropriate when no invariant needs protection.
Python's leading underscore is a non-public convention, not an access-control
boundary. Within a class definition, names with at least two leading underscores and at most one trailing underscore undergo name mangling. This helps avoid subclass collisions but does not provide secrecy; names such as __init__ are not mangled.
Choose the smallest model
- Use a plain function when behavior does not need persistent state.
- Use a
dataclasses.dataclassfor a record-like value with generated boilerplate. - Use composition when one object can delegate to another.
- Use inheritance when the subtype genuinely satisfies the parent's behavioral contract, not merely to reuse a few lines of code.
Python code often depends on behavior rather than a concrete inheritance tree.
An object that supports the required protocol can be accepted regardless of its
class. Static type checkers can express this with typing.Protocol.
@classmethod receives the class and is useful for alternate constructors.
@staticmethod receives neither class nor instance; if it is not meaningfully
part of the type's namespace, a module-level function is usually simpler.
Special methods such as __repr__, __eq__, and __len__ integrate a type with
Python's data model. Implement them according to their documented contracts; do
not call special methods directly when the corresponding built-in operation
exists.
Using instances and observing attribute lookup
For this example, amounts are integer minor currency units supplied by the caller.
Annotations do not check that assumption: a positive float or True would also
pass the current test. A boundary accepting untrusted amounts needs explicit type
and range validation. This class illustrates state ownership, not a complete
financial ledger.
ada = Account("Ada", 100)
grace = Account("Grace")
assert ada.deposit(25) is None
assert ada.balance == 125
assert grace.balance == 0
Account.deposit(grace, 10) # equivalent to grace.deposit(10)
assert grace.balance == 10
ada.institution = "Local branch"
assert ada.institution == "Local branch"
assert grace.institution == Account.institution
For ordinary data attributes, lookup finds instance state before falling back to
class state. Assigning ada.institution therefore shadows the class attribute;
it does not change the class or grace. A property follows descriptor rules:
ada.balance = 0 raises AttributeError because this property has no setter.
Direct assignment to ada._balance remains possible by convention.
The classes tutorial
explains inheritance and method overriding. A subclass declares its base as
class Child(Parent): and may replace a method. If it defines __init__, it must
call super().__init__(...) when the base initialization is required; that call
is not inserted automatically. super() follows the method resolution order,
not simply a hard-coded parent, which matters with multiple inheritance.