Polyglot
Languages Python oop
Python § oop

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.count first looks on the instance, then on the class.
  • Write: widget.count = 5 creates 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:

  • @classmethod for factory methods — alternative ways to construct an instance (Date.today(), Date.from_string("2024-01-15")).
  • @staticmethod for 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 (area from radius).
  • 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:

MethodEffect
__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 Protocol for structural interfaces.
  • Use ABCs sparingly — when subtype dispatch is genuine.
  • Avoid deep inheritance; favour composition.
  • Use @property for 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.