Classes and inheritance
Python is fully object-oriented: every value is an object, classes are first-class, and inheritance (including multiple inheritance) is admitted. The class system is prototype-flavoured but conventional in shape — instances have attributes, classes have methods, the resolution order is well-defined. Modern Python admits substantial enhancements: dataclasses (PEP 557, since 3.7) automate the conventional record-class boilerplate; properties admit attribute-like access to computed values; abstract base classes (the abc module) admit interface declarations; the Method Resolution Order (MRO) governs multiple inheritance. The combination admits a flexible OOP style that interoperates with duck typing.
This page covers class declarations, methods, properties, inheritance and the MRO, dataclasses, abstract classes, and the conventions for using each. The duck-typing side (protocols and dunders) is in Duck typing and protocols.
Class declaration
A class is introduced with class:
class Counter:
def __init__(self, initial: int = 0):
self.value = initial
def increment(self):
self.value += 1
def reset(self):
self.value = 0
The __init__ is the initialiser (not a constructor in the C++ sense; the constructor is __new__, rarely overridden). The self parameter is the instance; conventional first parameter of every instance method.
Instances are created by calling the class:
c = Counter(10)
c.increment()
print(c.value) # 11
Class names conventionally use CamelCase; methods and attributes use snake_case.
Instance and class attributes
Attributes are stored on the instance unless declared at class level:
class Widget:
count = 0 # class attribute (shared)
def __init__(self, name: str):
self.name = name # instance attribute (per-instance)
Widget.count += 1
The count is shared across all Widget instances; the name is per-instance. The distinction matters:
- Read:
widget.countfirst looks on the instance, then on the class. - Write:
widget.count = 5creates an instance attribute that shadows the class one.
The conventional discipline is to use class attributes for genuinely shared state (counters, registries, defaults) and instance attributes for per-instance state.
For mutable defaults, a class-level mutable would be shared (similar to the function default-argument trap):
class Container:
items = [] # WRONG: shared across instances
def add(self, item):
self.items.append(item) # mutates the shared list
# Right:
class Container:
def __init__(self):
self.items = [] # per-instance
Methods
A method is a function defined inside a class. The first parameter is conventionally self — the instance:
class Point:
def __init__(self, x: float, y: float):
self.x = x
self.y = y
def distance_to(self, other: "Point") -> float:
return ((self.x - other.x) ** 2 + (self.y - other.y) ** 2) ** 0.5
p = Point(3, 4)
q = Point(0, 0)
p.distance_to(q) # 5.0
The self parameter is supplied automatically when the method is called on an instance; Point.distance_to(p, q) and p.distance_to(q) are equivalent.
@classmethod and @staticmethod
Two decorators modify the dispatch:
class Date:
def __init__(self, year: int, month: int, day: int):
self.year = year
self.month = month
self.day = day
@classmethod
def today(cls) -> "Date":
# cls is the class; admits subclass-aware construction
import datetime
d = datetime.date.today()
return cls(d.year, d.month, d.day)
@staticmethod
def is_valid(year: int, month: int, day: int) -> bool:
# no implicit first parameter
return 1 <= month <= 12 and 1 <= day <= 31
@classmethod— the first parameter (cls) is the class (admits subclass-aware factory methods).@staticmethod— no implicit first parameter (a regular function in the class’s namespace).
The conventional uses:
@classmethodfor factory methods — alternative ways to construct an instance (Date.today(),Date.from_string("2024-01-15")).@staticmethodfor utility functions logically associated with the class but not requiring access to an instance or the class.
Properties
A property admits attribute-like access to computed values:
class Circle:
def __init__(self, radius: float):
self._radius = radius
@property
def radius(self) -> float:
return self._radius
@radius.setter
def radius(self, value: float):
if value < 0:
raise ValueError("negative radius")
self._radius = value
@property
def area(self) -> float:
return 3.14159 * self._radius ** 2
c = Circle(5)
print(c.radius) # 5; calls the getter
c.radius = 10 # calls the setter
print(c.area) # 314.159; computed
The @property decorator transforms a method into a getter; the @radius.setter adds a setter. The mechanism admits:
- Computed attributes (
areafromradius). - Validation on assignment (the setter checks).
- Backward-compatible API changes (a public attribute can become a property without breaking callers).
The conventional Python discipline is to use plain attributes by default and introduce properties only when validation, computation, or controlled access matters.
Inheritance
A class may inherit from one or more base classes:
class Animal:
def __init__(self, name: str):
self.name = name
def describe(self) -> str:
return f"{self.name} is an animal"
class Dog(Animal):
def __init__(self, name: str, breed: str):
super().__init__(name) # call Animal.__init__
self.breed = breed
def describe(self) -> str:
return f"{self.name} is a {self.breed}"
dog = Dog("Rex", "Labrador")
print(dog.describe()) # "Rex is a Labrador"
print(dog.name) # "Rex"; from Animal.__init__
The class Dog(Animal): declares Dog as a subclass of Animal. The super() admits calling the parent class’s methods; the conventional contemporary form (since 3.0) admits the no-argument super() which figures out the right class from context.
Method overriding
A subclass may override a method by redefining it:
class Animal:
def speak(self) -> str:
return "..."
class Dog(Animal):
def speak(self) -> str:
return "woof"
class Cat(Animal):
def speak(self) -> str:
return "meow"
There is no override keyword; the override is by name. Type checkers (mypy, pyright) verify that the override’s signature is compatible.
Multiple inheritance and MRO
Python admits multiple inheritance:
class Drawable:
def draw(self):
...
class Saveable:
def save(self):
...
class Shape(Drawable, Saveable):
def __init__(self, name):
self.name = name
The Method Resolution Order (MRO) determines the order in which Python searches base classes for an attribute. The MRO is computed by the C3 linearisation algorithm:
print(Shape.__mro__)
# (<class 'Shape'>, <class 'Drawable'>, <class 'Saveable'>, <class 'object'>)
The MRO is consulted for attribute lookup; for instance.method(), Python checks each class in the MRO in order.
The classic diamond problem (two parent classes both inherit from a common ancestor) is resolved by the C3 linearisation:
class A:
def method(self): print("A")
class B(A):
def method(self): print("B")
class C(A):
def method(self): print("C")
class D(B, C):
pass
d = D()
d.method() # "B"; B is before C in MRO
print(D.__mro__)
# (<class 'D'>, <class 'B'>, <class 'C'>, <class 'A'>, <class 'object'>)
The MRO ensures consistent and predictable resolution.
super()
The conventional way to call a parent method:
class Dog(Animal):
def __init__(self, name: str, breed: str):
super().__init__(name) # calls Animal.__init__
self.breed = breed
The no-argument super() (Python 3) figures out the class and instance from context. The pre-3.0 form super(Dog, self).__init__(name) is verbose and rarely seen in modern code.
For multiple inheritance, super() follows the MRO:
class Loggable:
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.log = []
class Identifiable:
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.id = generate_id()
class Widget(Loggable, Identifiable):
def __init__(self, name):
super().__init__() # calls Loggable, then Identifiable
self.name = name
The *args, **kwargs forwarding is the conventional pattern for cooperative multiple inheritance; each base class calls super().__init__() so that the chain runs to completion.
Dataclasses
Python 3.7 introduced dataclasses — automatic generation of __init__, __repr__, __eq__, and similar from field declarations:
from dataclasses import dataclass
@dataclass
class Point:
x: float
y: float
p1 = Point(3.0, 4.0)
p2 = Point(3.0, 4.0)
print(p1) # Point(x=3.0, y=4.0); auto __repr__
print(p1 == p2) # True; auto __eq__
The decorator generates:
__init__from the field declarations.__repr__showing the field values.__eq__based on structural equality.- (Optional)
__hash__, comparison operators.
Common options:
@dataclass(frozen=True) # immutable; hashable
class Point:
x: float
y: float
@dataclass(order=True) # also generates <, <=, >, >=
class Version:
major: int
minor: int
patch: int
@dataclass(slots=True) # uses __slots__ for memory efficiency
class Compact:
a: int
b: int
Dataclasses are the conventional Python idiom for value-class-like data; nearly every modern Python codebase uses them.
For more elaborate validation, the pydantic library extends dataclasses with type-checking and conversion at runtime — widely used in modern Python (notably in FastAPI).
Special methods (dunders)
Methods named __name__ participate in language protocols. The conventional ones:
| Method | Effect |
|---|---|
__init__ | Initialiser |
__del__ | Finaliser (rarely used) |
__repr__ | repr(x); debug representation |
__str__ | str(x); user-facing string |
__eq__, __hash__ | Equality and hashing |
__lt__, __le__, __gt__, __ge__ | Comparison |
__bool__ | Truthiness |
__len__ | len(x) |
__iter__ | Iterability |
__getitem__, __setitem__, __delitem__ | Subscripting |
__contains__ | in operator |
__add__, __sub__, __mul__, … | Arithmetic operators |
__call__ | Make instance callable |
__enter__, __exit__ | Context manager protocol |
The full treatment of dunders and protocol participation is in Duck typing and protocols.
Abstract base classes
The abc module admits abstract base classes — classes that declare an interface and cannot be instantiated:
from abc import ABC, abstractmethod
class Shape(ABC):
@abstractmethod
def area(self) -> float:
...
@abstractmethod
def perimeter(self) -> float:
...
def describe(self) -> str:
# concrete method using the abstract ones
return f"area={self.area()}, perimeter={self.perimeter()}"
class Circle(Shape):
def __init__(self, radius: float):
self.radius = radius
def area(self) -> float:
return 3.14159 * self.radius ** 2
def perimeter(self) -> float:
return 2 * 3.14159 * self.radius
# Cannot instantiate abstract:
Shape() # TypeError: can't instantiate abstract class
c = Circle(5)
c.describe() # works; Circle implements all abstract methods
The @abstractmethod decorator marks methods as abstract; the class cannot be instantiated until all abstract methods are implemented.
The collections.abc module provides ABCs for the conventional protocols (Iterable, Sequence, Mapping, Set); the abc.ABC is the base for user-defined ABCs.
For structural typing (where any conforming type works, not just declared subclasses), typing.Protocol is the conventional choice; treated in Duck typing and protocols.
__slots__
By default, instances store attributes in a per-instance __dict__. The __slots__ declaration replaces the dict with fixed slots:
class Compact:
__slots__ = ("x", "y")
def __init__(self, x: float, y: float):
self.x = x
self.y = y
c = Compact(3, 4)
c.z = 5 # AttributeError: 'Compact' object has no attribute 'z'
The mechanism reduces memory per instance (no per-instance dict) and admits faster attribute access. The trade-off is that new attributes cannot be added at runtime; instances are also unpicklable by default unless the slots are arranged correctly.
The conventional uses are performance-sensitive types with many instances (game entities, packets, geometric primitives). Most user-defined classes do not need __slots__.
Multiple inheritance and mixins
A mixin is a class designed to be combined via multiple inheritance. The conventional pattern:
class JSONSerializable:
def to_json(self) -> str:
return json.dumps(self.__dict__)
class Persistable:
def save(self, path: str):
with open(path, "w") as f:
f.write(self.to_json())
class User(JSONSerializable, Persistable):
def __init__(self, name: str, email: str):
self.name = name
self.email = email
u = User("Alice", "alice@example.com")
u.save("user.json")
The mixins provide cross-cutting behaviour (to_json, save); User combines them with its own logic. The pattern is the conventional Python alternative to interface-based composition.
__init_subclass__
A hook that runs when a class is subclassed:
class Plugin:
plugins = []
def __init_subclass__(cls, **kwargs):
super().__init_subclass__(**kwargs)
Plugin.plugins.append(cls)
class MyPlugin(Plugin):
pass
class AnotherPlugin(Plugin):
pass
print(Plugin.plugins)
# [<class 'MyPlugin'>, <class 'AnotherPlugin'>]
The mechanism admits self-registering plugin systems and similar patterns. It is rare in routine code but useful for frameworks.
Common patterns
Builder pattern via fluent methods
class QueryBuilder:
def __init__(self):
self.filters = []
self.order = None
def filter(self, condition):
self.filters.append(condition)
return self
def order_by(self, field):
self.order = field
return self
q = QueryBuilder().filter("age > 18").filter("active").order_by("name")
Each method returns self; the calls chain.
Factory method
class Shape:
@classmethod
def from_dict(cls, data: dict):
kind = data["kind"]
if kind == "circle":
return Circle(data["radius"])
if kind == "square":
return Square(data["side"])
raise ValueError(f"unknown shape: {kind}")
The class method admits alternative construction paths.
Property for backward compatibility
class Person:
def __init__(self, first_name: str, last_name: str):
self.first_name = first_name
self.last_name = last_name
@property
def full_name(self) -> str:
return f"{self.first_name} {self.last_name}"
The full_name looks like an attribute but is computed; admits substituting a property for a stored attribute later without breaking callers.
Dataclass with default factory
from dataclasses import dataclass, field
@dataclass
class Container:
items: list = field(default_factory=list)
metadata: dict = field(default_factory=dict)
The field(default_factory=list) is the dataclass equivalent of the None-sentinel pattern for mutable defaults.
Context manager class
class FileHolder:
def __init__(self, path: str, mode: str):
self.path = path
self.mode = mode
def __enter__(self):
self.f = open(self.path, self.mode)
return self.f
def __exit__(self, exc_type, exc_value, traceback):
self.f.close()
return False
with FileHolder("input.txt", "r") as f:
contents = f.read()
The __enter__/__exit__ admits the class as a context manager. Treated in Duck typing and protocols and Error handling.
Singleton
class Config:
_instance = None
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
The pattern admits a single instance per process. Modern Python conventionally avoids singletons in favour of dependency injection or module-level state.
A note on the conventional style
Python’s class system is flexible but the conventional style is restrained:
- Use dataclasses for value-like records.
- Use plain classes for behaviour-rich types.
- Use
Protocolfor structural interfaces. - Use ABCs sparingly — when subtype dispatch is genuine.
- Avoid deep inheritance; favour composition.
- Use
@propertyfor computed access, not for getters around stored attributes. - Use
__slots__only for performance-critical types.
The combination admits a substantial OOP style without forcing it on every program; Python’s flexibility lets simple data structures stay simple and complex structures use the full mechanism.