OOP Senior en Python: composición, MRO, ABCs, dataclasses
El modelo de objetos de Python resuelve herencia múltiple con C3 linearization (el MRO), expone contratos vía ABCs (nominal/explícito) o Protocol (structural/duck typing tipado), y modela datos con boilerplate mínimo vía dataclasses. Dominar estos tres, y saber cuándo componer en lugar de heredar, es dominar cómo Python compone comportamiento y estado sin caer en jerarquías frágiles. La regla rectora es favor composition over inheritance: heredar acopla a la implementación del padre; componer acopla solo a una interfaz.
Teoría
Herencia: cuándo es una trampa
La herencia crea un acoplamiento fuerte entre clases. Cuando B hereda de A, B recibe todo A —incluyendo decisiones de implementación que quizá no querías— y un cambio en el padre puede romper a todas las hijas silenciosamente. Este es el problema clásico de la fragile base class.
El síntoma más visible es la jerarquía que miente: modelas con “es-un” algo que en realidad no cumple el contrato del padre.
# Herencia profunda -- frágil, y además miente
class Animal:
def move(self) -> str:
return "moving"
class Bird(Animal):
def move(self) -> str:
return "flying"
class Penguin(Bird):
def move(self) -> str:
return "swimming" # Un pájaro que no vuela? La jerarquía miente.
Penguin hereda de Bird, pero rompe la expectativa implícita de que un Bird vuela. Todo código que reciba un Bird y asuma que puede volar se rompe cuando le pasas un Penguin. Esto es una violación del Liskov Substitution Principle: si B es subtipo de A, debería poder usarse B en cualquier lugar donde se use A sin romper nada.
El ejemplo canónico de la violación LSP es Rectangle/Square:
class Rectangle:
def __init__(self, width: float, height: float) -> None:
self.width = width
self.height = height
def set_width(self, w: float) -> None:
self.width = w
def set_height(self, h: float) -> None:
self.height = h
def area(self) -> float:
return self.width * self.height
class Square(Rectangle):
"""Un cuadrado ES un rectángulo... o no?"""
def set_width(self, w: float) -> None:
self.width = w
self.height = w # fuerza que ambos lados sean iguales
def set_height(self, h: float) -> None:
self.width = h
self.height = h
def resize_and_check(rect: Rectangle) -> None:
rect.set_width(5)
rect.set_height(10)
assert rect.area() == 50 # espero 5 * 10 = 50
resize_and_check(Rectangle(2, 3)) # OK: 50
resize_and_check(Square(2, 2)) # FALLA: area es 100 (10 * 10), no 50
Square respeta la sintaxis de Rectangle (tiene los mismos métodos) pero rompe su contrato (ancho y alto independientes). El código que espera un Rectangle no puede confiar en un Square. Matemáticamente un cuadrado es un rectángulo; como tipos mutables, no lo es.
Otra trampa frecuente en Python: heredar de tipos built-in como dict o list. Muchos métodos internos del built-in no pasan por tus overrides, así que tu subclase se comporta de forma inconsistente. Para extender un dict casi siempre es mejor collections.UserDict o composición.
La pregunta senior sobre herencia:
¿Estoy heredando para compartir tipo (correcto) o para compartir código (probablemente mal)? Si es solo para reusar código, composición es mejor.
Composición sobre herencia
En vez de “un Penguin ES un Bird”, piensa “un Penguin TIENE capacidades”. Modela cada capacidad por separado y compón los objetos con las que realmente tienen.
from dataclasses import dataclass
from typing import Protocol
# Capacidades como protocolos (interfaces estructurales)
class CanSwim(Protocol):
def swim(self) -> str: ...
class CanFly(Protocol):
def fly(self) -> str: ...
class CanWalk(Protocol):
def walk(self) -> str: ...
# Implementaciones de capacidades
@dataclass
class SwimmingAbility:
speed: float = 5.0
def swim(self) -> str:
return f"swimming at {self.speed} km/h"
@dataclass
class FlyingAbility:
altitude: float = 100.0
def fly(self) -> str:
return f"flying at {self.altitude}m"
@dataclass
class WalkingAbility:
speed: float = 3.0
def walk(self) -> str:
return f"walking at {self.speed} km/h"
# Componer animales con las capacidades que REALMENTE tienen
@dataclass
class Eagle:
flying: FlyingAbility = FlyingAbility()
walking: WalkingAbility = WalkingAbility(speed=1.0)
@dataclass
class Penguin:
swimming: SwimmingAbility = SwimmingAbility(speed=8.0)
walking: WalkingAbility = WalkingAbility(speed=2.0)
# No tiene flying -- porque no vuela. Simple.
@dataclass
class Duck:
flying: FlyingAbility = FlyingAbility(altitude=50.0)
swimming: SwimmingAbility = SwimmingAbility(speed=3.0)
walking: WalkingAbility = WalkingAbility(speed=2.0)
Ya no hay jerarquía que mentir: un Penguin simplemente no tiene atributo flying. Las capacidades son reutilizables y componibles sin arrastrar una cadena de herencia.
Ejemplo backend real: servicios con capacidades vía inyección
El mismo antipatrón aparece constantemente en backends cuando se usa una BaseService para “compartir funcionalidad”:
# MAL: herencia para compartir funcionalidad
class BaseService:
def log(self, msg): ...
def cache_get(self, key): ...
def cache_set(self, key, val): ...
def send_event(self, event): ...
def validate(self, data): ...
class OrderService(BaseService):
def create_order(self, data):
self.validate(data) # de BaseService
order = self.db.save(data)
self.cache_set(f"order:{order.id}", order) # de BaseService
self.send_event({"type": "order_created"}) # de BaseService
self.log(f"Order {order.id} created") # de BaseService
return order
class UserService(BaseService):
# Hereda validate, cache, events, log... aunque quizá no necesita todo
def create_user(self, data):
self.validate(data)
user = self.db.save(data)
self.send_event({"type": "user_created"})
return user
UserService hereda cache_get/cache_set aunque no cachea nada: depende de métodos que no usa. Esto viola el Interface Segregation Principle. Además, BaseService se vuelve un imán de responsabilidades: cualquier servicio nuevo la infla más, y un cambio en ella arriesga a todos.
La alternativa es composición con inyección de dependencias: cada servicio recibe exactamente las colaboraciones que necesita, tipadas como interfaces (Protocol o ABC).
# MEJOR: composición con inyección de dependencias
class OrderService:
def __init__(
self,
repository: OrderRepository,
cache: CachePort,
events: EventBus,
validator: OrderValidator,
) -> None:
self.repository = repository
self.cache = cache
self.events = events
self.validator = validator
def create_order(self, data: dict) -> Order:
self.validator.validate(data)
order = self.repository.save(data)
self.cache.set(f"order:{order.id}", order)
self.events.emit("order_created", {"order_id": order.id})
return order
class UserService:
def __init__(
self,
repository: UserRepository,
events: EventBus,
validator: UserValidator,
) -> None:
self.repository = repository
self.events = events
self.validator = validator
# No tiene cache -- porque no lo necesita
def create_user(self, data: dict) -> User:
self.validator.validate(data)
user = self.repository.save(data)
self.events.emit("user_created", {"user_id": user.id})
return user
Cada servicio tiene EXACTAMENTE lo que necesita, nada más. Y como las dependencias entran por el constructor tipadas como abstracciones, puedes inyectar dobles de prueba (FakeCache, InMemoryRepository) sin tocar el servicio.
El costo de la composición es la indirección: escribes forwarding explícito (self.cache.set(...) en vez de un self.cache_set(...) heredado). El pago es un seam testeable y tipos blindados contra cambios internos ajenos. En Python el idiom suele ser “has-a + Protocol” antes que una clase base.
Mixins: el punto medio
Un mixin es una clase pequeña que aporta un set de métodos ortogonal y reutilizable (serialización, logging), no pensada para instanciarse sola y típicamente sin estado propio (sin __init__, sin atributos que le pertenezcan). Se combina con una clase “real” para inyectar comportamiento.
class SerializableMixin:
def to_dict(self) -> dict:
return {k: v for k, v in vars(self).items() if not k.startswith("_")}
class LoggableMixin:
def log(self, msg: str) -> None:
print(f"[{type(self).__name__}] {msg}")
class Report(SerializableMixin, LoggableMixin):
def __init__(self, title: str) -> None:
self.title = title
Los mixins son útiles mientras se mantengan stateless y de un solo propósito. En cuanto un mixin necesita su propio __init__ con estado, te estás acercando a herencia múltiple no gestionada, con sus bugs de orden de inicialización; esa es la señal para pasar a composición.
Cuándo SÍ usar herencia
Herencia es correcta cuando hay un is-a genuino y sustituible (respeta LSP), con una base estable que tú controlas:
- Jerarquías genuinas “es-un” que no van a cambiar.
- Frameworks que lo requieren (modelos de ORM, bases declarativas).
- ABCs para definir contratos con posible comportamiento por defecto compartido.
# Herencia correcta: ABC como contrato
from abc import ABC, abstractmethod
class PaymentGateway(ABC):
@abstractmethod
def charge(self, amount: float, currency: str) -> str: ...
@abstractmethod
def refund(self, transaction_id: str) -> bool: ...
class ExternalGateway(PaymentGateway):
def charge(self, amount: float, currency: str) -> str:
return self._client.create_charge(amount=amount, currency=currency).id
def refund(self, transaction_id: str) -> bool:
return self._client.create_refund(charge=transaction_id).ok
MRO y el problema del diamante
Cuando hay herencia múltiple, Python necesita un orden determinista para buscar atributos y métodos. Ese orden es el MRO (Method Resolution Order), calculado por C3 linearization. El punto clave y contraintuitivo: super() no significa “el padre”, significa “la siguiente clase en el MRO”.
class A:
def __init__(self) -> None:
print("A"); super().__init__()
class B(A):
def __init__(self) -> None:
print("B"); super().__init__()
class C(A):
def __init__(self) -> None:
print("C"); super().__init__()
class D(B, C):
def __init__(self) -> None:
print("D"); super().__init__()
D() # imprime: D, B, C, A
print([cls.__name__ for cls in D.__mro__])
# ['D', 'B', 'C', 'A', 'object']
Dentro de B.__init__, super().__init__() no llama a A —llama a C— porque el MRO de D es D → B → C → A → object. super() recorre esa lista linealizada, garantizando que cada clase del diamante se inicializa una sola vez. Si B llamara directamente a A.__init__(self) en vez de super(), C se saltaría o A se ejecutaría dos veces.
Reglas prácticas del MRO:
- El orden de precedencia es de izquierda a derecha en la lista de bases: en
class D(B, C),Bgana sobreC. Por eso los mixins que deben sobrescribir van antes de la base. - Para que la herencia múltiple cooperativa funcione, todas las clases deben llamar
super()y aceptar firmas compatibles (a menudo*args, **kwargs), para que la cadena llegue a cada clase exactamente una vez y termine enobject. super()sin argumentos (Python 3) lee__class__del scope de la función, así que solo funciona dentro del cuerpo de un método de la clase que lo define.- Si C3 no puede producir un orden consistente (jerarquías contradictorias), Python lanza
TypeErroral definir la clase.
La fragilidad de la cooperación cooperativa es en sí misma un argumento fuerte para mantener las jerarquías planas y preferir composición.
ABCs vs typing.Protocol (nominal vs estructural)
Ambos definen “interfaces”, pero fallan de forma distinta y en momentos distintos.
ABC (abc.ABC + @abstractmethod) es nominal: una clase debe heredar explícitamente e implementar todos los métodos abstractos, o no se puede instanciar (TypeError en construcción). El contrato se declara desde el proveedor y se verifica en runtime al instanciar.
from abc import ABC, abstractmethod
class Repository(ABC):
@abstractmethod
def get(self, id: str) -> dict: ...
@abstractmethod
def save(self, entity: dict) -> None: ...
class PostgresRepository(Repository):
def get(self, id: str) -> dict: ...
def save(self, entity: dict) -> None: ...
# Repository() -> TypeError: Can't instantiate abstract class
# Si PostgresRepository olvida save() -> TypeError al instanciar
Protocol (PEP 544) es estructural: cualquier clase con los métodos/atributos que coinciden conforma, sin heredar de nada. La conformidad la verifica el type checker estáticamente; el contrato lo define el consumidor.
from typing import Protocol
class Priced(Protocol):
def price(self) -> int: ...
def checkout(item: Priced) -> int: # acepta CUALQUIER cosa con price() -> int
return item.price()
class Book: # no hereda de Priced
def __init__(self, cents: int) -> None:
self.cents = cents
def price(self) -> int:
return self.cents
checkout(Book(5000)) # OK: duck typing tipado
@runtime_checkable habilita isinstance sobre un Protocol, pero solo chequea nombres de métodos, no firmas ni tipos de retorno; no te apoyes en él para validación real.
Regla de decisión:
| Usa ABC cuando… | Usa Protocol cuando… |
|---|---|
| Controlas la jerarquía | La interfaz debe definirla el consumidor |
| Quieres fallo temprano en runtime al instanciar | Interoperas con clases de terceros que no puedes modificar |
Necesitas isinstance semántico o register() | Quieres desacople total, sin obligar a heredar |
| Hay comportamiento por defecto compartido real | Tipas duck typing en fronteras (ports) |
Regla de bolsillo: define interfaces como Protocol en las fronteras para desacoplar; usa ABC cuando una base compartida y controlada con comportamiento por defecto aporta valor.
Dataclasses vs NamedTuple vs Pydantic vs dict
Para modelar datos, sube de nivel solo cuando necesitas la garantía del siguiente escalón:
dict (flexible, sin garantías) → NamedTuple (inmutable, ligero, semántica de tupla) → @dataclass (mutable o frozen, métodos, __post_init__, slots) → Pydantic (validación/coerción en runtime, a costo de dependencia y CPU).
La distinción crítica: las anotaciones de tipo en una dataclass son hints, nunca se validan en runtime. Pydantic sí valida y coerciona. Confundirlos causa bugs de “por qué mi API aceptó un string como int”.
from dataclasses import dataclass, field
# Value object inmutable: frozen => hashable, slots => sin __dict__
@dataclass(frozen=True, slots=True)
class Money:
amount: int # centavos, evita floats
currency: str = "COP"
# Agregado mutable con default seguro y campo derivado
@dataclass(slots=True)
class Cart:
# default_factory: NUNCA `items: list = []` (default mutable compartido)
items: list[Money] = field(default_factory=list)
_total: int = field(default=0, init=False, repr=False)
def __post_init__(self) -> None:
# se corre tras el __init__ generado; ideal para invariantes/derivados
self._total = sum(m.amount for m in self.items)
assert Money(100) == Money(100) # __eq__ generado por valor
assert hash(Money(100)) # hashable porque es frozen
Detalles que un senior conoce:
- Default mutable:
items: list = []compartiría la MISMA lista entre todas las instancias. Las dataclasses lanzanValueErrorpara defaults mutables conocidos, protegiéndote; usafield(default_factory=list). - Matriz de hashing:
eq=True, frozen=False(default) →__hash__ = None, unhashable (correcto: mutable + igualdad por valor rompería los invariantes de un dict).eq=True, frozen=True→__hash__generado (value object seguro).eq=False→ heredaobject.__hash__(identidad). slots=Trueelimina el__dict__por instancia: menos memoria y acceso más rápido, útil cuando el conteo de instancias importa. Costo: no puedes agregar atributos no declarados.__post_init__para invariantes cross-field y campos derivados;field(init=False, ...)para atributos computados que no quieres en el__init__generado.
Cuándo cada uno:
dict: datos ad-hoc, sin schema. Rápido de escribir, cero garantías.NamedTuple: cuando necesitas semántica de tupla (indexable/iterable) o devolver varios valores nombrados; inmutable y ligero.@dataclass: datos internos y confiables con comportamiento.frozen=Truepara value objects;slots=Truecuando la memoria importa.- Pydantic: en la frontera de entrada (body HTTP, config, JSON externo), donde la validación/coerción en runtime justifica su costo. Una dataclass en esa frontera es un hueco de correctitud/seguridad: guardará feliz
{"age": "not-a-number"}.
SOLID aplicado en Python
SOLID no es una checklist que aplicas siempre; son heurísticas para decidir estructura. La pregunta no es “¿cumplo SRP?” sino “¿esta separación me da beneficio real o solo agrega complejidad?”.
S — Single Responsibility Principle. Una clase tiene un solo motivo para cambiar; es decir, un solo actor/equipo debería pedir cambios en ella. Un OrderService que valida, persiste, envía email y genera factura tiene cuatro motivos de cambio mezclados.
# Cada clase cambia por una sola razón
class OrderValidator: # cambia si cambian las reglas de negocio
def validate(self, data: dict) -> None: ...
class OrderRepository: # cambia si cambia la DB o el schema
def save(self, data: dict) -> Order: ...
class OrderNotifier: # cambia si cambian los templates
def send_confirmation(self, order: Order) -> None: ...
class OrderService: # orquesta; cambia solo si cambia el flujo
def __init__(self, validator, repository, notifier) -> None:
self.validator, self.repository, self.notifier = validator, repository, notifier
def create_order(self, data: dict) -> Order:
self.validator.validate(data)
order = self.repository.save(data)
self.notifier.send_confirmation(order)
return order
Cuidado con el over-engineering: si algo se entiende en 3 líneas y lo mantiene una sola persona, separarlo en UserFetcher + EmailBuilder + EmailSender es agregar abstracciones sin motivo real de cambio independiente.
O — Open/Closed Principle. Abierto para extensión, cerrado para modificación: agrega comportamiento sin editar código existente y ya probado. El antipatrón es el if/elif que crece:
# Malo: cada tipo nuevo = editar (y arriesgar) esta clase
class DiscountCalculator:
def calculate(self, order: Order) -> float:
if order.customer_type == "premium":
return order.total * 0.20
elif order.customer_type == "regular":
return order.total * 0.10
return 0.0
# Bien: cada variante nueva = clase nueva (Strategy)
from abc import ABC, abstractmethod
class DiscountStrategy(ABC):
@abstractmethod
def calculate(self, order: Order) -> float: ...
class PremiumDiscount(DiscountStrategy):
def calculate(self, order: Order) -> float:
return order.total * 0.20
class DiscountCalculator:
def __init__(self, strategy: DiscountStrategy) -> None:
self.strategy = strategy
def calculate(self, order: Order) -> float:
return self.strategy.calculate(order)
No lo apliques “por si acaso”: vale la pena con 3+ variantes o crecimiento conocido. Con 2 variantes estables, un if/else es más honesto.
L — Liskov Substitution Principle. Los subtipos deben respetar el contrato del padre: no lanzar donde el padre retorna, no exigir precondiciones más estrictas. Además del Rectangle/Square de arriba, aparece en backend cuando una implementación rompe el contrato de la interfaz:
class Cache(ABC):
@abstractmethod
def get(self, key: str) -> str | None:
"""Retorna el valor o None si no existe."""
class BrokenCache(Cache):
def get(self, key: str) -> str | None:
result = self._store.get(key)
if result is None:
raise KeyError(key) # VIOLA LSP: el contrato dice None, no exception
return result
I — Interface Segregation Principle. Ningún cliente debería depender de métodos que no usa. Divide interfaces gordas en interfaces pequeñas y enfocadas:
# Malo: interfaz gorda; un servicio de solo-lectura depende de save/delete/bulk_import
# Bien: interfaces segregadas, se combinan según necesidad
class Readable(ABC):
@abstractmethod
def find_by_id(self, id: str) -> dict: ...
class Writable(ABC):
@abstractmethod
def save(self, entity: dict) -> None: ...
class OrderRepository(Readable, Writable):
...
class ReportService:
def __init__(self, repo: Readable) -> None: # solo depende de lectura
self.repo = repo
D — Dependency Inversion Principle. Los módulos de alto nivel no dependen de los de bajo nivel; ambos dependen de abstracciones. Es lo que hace práctico el testing y el swap de proveedores:
class DatabasePort(ABC):
@abstractmethod
def insert(self, table: str, data: dict) -> dict: ...
class EmailPort(ABC):
@abstractmethod
def send(self, to: str, body: str) -> None: ...
class OrderService:
def __init__(self, db: DatabasePort, email: EmailPort) -> None:
self.db = db
self.email = email # depende de abstracciones, no de concretos
def create_order(self, data: dict) -> dict:
order = self.db.insert("orders", data)
self.email.send(order["customer_email"], "Order confirmed")
return order
# prod: OrderService(PostgresAdapter(), SmtpAdapter())
# tests: OrderService(FakeDatabase(), FakeEmail())
# migrar de proveedor NO toca OrderService
En frameworks web modernos, la inyección se resuelve declarativamente en el borde del endpoint, resolviendo las dependencias por request y permitiendo overridearlas en tests. En la práctica, D y O son los que más pagan en sistemas grandes: son los que hacen práctico el unit testing.
Ejercicios
Ejercicio 1 — Refactor de herencia profunda a composición
Tienes una NotificationSender que hereda de una BaseService gorda para reusar logging, caché y métricas, pero solo usa el logging. Refactoriza a composición inyectando únicamente lo que necesita, y explica qué principio SOLID arreglaste.
Solución
from typing import Protocol
class Logger(Protocol):
def info(self, msg: str) -> None: ...
class EmailClient(Protocol):
def send(self, to: str, body: str) -> None: ...
class NotificationSender:
def __init__(self, logger: Logger, email: EmailClient) -> None:
self._logger = logger
self._email = email
def notify(self, to: str, message: str) -> None:
self._email.send(to, message)
self._logger.info(f"notification sent to {to}")
Arregla ISP (ya no depende de cache_* ni metrics_* que no usa) y DIP (depende de Protocols, no de una clase concreta). Bonus: ahora es testeable inyectando un FakeLogger y un FakeEmailClient, sin la BaseService.
Ejercicio 2 — Diseñar con composición en vez de jerarquía
Modela un MediaFile que puede ser reproducible, descargable y/o transcodificable, en cualquier combinación. Evita una explosión de subclases (DownloadablePlayableFile, etc.).
Solución
from dataclasses import dataclass, field
from typing import Protocol
class Playable(Protocol):
def play(self) -> str: ...
class Downloadable(Protocol):
def download(self) -> str: ...
@dataclass
class StreamCapability:
bitrate: int = 128
def play(self) -> str:
return f"streaming at {self.bitrate}kbps"
@dataclass
class DownloadCapability:
url: str = ""
def download(self) -> str:
return f"downloading from {self.url}"
@dataclass
class MediaFile:
name: str
capabilities: dict[str, object] = field(default_factory=dict)
def add(self, key: str, cap: object) -> "MediaFile":
self.capabilities[key] = cap
return self
song = (
MediaFile("track.mp3")
.add("stream", StreamCapability(bitrate=320))
.add("download", DownloadCapability(url="https://cdn/track.mp3"))
)
Cada capacidad es independiente y se compone a la carta. No hay jerarquía de tipos que combinar; agregar una capacidad nueva (p.ej. transcodificar) no toca las clases existentes (OCP).
Ejercicio 3 — Detectar y arreglar una violación de LSP
Esta jerarquía compila y “funciona” hasta que alguien la usa polimórficamente. Encuentra la violación y arréglala sin herencia.
class Bird:
def fly(self) -> str:
return "flying"
class Ostrich(Bird):
def fly(self) -> str:
raise NotImplementedError("ostriches can't fly")
def make_it_fly(bird: Bird) -> str:
return bird.fly() # explota con Ostrich
Solución
from typing import Protocol
class Flyer(Protocol):
def fly(self) -> str: ...
class Sparrow:
def fly(self) -> str:
return "flying"
class Ostrich:
def run(self) -> str: # simplemente no es un Flyer
return "running"
def make_it_fly(flyer: Flyer) -> str:
return flyer.fly()
make_it_fly(Sparrow()) # OK
# make_it_fly(Ostrich()) -> el type checker lo rechaza ANTES de runtime
La violación era que Ostrich prometía el contrato de Bird.fly() y lo rompía lanzando. Con un Protocol estructural, Ostrich simplemente no conforma a Flyer, y el error se detecta en type-check en vez de estallar en producción. Regla: no fuerces a un tipo a implementar una capacidad que no tiene.
Ejercicio 4 — Detectar la violación de SRP y separar
class UserService:
def register(self, data: dict) -> dict:
if "@" not in data["email"]:
raise ValueError("bad email")
user = self._db.insert("users", data)
token = self._make_jwt(user)
self._smtp.send(user["email"], f"Welcome! token={token}")
return {"user": user, "token": token}
def _make_jwt(self, user: dict) -> str: ...
¿Cuántos motivos de cambio tiene? Sepáralos.
Solución
class UserValidator: # cambia si cambian reglas de validación
def validate(self, data: dict) -> None:
if "@" not in data["email"]:
raise ValueError("bad email")
class UserRepository: # cambia si cambia la DB
def save(self, data: dict) -> dict: ...
class TokenService: # cambia si cambia el esquema de auth
def issue(self, user: dict) -> str: ...
class WelcomeNotifier: # cambia si cambia el proveedor de email
def send(self, user: dict, token: str) -> None: ...
class UserService: # orquesta; cambia si cambia el flujo
def __init__(self, validator, repo, tokens, notifier) -> None:
self._validator, self._repo = validator, repo
self._tokens, self._notifier = tokens, notifier
def register(self, data: dict) -> dict:
self._validator.validate(data)
user = self._repo.save(data)
token = self._tokens.issue(user)
self._notifier.send(user, token)
return {"user": user, "token": token}
Había cuatro motivos de cambio en una clase (validación, persistencia, auth, notificación). Ahora cambiar de proveedor de email no arriesga una regresión en la lógica de tokens: viven en clases separadas con suites de test separadas.
Ejercicio 5 — Elegir el contenedor de datos correcto
Para cada caso elige entre dict, NamedTuple, @dataclass (indica flags) o Pydantic, y justifica: (a) el body JSON de una request pública; (b) un punto (x, y) inmutable usado como clave de dict; (c) un agregado interno de dominio con métodos e invariantes; (d) el retorno de una función que devuelve dos valores nombrados.
Solución
# (a) Body de request pública -> Pydantic: valida y coerciona en la frontera
from pydantic import BaseModel
class CreateOrderBody(BaseModel):
customer_id: str
total: int
# (b) Punto inmutable y hashable -> dataclass frozen+slots (o NamedTuple)
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class Point:
x: int
y: int
# cache: dict[Point, float] = {} # Point es hashable
# (c) Agregado de dominio con comportamiento -> dataclass mutable
@dataclass
class Order:
items: list[int]
def total(self) -> int:
return sum(self.items)
# (d) Retorno de dos valores nombrados -> NamedTuple
from typing import NamedTuple
class MinMax(NamedTuple):
minimum: int
maximum: int
Clave: (a) usa Pydantic porque es entrada no confiable y necesitas validación en runtime; una dataclass ahí aceptaría {"total": "abc"}. (b) necesita ser hashable → frozen=True. (c) tiene comportamiento y estado mutable interno confiable → dataclass. (d) semántica de tupla ligera → NamedTuple.
Preguntas tipo entrevista (EN)
Q1: “What does super() actually do? Is it ‘call the parent class’?”
- ❌ Wrong / trap: “It calls the parent/base class.” Why it fails: with multiple inheritance this is provably wrong. In the diamond above,
B’ssuper().__init__()invokesC, notB’s baseA. Treatingsuper()as “the parent” leads to double-initialization or skipped classes. - ✅ Correct:
super()returns a proxy that dispatches to the next class in the MRO oftype(self), relative to the current class. The MRO is computed by C3 linearization and available asCls.__mro__. - ⭐ Optimal (senior): For cooperative multiple inheritance to work, every participant must call
super()and accept compatible signatures (often*args, **kwargs), so the chain reaches every class exactly once and terminates atobject. If any class breaks the chain or has an incompatible__init__signature, cooperation fails — which is a strong argument to keep hierarchies shallow and prefer composition.super()with no args (Python 3) reads__class__from the enclosing scope, so it only works inside a method body of the defining class.
Q2: “Why ‘favor composition over inheritance’? Give a case where inheritance breaks.”
- ❌ Wrong / trap: “Inheritance is bad / composition is always faster.” Why it fails: it’s not about performance and inheritance isn’t banned; it’s about coupling and substitutability. A vague answer signals cargo-culting.
- ✅ Correct: Inheritance couples you to the parent’s implementation, and subclasses must honor LSP. Classic break:
class Square(Rectangle)— ifRectangleexposes independentset_width/set_height,Squarecannot honor both without violating the base’s contract, so it’s not substitutable. Another: subclassingdict/listand finding built-in methods bypass your overrides. - ⭐ Optimal (senior): Reach for inheritance only on a genuine, substitutable is-a with a stable base you control; otherwise compose — hold a collaborator and delegate through a narrow interface (ideally a
Protocol), so a change in the collaborator’s internals can’t silently break your type. The cost is explicit forwarding/boilerplate; the payoff is a testable seam (you can inject a fake) and no fragile-base-class problem. In Python the idiom is often “has-a +Protocol” rather than a base class.
Q3: “@dataclass vs NamedTuple vs Pydantic vs a plain dict — how do you choose?”
- ❌ Wrong / trap: “Dataclass always; Pydantic is just a dataclass with types.” Why it fails: ignores that Pydantic validates and coerces at runtime while dataclasses do not — type annotations on a dataclass are hints, never enforced. Conflating them causes “why did my API accept a string as an int” bugs.
- ✅ Correct:
dict= ad-hoc, no schema/guarantees.NamedTuple= immutable, iterable/indexable, lightweight, backwards-compatible with tuple-consuming code.@dataclass= mutable orfrozen, supports methods,__post_init__,slots, defaults. Pydantic = runtime validation/coercion/serialization at I/O boundaries. - ⭐ Optimal (senior): Pick by the guarantee you need. Internal, trusted data with behavior →
@dataclass(usefrozen=Truefor value objects,slots=Truewhen instance count/memory matters). Need tuple semantics or a return of a few named values →NamedTuple. Untrusted input (HTTP body, config, external JSON) → Pydantic, because that’s the boundary where runtime validation earns its dependency cost. A dataclass at that boundary is a security/correctness gap: it will happily store{"age": "not-a-number"}.
Q4: “ABC vs typing.Protocol — when each?”
- ❌ Wrong / trap: “They’re interchangeable; both define interfaces.” Why it fails: one is nominal (requires explicit subclassing, enforced at instantiation) and the other is structural (checked by the type checker, no inheritance). They fail differently and at different times.
- ✅ Correct: ABC (
abc.ABC+@abstractmethod) is nominal: a class must explicitly inherit and implement all abstract methods or it can’t be instantiated (TypeErrorat construction).Protocol(PEP 544) is structural: any class with matching methods/attributes conforms, no inheritance needed; conformance is verified statically by the type checker. - ⭐ Optimal (senior): Use ABC when you own the hierarchy, want early runtime failure and discoverability, or need
isinstancesemantics (including virtual subclasses viaregister()). UseProtocolto type duck typing — especially when the interface should be defined by the consumer or you must accept third-party classes you can’t modify.@runtime_checkableenablesisinstanceon a Protocol but only checks method names, not signatures or return types, so don’t lean on it for real validation. Rule of thumb: define interfaces asProtocolat boundaries for decoupling; use ABC when a shared, controlled base with real default behavior adds value.
Q5: “This dataclass raises or misbehaves — why?” (mutable default + hashability)
- ❌ Wrong / trap: Writing
items: list = []as a field default and expecting a fresh list per instance; or expecting a non-frozen dataclass to be usable as a dict key. Why it fails: a bare mutable default is shared across all instances (Python evaluates it once) — dataclasses actually raiseValueErrorfor known-mutable defaults to protect you. And a default@dataclass(eq=True, frozen=False) sets__hash__ = None, so it’s unhashable. - ✅ Correct: Use
field(default_factory=list)for per-instance mutable defaults. For hashability, make it@dataclass(frozen=True)so Python generates a real__hash__(frozen + eq=True). - ⭐ Optimal (senior): Know the hashing matrix:
eq=True, frozen=False(default) →__hash__ = None(unhashable, correct — mutable + value-eq would break dict invariants);eq=True, frozen=True→ generated__hash__(safe value object);eq=False→ inheritsobject.__hash__(identity). Preferfrozen=True, slots=Truefor value objects used as keys/set members: immutability makes hashing sound andslotscuts memory. Use__post_init__for cross-field invariants/derived fields, andfield(init=False, ...)for computed attributes you don’t want in the generated__init__.
Q6: “What are mixins, and what are the risks?”
- ❌ Wrong / trap: “A mixin is just a base class with helpers, stack as many as you like.” Why it fails: piling mixins with their own
__init__/state creates MRO ordering bugs and initialization order surprises; the more state they carry, the closer you are to unmanaged multiple inheritance. - ✅ Correct: A mixin is a small class providing a focused, orthogonal set of methods, not meant to be instantiated alone, typically stateless (no
__init__, no instance attributes it owns). It’s combined with a “real” base to inject behavior (e.g. serialization, logging). - ⭐ Optimal (senior): Keep mixins stateless and single-purpose; if a mixin needs
__init__, make it cooperative (super().__init__(*args, **kwargs)) and document its required MRO position. Watch the ordering rule: inclass C(MixinA, Base), listed left-to-right = higher precedence in the MRO, so mixins go before the base to override it. When a mixin starts holding real state or you need to swap it at runtime, that’s the signal to switch from a mixin to composition/dependency injection.
Q7: “How does SOLID actually show up in day-to-day Python? Which principles pay off most?”
- ❌ Wrong / trap: “They’re five OOP rules that make code cleaner.” Why it fails: it’s a definition dump with no judgment; it doesn’t show you know when not to apply them.
- ✅ Correct: Single Responsibility: one reason to change per class, so a schema change doesn’t force edits to business logic. Open/Closed: extend by adding code (a new Strategy subclass), not editing tested code. Liskov: a subtype must be substitutable without breaking callers. Interface Segregation: clients shouldn’t depend on methods they don’t use — prefer small
Protocols. Dependency Inversion: depend on abstractions so you can swap a Postgres repo for an in-memory one in tests. - ⭐ Optimal (senior): In practice D and O pay off most in large systems — they’re what make unit testing practical, because you inject fakes at seams instead of standing up real infrastructure. But SOLID is a set of heuristics, not a checklist: over-applying SRP produces a fog of one-method classes, and OCP with a Strategy for two stable variants is just indirection. The senior move is asking “how many actors change this, and how many variants really exist?” before splitting — an honest
if/elsebeats a premature abstraction.
Referencias
- Python
super()/ built-in functions: https://docs.python.org/3/library/functions.html#super - The Python 2.3 Method Resolution Order (C3): https://docs.python.org/3/howto/mro.html
abc— Abstract Base Classes: https://docs.python.org/3/library/abc.html- PEP 544 — Protocols: Structural subtyping (static duck typing): https://peps.python.org/pep-0544/
dataclasses: https://docs.python.org/3/library/dataclasses.htmltyping—Protocol,runtime_checkable,NamedTuple: https://docs.python.org/3/library/typing.html- Raymond Hettinger, “Super considered super!” (PyCon): https://www.youtube.com/watch?v=EiOglTERPEo