Atlas

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:

# 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:

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íaLa interfaz debe definirla el consumidor
Quieres fallo temprano en runtime al instanciarInteroperas 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 realTipas 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:

Cuándo cada uno:

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’?”

Q2: “Why ‘favor composition over inheritance’? Give a case where inheritance breaks.”

Q3: “@dataclass vs NamedTuple vs Pydantic vs a plain dict — how do you choose?”

Q4: “ABC vs typing.Protocol — when each?”

Q5: “This dataclass raises or misbehaves — why?” (mutable default + hashability)

Q6: “What are mixins, and what are the risks?”

Q7: “How does SOLID actually show up in day-to-day Python? Which principles pay off most?”

Referencias