Back to Notes

Python OOP & Data Model

The Four Pillars, Concretely

  • Encapsulation — bundling data and methods together, hiding internal state so nothing outside the class depends on it directly. In Python this is achieved mostly by convention, not enforcement: prefixing with __ is a strong signal, not a hard lock (Python's dynamic nature makes true access control hard to enforce, unlike Go's package-level privacy).
  • Abstraction — exposing a simple interface while hiding complex implementation (abc.ABC, @abstractmethod). Abstraction vs encapsulation, precisely: abstraction is about what's exposed (a simple interface for complex behavior); encapsulation is about hiding internal state so nothing depends on it. They're often conflated but answer different questions.
  • Inheritance — a child class inherits properties/methods from a parent, avoiding rewriting shared logic (class Dog(Animal)).
  • Polymorphism — the same interface produces different behavior depending on the actual object (method overriding, duck typing).

Classes, Self, Instance vs. Class Variables

class Wall:
    height = 10                    # CLASS variable — shared across all instances

    def __init__(self, armor):
        self.armor = armor         # INSTANCE variable — unique per object

south_wall = Wall(5)
south_wall.height = 20             # only rebinds THIS instance's attribute lookup, doesn't touch the class
print(Wall.height)                 # still 10

Wall.height = 30                   # mutating the class variable itself DOES affect all instances

General guidance: avoid class variables for mutable shared state — they behave like global variables (hard to track which code path is modifying shared state) and are a common source of subtle bugs, especially with mutable defaults (a class-level items = [] is shared across every instance unless explicitly reassigned in __init__).

Encapsulation in Practice

class Wall:
    def __init__(self, armor, magic_resistance):
        self.__armor = armor                  # name-mangled to _Wall__armor
        self.__magic_resistance = magic_resistance

    def get_defense(self):
        return self.__armor + self.__magic_resistance

wall = Wall(10, 20)
wall.__armor        # AttributeError — not accessible by the plain name
wall.get_defense()  # 30 — works through the public method

Double-underscore prefixing triggers name mangling (__armor becomes _ClassName__armor internally) — a convention-based deterrent, not a real access-control mechanism (a determined caller can still reach wall._Wall__armor directly). State this precisely if asked "is Python's encapsulation secure" — the honest answer is no, it's organizational, not enforced.

Inheritance, super(), and MRO

class Animal:
    def __init__(self, name):
        self.name = name

class Dog(Animal):
    def __init__(self, name, breed):
        super().__init__(name)     # calls Animal.__init__ without hardcoding "Animal"
        self.breed = breed

super() matters most under multiple inheritance, where it doesn't just mean "my direct parent" — it follows the MRO (Method Resolution Order), computed via C3 linearization, which determines a single, consistent search order across a whole inheritance graph.

class A: pass
class B(A): pass
class C(A): pass
class D(B, C): pass

D.__mro__   # (D, B, C, A, object)

isinstance(obj, T) checks whether obj's class is T or a subclass of it. issubclass(bool, int) is True (bool is implemented as a subclass of int in Python) — a real, sometimes-surprising gotcha worth knowing (issubclass(float, int) is False, since float is not implemented as an int subclass despite the informal numeric relationship).

Polymorphism & Duck Typing

class Creature:
    def move(self): print("the creature moves")

class Dragon(Creature):
    def move(self): print("the dragon flies")

class Kraken(Creature):
    def move(self): print("the kraken swims")

for c in [Creature(), Dragon(), Kraken()]:
    c.move()
# the creature moves / the dragon flies / the kraken swims

Duck typing goes further than inheritance-based polymorphism — Python doesn't check an object's declared type at all, only whether it has the required method/attribute at the moment it's used ("if it walks like a duck and quacks like a duck, it's a duck"):

class Dog:
    def speak(self): return "Woof"
class Cat:
    def speak(self): return "Meow"

def make_sound(animal):
    print(animal.speak())     # works for ANY object with a .speak() method — no shared base class required

make_sound(Dog())
make_sound(Cat())

Abstract Base Classes (abc)

Duck typing works at runtime only — nothing stops a caller from instantiating an incomplete class or forgetting a method until it blows up mid-call. abc.ABC + @abstractmethod make "must implement this" a load-bearing contract enforced at instantiation time, not just a docstring promise.

from abc import ABC, abstractmethod

class Shape(ABC):
    @abstractmethod
    def area(self) -> float:
        ...

    @abstractmethod
    def perimeter(self) -> float:
        ...

    def describe(self):                # concrete method — ABCs CAN mix abstract + concrete
        return f"area={self.area()}, perimeter={self.perimeter()}"

class Circle(Shape):
    def __init__(self, r):
        self.r = r
    def area(self):
        return 3.14159 * self.r ** 2
    def perimeter(self):
        return 2 * 3.14159 * self.r

Shape()          # TypeError: Can't instantiate abstract class Shape with abstract methods area, perimeter
Circle(5).area()  # 78.53975 — fine, all abstract methods implemented

How enforcement actually works: ABC's metaclass (ABCMeta) tracks which names are decorated @abstractmethod on the class; instantiation is blocked (TypeError) if any of them remain unoverridden in the concrete subclass. It's a runtime check at __call__ time — not static/compile-time, since Python has no compile step. A subclass that overrides only some abstract methods is still abstract itself and still can't be instantiated.

Why bother if duck typing already works? Duck typing fails silently — you find the missing method only when that code path executes, possibly in production. An ABC fails loudly and immediately at instantiation, which is worth it for a class hierarchy with multiple implementers (plugins, strategy pattern, driver interfaces) where "did you implement the full contract" needs to be checked once, not per-call.

abc.ABC vs typing.Protocol: ABC is nominal typing — a class must explicitly inherit from it to satisfy the contract. Protocol (structural typing / "static duck typing") lets any class satisfy an interface just by having the right methods, no inheritance required — closer to how Go interfaces work. Use ABC when you own the class hierarchy and want a shared base with common concrete methods; use Protocol when you're typing third-party or unrelated classes you can't retrofit an inheritance relationship onto.

__slots__ — Trading Flexibility for Memory

By default, every instance carries a __dict__ for arbitrary attribute storage — flexible, but it costs real memory per instance (a hash table, not just the attributes themselves). __slots__ declares a fixed set of allowed attributes, so instances store them in a compact fixed-size layout instead.

class PointDict:
    def __init__(self, x, y):
        self.x, self.y = x, y

class PointSlots:
    __slots__ = ("x", "y")
    def __init__(self, x, y):
        self.x, self.y = x, y

p = PointSlots(1, 2)
p.z = 3          # AttributeError — 'z' isn't in __slots__, no __dict__ to fall back on

PointSlots instances are meaningfully smaller (no per-instance __dict__) and attribute access is slightly faster (fixed offset lookup vs. hash table lookup) — worth it at scale: millions of instances (e.g., parsed rows, graph nodes) where the __dict__ overhead adds up to real memory pressure. Trade-off: no dynamic attributes, no default multiple inheritance from two classes that both define non-empty __slots__, and __weakref__ needs to be added explicitly to __slots__ if you need the instances to be weakly referenceable. Don't reach for it by default — only when profiling shows instance count × __dict__ overhead is actually the bottleneck.

Descriptors — What @property Actually Is Underneath

A descriptor is any object implementing __get__/__set__/__delete__, placed as a class attribute — Python's attribute-lookup machinery calls these methods instead of doing plain dict lookup whenever the attribute is accessed on an instance. @property is the most common descriptor most people never realize they're using.

class Positive:
    def __set_name__(self, owner, name):
        self.name = "_" + name              # storage attribute name, e.g. "_radius"

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return getattr(obj, self.name)

    def __set__(self, obj, value):
        if value < 0:
            raise ValueError(f"{self.name[1:]} cannot be negative")
        setattr(obj, self.name, value)

class Circle:
    radius = Positive()                     # descriptor instance, shared across ALL Circle instances

    def __init__(self, radius):
        self.radius = radius                # triggers Positive.__set__, not a plain dict write

c = Circle(5)
c.radius        # 5 — triggers Positive.__get__
c.radius = -1   # ValueError — triggers Positive.__set__, validation runs

Why this matters beyond trivia: this is the exact mechanism @property is sugar for — @property just auto-generates a one-off descriptor class per decorated method. Knowing descriptors directly explains why @property intercepts plain-looking attribute access (c.radius = -1 looking like a dict write but actually calling a method), and it's the same mechanism behind Django model fields, SQLAlchemy columns, and functools.cached_property — recognizing "this framework field magically validates/transforms on assignment" as a descriptor, not special-case framework magic, is a senior-level signal.

Metaclasses — Classes Are Objects Too

type is itself a class, and by default it's the metaclass of every class — class Foo: ... is sugar for Foo = type("Foo", (), {...}). A custom metaclass intercepts class creation itself, not instance creation — it runs once when the class statement executes, not once per instance.

class Meta(type):
    def __new__(mcs, name, bases, namespace):
        print(f"creating class {name}")
        for key, value in namespace.items():
            if callable(value) and not key.startswith("__"):
                namespace[key] = staticmethod(lambda *a, **kw: print("intercepted"))
        return super().__new__(mcs, name, bases, namespace)

class MyClass(metaclass=Meta):     # prints "creating class MyClass" AT DEFINITION TIME
    def greet(self):
        print("hello")

ABCMeta (from the ABC section above) is the concrete, common example: it's a metaclass that hooks class and instantiation to enforce "no unimplemented @abstractmethods." Other real uses: ORMs (Django/SQLAlchemy model classes register themselves and build a schema from class-body annotations at class-creation time), enforcing naming conventions across a whole class hierarchy, and auto-registering plugin subclasses in a registry dict.

Metaclass vs. class decorator — when each is the right tool: both can modify a class after its body executes, but a metaclass controls the actual creation process (can change bases, intercept __new__/__init__ of the class itself, affects every subclass automatically since metaclasses are inherited) — reach for it only when you need that inheritance propagation or need to hook into creation before the class object fully exists. A class decorator (@register above a class Foo:) is simpler, sufficient, and more idiomatic for the common case of "do something once after this specific class is built" (register it, add a method, validate its shape) without needing every subclass to automatically inherit the behavior.

Operator Overloading (Dunder Methods)

OperationOperatorMethod
Addition+__add__
Equality==__eq__
Less than<__lt__
Lengthlen(obj)__len__
Indexingobj[key]__getitem__ / __setitem__
Membershipx in obj__contains__
Iterationfor x in obj__iter__ / __next__
Context managerwith obj:__enter__ / __exit__

__str__ vs __repr__ — a frequently-confused pair:

class Point:
    def __init__(self, x, y): self.x, self.y = x, y
    def __repr__(self):
        return f"Point({self.x!r}, {self.y!r})"   # for DEVELOPERS — unambiguous, ideally eval-able
    def __str__(self):
        return f"({self.x}, {self.y})"             # for END USERS — readable

repr(Point(1, 2))   # "Point(1, 2)"  — shown in REPL, logging, debugging
str(Point(1, 2))    # "(1, 2)"       — shown by print()

If only __repr__ is defined, it's used as the fallback for str() too — so defining __repr__ alone is a reasonable minimum; defining only __str__ leaves debugging output (REPL, logs) using the default, unhelpful <Point object at 0x...>.

@property, @classmethod, @staticmethod

class Circle:
    def __init__(self, radius):
        self._radius = radius

    @property
    def radius(self):
        return self._radius

    @radius.setter
    def radius(self, value):
        if value < 0:
            raise ValueError("Radius cannot be negative")
        self._radius = value

c = Circle(5)
c.radius       # 5 — looks like plain attribute access
c.radius = -1  # ValueError — validation runs transparently

@property lets you start with a plain attribute and later add validation/computed logic without changing the public API — callers still write c.radius, unaware a method is running underneath.

class MyClass:
    class_var = "shared"

    @classmethod
    def class_method(cls):     # receives the CLASS, not an instance — often used as alternative constructors
        return cls()

    @staticmethod
    def static_method():       # receives NEITHER self nor cls — just a namespaced plain function
        return "no access to class or instance"

@classmethod is the right tool for alternative constructors (Point.from_tuple((1,2)) style); @staticmethod is appropriate when a method is logically related to the class but genuinely needs no access to instance or class state at all.

@dataclass

from dataclasses import dataclass, field

@dataclass
class Point:
    x: float
    y: float
    label: str = "origin"
    tags: list = field(default_factory=list)   # mutable default — MUST use field(), not tags=[]

p = Point(1.0, 2.0)
print(p)   # Point(x=1.0, y=2.0, label='origin', tags=[])

Auto-generates __init__, __repr__, and __eq__ from the annotated fields — eliminating the boilerplate of writing these by hand for simple value-holding classes. Note the mutable-default fix required here is the class-level version of the mutable-default-argument gotcha in [[Identity, Mutability & Memory]] — tags: list = [] directly on the class would share one list across every instance; field(default_factory=list) creates a fresh list per instance instead.

Interview Drills

  1. Why is Python's encapsulation described as "by convention, not by force"? — Prefixing an attribute with __ triggers name mangling (a rename, not a lock) — the attribute is still reachable via its mangled name (obj._ClassName__attr); Python's dynamic nature doesn't provide a true private-access enforcement mechanism the way some statically-typed languages do.
  2. What is MRO, and why does super() need it specifically under multiple inheritance? — MRO (Method Resolution Order, computed via C3 linearization) defines one consistent search order across a diamond-shaped or otherwise complex inheritance graph; super() follows this order rather than naively going to "the first listed parent," which is what makes cooperative multiple inheritance work correctly rather than ambiguously.
  3. Why must a dataclass use field(default_factory=list) instead of tags: list = []? — This is the class-level instance of the mutable-default-argument gotcha — a bare [] as a class-level default would be created once and shared across every instance of the dataclass; field(default_factory=list) tells the generated __init__ to call list() fresh for each new instance.
  4. What actually stops you from instantiating a class with an unimplemented @abstractmethod? — ABCMeta (the metaclass behind ABC) checks at instantiation (__call__) whether any name marked @abstractmethod is still unoverridden in the concrete class; if so it raises TypeError immediately, rather than letting the missing method surface later as an AttributeError at call time the way duck typing would.
  5. When would you reach for __slots__, and what do you give up? — When instance count is large enough that per-instance __dict__ overhead is a measurable memory cost (millions of small objects) — you give up dynamic attribute assignment (AttributeError on anything not declared), default multiple inheritance across two non-empty-__slots__ classes, and weak-referenceability unless __weakref__ is added explicitly.
  6. What is @property actually doing under the hood? — It's sugar for a descriptor — an auto-generated class implementing __get__/__set__, installed as a class attribute; Python's attribute-lookup protocol calls those methods instead of a plain __dict__ read/write whenever the attribute is accessed on an instance, which is what lets c.radius = -1 look like a plain assignment while actually running validation code.
  7. What does a metaclass intercept that a class decorator can't? — Class creation itself (__new__/__init__ of the metaclass, run once when the class statement executes, before the class object fully exists) — and because metaclasses are inherited, every subclass automatically goes through the same creation hook. A class decorator only runs once, after one specific class is already built, and that behavior does not automatically propagate to subclasses.

Cross-links

[[Identity, Mutability & Memory]] · [[Python Decorators]] · [[SOLID Principles]] · [[OOD Design Patterns]]