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)
| Operation | Operator | Method |
|---|---|---|
| Addition | + | __add__ |
| Equality | == | __eq__ |
| Less than | < | __lt__ |
| Length | len(obj) | __len__ |
| Indexing | obj[key] | __getitem__ / __setitem__ |
| Membership | x in obj | __contains__ |
| Iteration | for x in obj | __iter__ / __next__ |
| Context manager | with 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
- 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. - 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. - Why must a dataclass use
field(default_factory=list)instead oftags: 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 calllist()fresh for each new instance. - What actually stops you from instantiating a class with an unimplemented
@abstractmethod? —ABCMeta(the metaclass behindABC) checks at instantiation (__call__) whether any name marked@abstractmethodis still unoverridden in the concrete class; if so it raisesTypeErrorimmediately, rather than letting the missing method surface later as anAttributeErrorat call time the way duck typing would. - 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 (AttributeErroron anything not declared), default multiple inheritance across two non-empty-__slots__classes, and weak-referenceability unless__weakref__is added explicitly. - What is
@propertyactually 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 letsc.radius = -1look like a plain assignment while actually running validation code. - What does a metaclass intercept that a class decorator can't? — Class creation itself (
__new__/__init__of the metaclass, run once when theclassstatement 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]]