description: "Advanced OOP — An ABC defines a contract: subclasses must implement certain methods, or Python refuses to let you instantiate them. This catches missing…"---
01 · Advanced OOP¶
🎥 Video walkthrough¶
Level 2 covered classes, inheritance, and dunder methods. This module goes further: formal interfaces with abstract base classes, composing behavior with mixins, and a first look at metaclasses — the mechanism that creates classes themselves.
Abstract base classes (ABCs)¶
An ABC defines a contract: subclasses must implement certain methods, or Python refuses to let you instantiate them. This catches missing implementations at object-creation time instead of failing later when the missing method is finally called.
from abc import ABC, abstractmethod
class PaymentMethod(ABC):
@abstractmethod
def charge(self, amount):
"""Charge the given amount. Must be implemented by subclasses."""
@abstractmethod
def refund(self, amount):
"""Refund the given amount. Must be implemented by subclasses."""
def receipt(self, amount):
# concrete methods can still live on an ABC and be shared by all subclasses
return f"Charged {amount} via {type(self).__name__}"
class CreditCard(PaymentMethod):
def charge(self, amount):
return f"Charging ${amount} to credit card"
def refund(self, amount):
return f"Refunding ${amount} to credit card"
try:
PaymentMethod() # TypeError: Can't instantiate abstract class
except TypeError as e:
print(e)
card = CreditCard()
print(card.charge(50))
print(card.receipt(50))
If CreditCard forgot to implement refund, instantiating it would raise
TypeError: Can't instantiate abstract class CreditCard with abstract method refund
— caught immediately, rather than a confusing AttributeError deep in
production.
Mixins — composing reusable behavior¶
A mixin is a small class meant to be combined with others via multiple inheritance, adding one focused piece of behavior rather than representing a complete "is-a" relationship.
class JSONSerializableMixin:
def to_json(self):
import json
return json.dumps(self.__dict__)
class ComparableByNameMixin:
def __eq__(self, other):
return self.name == other.name
def __lt__(self, other):
return self.name < other.name
class Product(JSONSerializableMixin, ComparableByNameMixin):
def __init__(self, name, price):
self.name = name
self.price = price
p1 = Product("Widget", 9.99)
p2 = Product("Gadget", 19.99)
print(p1.to_json()) # {"name": "Widget", "price": 9.99}
print(sorted([p1, p2])) # sorted using ComparableByNameMixin's __lt__
Method resolution order (MRO)¶
When a class inherits from multiple parents, Python needs a deterministic order to search for methods — the MRO, computed with the C3 linearization algorithm.
class A:
def greet(self):
return "A"
class B(A):
def greet(self):
return "B -> " + super().greet()
class C(A):
def greet(self):
return "C -> " + super().greet()
class D(B, C):
def greet(self):
return "D -> " + super().greet()
print(D().greet()) # D -> B -> C -> A
print([cls.__name__ for cls in D.__mro__])
# ['D', 'B', 'C', 'A', 'object']
Each super().greet() call moves to the next class in the MRO — not
necessarily straight to the immediate parent — which is what lets B and C
both run exactly once even though both inherit from A.
Properties, revisited: computed & cached attributes¶
from functools import cached_property
class Report:
def __init__(self, rows):
self.rows = rows
@cached_property
def total(self):
print("computing total...") # only prints once
return sum(self.rows)
report = Report([10, 20, 30])
print(report.total) # computing total... / 60
print(report.total) # 60 (cached — no recomputation)
@cached_property (from functools) computes the value once on first access
and stores it on the instance, useful for expensive derived values that don't
change.
A first look at metaclasses¶
A class is itself an instance of something — normally type. A metaclass lets
you customize how classes get built, e.g. validating their structure or
auto-registering subclasses.
class ValidatingMeta(type):
def __new__(mcs, name, bases, namespace):
# runs once, when the CLASS (not instance) is created
if "required_field" not in namespace and bases:
raise TypeError(f"{name} must define 'required_field'")
return super().__new__(mcs, name, bases, namespace)
class Base(metaclass=ValidatingMeta):
required_field = None # satisfies the base class itself
class Good(Base):
required_field = "present"
try:
class Bad(Base):
pass # missing required_field
except TypeError as e:
print(e) # Bad must define 'required_field'
Metaclasses are a deep topic — Level 4's
Metaprogramming module covers them (and
the more commonly used __init_subclass__ alternative) in much more depth.
For now, recognize that class Foo(metaclass=Something) hooks into how the
class object itself gets built.
Cheat sheet¶
| Concept | Purpose |
|---|---|
ABC + @abstractmethod |
enforce a required interface |
| Mixin | share one focused behavior across unrelated classes |
MRO (ClassName.__mro__) |
the order Python searches for attributes/methods |
@cached_property |
compute an expensive attribute once, reuse it |
metaclass= |
customize how classes themselves are constructed |
How It Actually Works¶
An ABC's enforcement isn't checked at method-call time — it's checked at
instantiation time, and the mechanism is a metaclass. ABC is built on
ABCMeta, a subclass of type; @abstractmethod just tags a function object with
a __isabstractmethod__ = True attribute. When a class body finishes executing,
ABCMeta.__new__ scans the resulting namespace (and inherited members) for anything
still carrying that flag and stores the set of unimplemented names on the class as
__abstractmethods__. object.__new__ (called during instantiation) checks that set
and raises TypeError immediately if it's non-empty — which is exactly why
PaymentMethod() fails before __init__ ever runs, and why a subclass that
overrides every abstract method with a concrete one becomes instantiable again: its
own __abstractmethods__ computes to empty.
The MRO for class D(B, C) is not "depth-first left-to-right" (the old, broken
classic algorithm) — CPython computes it with C3 linearization: merge the MROs
of B and C plus the list [B, C] itself, taking the head of the first list that
doesn't appear in the tail of any other list, repeating until nothing's left. This
guarantees each ancestor appears exactly once and before its own parents, and that
the order respects each base's own local precedence — that's why D → B → C → A,
not D → B → A → C → A. super().greet() doesn't call "the parent" — it looks up
type(self).__mro__, finds the calling class's position in it, and calls the next
entry — which is why B.greet and C.greet both run exactly once even though both
ultimately inherit from A: each super() call advances one shared position along
one shared list, not down two independent lineages.
@cached_property is a non-data descriptor (it defines __get__ but not
__set__), which matters specifically because of attribute lookup priority: a
non-data descriptor is checked only if the instance's own __dict__ doesn't already
have that name. The first access to report.total runs your function, computes the
result, and then manually writes self.__dict__["total"] = 60 — after that, ordinary
attribute lookup finds total sitting directly in the instance's __dict__ and
never consults the descriptor's __get__ again, which is the entire caching
mechanism (and why the class needs a real, writable instance __dict__ to work at
all).
Finally, class Bad(Base): ... triggers ValidatingMeta.__new__ before Bad even
exists as an object: the class statement itself compiles to a call to the
metaclass — ValidatingMeta("Bad", (Base,), namespace) — and only if that call
returns successfully does the name Bad get bound. Raising TypeError inside
__new__ means the class object is never created at all, not merely rejected after
the fact.
Exercise¶
Define an ABC Shape with abstract methods area() and perimeter(), plus a
concrete method describe() that returns a formatted string using both. Add a
RoundingMixin that overrides describe() to round its output to 2 decimal
places before delegating to the parent's version via super(). Implement
Rectangle and Circle subclasses that combine Shape with
RoundingMixin, and confirm instantiating a Shape directly raises
TypeError.