Módulo 3: Patrones para el comportamiento que varía

6. En un lenguaje con funciones de primera clase, ¿hace falta la clase?

Descripción

Al terminar esta lección vas a poder hacer algo que separa a quien aplica patrones de quien decide sobre ellos: implementar el mismo patrón en su forma más ligera que el problema admita. En Python, una Strategy suele ser una función pasada como argumento, no una interfaz con tres implementaciones y un registro. Y saber cuándo esa forma ligera alcanza —y cuándo no— te ahorra la mitad de los archivos que este módulo te habría hecho escribir.

Vas a salir con tres cosas. Primero, la comprensión de por qué el catálogo de patrones está lleno de clases: fue escrito para lenguajes donde una función no se podía guardar en una variable ni pasar como argumento, y buena parte de la ceremonia que ves en los diagramas es una solución alterna a una carencia del lenguaje. Segundo, la reescritura del código que ya hiciste —las reglas de precio de la lección 3— en su versión de funciones, con la comparación línea a línea. Y tercero, la lista concreta de cuándo la clase se gana su lugar: estado propio entre llamadas, varias operaciones relacionadas, configuración que hay que validar, y necesidad de identidad. Cuatro razones. Si no tienes ninguna, la clase es ceremonia.

Esto importa por una razón práctica y otra de fondo. La práctica: el código con clases innecesarias es más largo, tiene más archivos y esconde una función de tres líneas detrás de una jerarquía. La de fondo: el patrón es la idea, no el mecanismo. Quien confunde las dos cosas cree que no hay Strategy si no hay una interfaz, y entonces no reconoce la Strategy que tiene delante escrita con un diccionario de funciones —ni sabe elegir la forma adecuada—.

Conexión con el módulo: esta lección es a los patrones de este módulo lo que el módulo 2 es a la guía entera: el contrapeso. Las lecciones 2 y 3 te dieron Strategy con clases, la 4 Template Method con herencia, la 5 State con un objeto por estado. Aquí volvemos sobre las tres y preguntamos qué sobraba. No para deshacer el trabajo: para que sepas elegir el peso. La lección 7 sigue por el mismo camino desde otro ángulo —muchos condicionales no necesitan ningún patrón— y el proyecto de la lección 8 va a evaluar precisamente esa capacidad de elegir la forma mínima que resuelve el problema.

Contratar a alguien o pedir un favor

Necesitas que alguien recoja un paquete en la esquina. Tienes dos formas de resolverlo.

Puedes pedirle el favor a quien pase: le explicas qué hacer, lo hace, se va. No hace falta contrato, ni escritorio, ni presentarlo al equipo. Termina la tarea y la relación se acaba ahí. Es rapidísimo de organizar y funciona perfecto si la tarea es una sola cosa que se explica en una frase.

O puedes contratar a alguien: le das un escritorio, un puesto, un nombre en el organigrama. Ahora esa persona recuerda cosas entre encargos —sabe cuántos paquetes lleva esta semana, tiene la llave del almacén, conoce a los proveedores—, puede hacer varias tareas relacionadas —recoger, registrar, avisar— y tiene una identidad: puedes referirte a ella, ponerla en una lista, asignarle responsabilidades.

Contratar cuesta más. Vale la pena cuando hace falta memoria, cuando hay varias tareas que van juntas, o cuando necesitas poder nombrar a esa persona. Si lo único que necesitas es que alguien cruce la calle una vez, contratar es absurdo.

Las funciones son el favor. Las clases son la contratación. Y la pregunta correcta no es cuál es más elegante: es si tu tarea necesita memoria, varias operaciones o identidad. Si no necesita ninguna de las tres, pediste un favor y montaste una oficina.

Con esa imagen puesta, vale la pena contar de dónde viene la ceremonia que ves en los libros de patrones. El catálogo original se escribió a principios de los noventa, pensando sobre todo en C++ y Smalltalk, y se popularizó con Java. En esos lenguajes —al menos en aquel momento— no podías guardar una función en una variable ni pasarla como argumento. Si querías entregarle a alguien "una forma de calcular algo", tu única opción era envolver esa forma en un objeto: una clase, con un método, cuyo único propósito era ser el envoltorio transportable de una función.

Peter Norvig lo señaló en 1996 en una charla que sigue siendo la referencia: en un lenguaje dinámico, 16 de los 23 patrones del catálogo tienen una implementación cualitativamente más simple, o directamente desaparecen porque el lenguaje ya los trae resueltos. No es que los patrones estuvieran mal: es que parte de lo que resolvían era una carencia del lenguaje, no del problema.

Python tiene funciones de primera clase desde siempre. Puedes guardarlas en variables, meterlas en listas y diccionarios, pasarlas como argumento y devolverlas desde otra función. Todo el andamio de "una clase con un solo método para poder transportar un comportamiento" es, en Python, opcional.

Ejemplo trabajado: las reglas de precio, otra vez, pero con funciones

Volvamos al código de la lección 3. Así quedó la Strategy con clases —te dejo dos de las cuatro reglas y el registro, para que la comparación sea justa—:

# Archivo: pricing/rules.py  — versión con clases (lección 3)
from typing import Protocol
from pricing.context import PricingContext


class PricingRule(Protocol):
    def price_for(self, context: PricingContext) -> float: ...


class GeneralPricing:
    def price_for(self, context: PricingContext) -> float:
        price = context.base_price
        if context.quantity >= 10:
            price = price * 0.95
        return _with_service_fee(price)


class VipPricing:
    def price_for(self, context: PricingContext) -> float:
        price = context.base_price * 1.35 + 150.0
        if context.is_member:
            price = price * 0.90
        return _with_service_fee(price)
# Archivo: pricing/calculator.py
RULES = {
    "general": GeneralPricing(),
    "vip": VipPricing(),
    "early_bird": EarlyBirdPricing(),
    "courtesy": CourtesyPricing(),
}

Ahora la misma cosa, con funciones:

# Archivo: pricing/rules.py  — versión con funciones
from typing import Callable
from pricing.context import PricingContext

# El contrato: una regla es cualquier cosa que reciba un contexto y
# devuelva un precio. Un alias de tipo, no una interfaz.
PricingRule = Callable[[PricingContext], float]


def general_pricing(context: PricingContext) -> float:
    price = context.base_price
    # Compra grupal: a partir de 10 boletos, 5% de descuento.
    if context.quantity >= 10:
        price = price * 0.95
    return _with_service_fee(price)


def vip_pricing(context: PricingContext) -> float:
    # 35% sobre el base, más el cargo fijo del lounge.
    price = context.base_price * 1.35 + 150.0
    if context.is_member:
        price = price * 0.90
    return _with_service_fee(price)
# Archivo: pricing/calculator.py
RULES: dict[str, PricingRule] = {
    "general": general_pricing,
    "vip": vip_pricing,
    "early_bird": early_bird_pricing,
    "courtesy": courtesy_pricing,
}


def calculate_line_price(ticket, order, customer, now) -> float:
    rule = RULES.get(ticket.kind)
    if rule is None:
        raise ValueError(f"Tipo de boleto desconocido: {ticket.kind}")
    return rule(build_context(ticket, order, customer, now))

Qué esperar. Se usa exactamente igual:

print(calculate_line_price(ticket, order, customer, now))
# 540.0        ← el mismo número que en la lección 3

# Y las pruebas por regla se acortan:
def test_general_applies_group_discount_from_ten():
    assert general_pricing(ctx(quantity=9)) == 540.00
    assert general_pricing(ctx(quantity=10)) == 513.00

Compara esa prueba con la de la lección 3: GeneralPricing().price_for(ctx(...)) contra general_pricing(ctx(...)). Desapareció el paréntesis vacío de construir un objeto que no guarda nada, y desapareció el nombre del método, que además era redundante —GeneralPricing.price_for dice "precio" dos veces—.

Hagamos la cuenta con honestidad, porque es lo que decide:

Con clasesCon funciones
Líneas por regla6 (incluye class, def, self)4
El contratoUn Protocol con un métodoUn alias Callable[...] de una línea
Construir para usarGeneralPricing()Nada: la función ya está
Llamarrule.price_for(ctx)rule(ctx)
Escribir una pruebaInstanciar + llamar el métodoLlamar
Total del módulo~45 líneas~30 líneas

Un tercio menos de código para exactamente el mismo comportamiento. Y —esto importa más que las líneas— exactamente la misma estructura conceptual: hay un contrato, hay cuatro implementaciones intercambiables, y hay un punto de elección. Sigue siendo una Strategy. Lo que cambió es el peso del mecanismo, no la idea.

Dice el módulo 1 que un patrón es una solución con nombre a un problema recurrente. Lo que acabas de comprobar es el corolario: el nombre describe la solución, no su implementación. Si en una revisión de código alguien dice "esto no es una Strategy porque no hay interfaz", está confundiendo el patrón con su forma en Java.

Y ahora lo que se perdió, porque también hay algo.

En la versión con clases, cada regla tiene un nombre de tipo con el que puedes referirte a ella: isinstance(rule, CourtesyPricing) funciona, aparece en el autocompletado como una entidad, y un error te dice VipPricing.price_for en la traza. Con funciones tienes el nombre de la función, que es casi lo mismo pero se pierde en cuanto envuelves algo en una lambda o en un partial.

En la versión con clases hay un lugar natural para metadatos: CourtesyPricing.requires_approval = True, VipPricing.display_name = "VIP". Con funciones se puede —una función es un objeto y admite atributos— pero se ve raro y nadie lo espera.

Y el Protocol con un método nombrado documenta mejor que un Callable[[PricingContext], float], sobre todo cuando el tipo de retorno es ambiguo. Callable[[Order], bool] no te dice si devuelve "es válida" o "hay que rechazarla"; un método llamado is_valid sí.

Ninguna de esas pérdidas es grave aquí. Pero fíjate en la forma de las tres: todas tienen que ver con identidad. Es la primera de las cuatro razones por las que una clase se gana su lugar, y ahora vamos a verlas juntas.

Cuándo la clase se gana su lugar

Cuatro razones. Si tienes al menos una, la clase está justificada. Si no tienes ninguna, es ceremonia.

Razón 1: la implementación guarda estado entre llamadas. Esta es la más clara y la menos discutible. Si el objeto necesita recordar algo de una llamada a la siguiente —un contador, una caché, una conexión abierta, un archivo a medio escribir—, necesita un lugar donde vivir. Eso es un objeto.

class RateLimitedNotifier:
    """Manda avisos, pero nunca más de N por hora al mismo cliente.

    Necesita RECORDAR a quién le mandó y cuándo. Eso no cabe en una
    función suelta sin ensuciar algo global.
    """
    def __init__(self, channel, max_per_hour=3):
        self._channel = channel
        self._max = max_per_hour
        self._sent = defaultdict(list)     # ← el estado, la razón de la clase

    def send(self, customer, message):
        recent = [t for t in self._sent[customer.id] if t > now() - HOUR]
        if len(recent) >= self._max:
            return False
        self._channel.send(customer, message)
        self._sent[customer.id] = recent + [now()]
        return True

Recuerda CsvExporter de la lección 4: guardaba self._file entre _open, _write_row y _close. Ahí la clase estaba justificada por esta razón exacta, aunque no lo dijimos con estas palabras.

Y el contraejemplo, para que la regla no se te vuelva un reflejo: GeneralPricing no guardaba nada. Por eso en la lección 3 pudimos instanciarla una sola vez al importar el módulo y reusarla siempre. Cuando una clase se puede tratar como un singleton sin consecuencias, casi siempre era una función.

Razón 2: el contrato tiene más de una operación relacionada. Una función es un verbo. Si tu abstracción necesita varios verbos que van juntos y comparten contexto, es un sustantivo, y los sustantivos son clases.

Recuerda NotificationChannel del ejercicio 3 de la lección 2:

class NotificationChannel(Protocol):
    def is_available_for(self, customer) -> bool: ...
    def send(self, customer, message: str) -> bool: ...

Dos operaciones que tienen que venir juntas: no tiene sentido tener el is_available_for del SMS y el send del correo. Podrías pasarlas como dos funciones sueltas, pero entonces quien las use tiene que acordarse de que van en pareja, y nada se lo garantiza. La clase hace explícito que son una unidad.

La regla mecánica: una operación → función. Dos o más que comparten contexto → clase. Y el matiz que hay que decir en voz alta: si tu clase tiene tres métodos que no comparten nada —ni datos, ni contexto, ni sentido— no es una unidad, es una carpeta escrita con class. Eso es un módulo, no un objeto.

Razón 3: hay configuración que conviene validar al construir. Cuando una implementación necesita parámetros —una tasa, un umbral, una llave de API—, el constructor es un buen lugar para recibirlos y verificarlos una vez, en vez de en cada llamada.

class TieredCommission:
    def __init__(self, tiers: list[tuple[float, float]]):
        # Se valida AQUÍ, al construir, y no en cada cálculo:
        # si los tramos están desordenados, el error sale al arrancar
        # la aplicación y no a mitad de un cobro.
        if not tiers or tiers != sorted(tiers):
            raise ValueError("Los tramos deben venir ordenados y no vacíos")
        self._tiers = tiers

    def __call__(self, amount: float) -> float:
        ...

Ese "el error sale al arrancar y no a mitad de un cobro" es la ganancia real, y tiene nombre: fallar temprano. Ahora bien, sé honesto contigo mismo al usar esta razón, porque en Python hay una alternativa igual de válida que no necesita clase:

def make_tiered_commission(tiers):
    """Devuelve una función de comisión configurada con estos tramos."""
    if not tiers or tiers != sorted(tiers):
        raise ValueError("Los tramos deben venir ordenados y no vacíos")

    def commission(amount: float) -> float:
        # `tiers` sigue vivo aquí dentro: es una clausura.
        ...
    return commission

Eso es una clausura —una función que se lleva consigo los datos del entorno donde nació— y hace lo mismo que la clase, con validación al construir incluida. Si la configuración es poca, la clausura gana. Si son ocho parámetros con nombres, la clase se lee mejor.

Razón 4: necesitas identidad —nombre, tipo, metadatos, registro—. Cuando el sistema tiene que hablar sobre las implementaciones y no solo usarlas. Un panel de administración que lista las reglas de precio activas. Un archivo de configuración que las nombra. Un registro que las descubre. Una traza de error que tiene que decir cuál falló.

@dataclass(frozen=True)
class PricingRuleInfo:
    key: str                 # "early_bird"
    display_name: str        # "Preventa"
    requires_approval: bool
    calculate: Callable[[PricingContext], float]

Fíjate en esta forma híbrida, porque es la que más veces vas a querer en la práctica: los metadatos en un objeto de datos, el comportamiento en una función. Tienes identidad sin heredar nada y sin escribir una clase por implementación. Es la solución que combina lo mejor de las dos.

Y las razones que NO cuentan, porque son las que más se usan:

"Es más orientado a objetos." Python es multiparadigma por diseño y su biblioteca estándar está llena de funciones sueltas. Esto no es un argumento, es una preferencia estética disfrazada.

"Así se puede extender después." Es la abstracción prematura del módulo 2, con otro nombre. Y es falso además: convertir una función en clase el día que haga falta es un refactor de diez minutos, porque quien la llama la invoca igual (rule(ctx) funciona con una función y con un objeto que tenga __call__).

"El libro lo hace con clases." El libro se escribió para lenguajes sin funciones de primera clase. Ya sabes de dónde viene esa forma.

"Es la convención del equipo." Esta sí cuenta, y bastante. Si tu proyecto usa clases en todos lados, una función suelta va a chocar y va a costarle a quien lea. La consistencia tiene valor real. Pero úsala como lo que es —una razón de contexto— y no la confundas con una razón técnica.

El catálogo de formas ligeras en Python

Aquí está el repertorio completo, de menos a más peso. Elige el primero que resuelva tu problema.

1. Una función suelta, pasada como argumento. La forma más liviana. Y la usas todos los días sin llamarla patrón:

attendees.sort(key=lambda a: a.name)
attendees.sort(key=lambda a: a.purchased_at)

Eso es una Strategy. sort no sabe cómo comparar; recibe la forma de extraer la clave. Contrato (Callable[[Attendee], Any]), implementaciones (las dos lambdas), punto de elección (quien llama). Las tres piezas de la lección 2, en una línea. Si alguna vez dudas de que un patrón pueda vivir sin clases, mira sorted.

2. Un diccionario de funciones. Es la Strategy con su punto de elección, en su forma mínima. Es lo que hiciste con RULES en el ejemplo trabajado.

WRITERS = {"csv": write_csv, "xlsx": write_xlsx, "pdf": write_pdf}
writer = WRITERS[fmt]

3. Una clausura, cuando hace falta configuración. Una función que fabrica funciones ya configuradas:

def make_early_bird_pricing(discount: float):
    """Devuelve una regla de early-bird con el descuento indicado."""
    def price_for(context: PricingContext) -> float:
        before = (context.early_bird_cutoff is not None
                  and context.now <= context.early_bird_cutoff)
        price = context.base_price * (1 - discount) if before else context.base_price
        return _with_service_fee(price)
    return price_for


RULES = {
    "early_bird": make_early_bird_pricing(discount=0.30),
    "flash_sale": make_early_bird_pricing(discount=0.50),   # ¡la misma lógica!
}

Mira la última línea, porque es donde esto brilla. Dos reglas de negocio distintas, cero código duplicado, cero clases. Con el enfoque de clases habrías escrito FlashSalePricing(EarlyBirdPricing) o una clase con un parámetro en el constructor. La clausura llega al mismo sitio con menos ceremonia.

4. functools.partial, cuando solo hay que fijar argumentos. Si ya tienes una función general y lo único que quieres es dejar algunos parámetros clavados:

from functools import partial

def percentage_pricing(context, rate: float, extra: float = 0.0) -> float:
    return _with_service_fee(context.base_price * rate + extra)


RULES = {
    "general": partial(percentage_pricing, rate=1.00),
    "vip":     partial(percentage_pricing, rate=1.35, extra=150.0),
}

Cuidado con este: es muy tentador y se pasa de la raya rápido. Aquí ya se nota, y vale la pena decirlo. Esta versión asume que general y VIP son "la misma fórmula con otros números", y en la lección 3 vimos que no lo son: general tiene el descuento grupal y VIP tiene el descuento de socio. Forzarlas a la misma función parametrizada es exactamente el error de "buscar el eje de variación en la forma del código en vez de en el negocio" que la lección 1 te advirtió. partial es excelente para fijar dependencias —un cliente, un reloj, una configuración— y peligroso para unificar reglas de negocio que solo se parecen hoy.

5. Un objeto invocable (__call__), cuando quieres las dos cosas. Una clase que se usa como función:

class TieredCommission:
    def __init__(self, tiers):
        self._tiers = tiers

    def __call__(self, amount: float) -> float:
        ...

# Se usa igual que una función:
commission = TieredCommission(tiers=[(0, 0.05), (10_000, 0.03)])
print(commission(15_000))

Sirve cuando necesitas estado o metadatos pero quieres que quien lo use no se entere de la diferencia. Es la forma que hace intercambiables la versión ligera y la pesada: si RULES guarda funciones y un día una de ellas necesita estado, la conviertes en un invocable y nadie más cambia. Esa intercambiabilidad es el mejor argumento contra el "así se puede extender después": ya se puede.

6. Una clase con métodos nombrados. El peso completo. Justificado cuando tienes varias operaciones relacionadas —la razón 2—.

La regla para recorrer la lista: baja por el catálogo y quédate en el primer punto que resuelva tu problema. La mayoría de los casos se detienen en el 1 o el 2.

Errores comunes

Escribir una clase de un solo método sin estado (de diseño). Qué pasa: aparece class PriceCalculator con un único método calculate(...) y ningún atributo. Cada uso es PriceCalculator().calculate(x), es decir, construir un objeto vacío para llamar a una función. La clase no agrupa nada, no recuerda nada y no se puede configurar: es una función con dos líneas extra y un paréntesis vacío. Por qué pasa: la costumbre de otros lenguajes, donde todo tiene que vivir dentro de una clase porque el lenguaje no admite funciones al nivel del módulo. Cómo detectarlo: la prueba de los treinta segundos —si tu clase no tiene __init__, o su __init__ no guarda nada, y tiene un solo método público, es una función—. Otro síntoma: si el nombre de la clase y el del método dicen lo mismo (PriceCalculator.calculate), estás nombrando dos veces la misma cosa. Cómo corregirlo: bórrala y deja la función. Si algún día necesita estado, __call__ te devuelve la clase sin que nadie más cambie una línea.

Irse al otro extremo y pasar cinco funciones sueltas (de diseño). Qué pasa: alguien aprende esto, decide que las clases sobran, y termina con firmas como export_report(event_id, path, open_fn, header_fn, row_fn, close_fn, sort_fn). Nadie recuerda el orden de los argumentos, nada garantiza que las cinco funciones sean coherentes entre sí, y pasar cuatro de las cinco por error compila perfectamente. Por qué pasa: el péndulo. Después de ver que una función basta para una Strategy, es fácil concluir que basta para todo. Cómo detectarlo: si tu firma recibe más de dos funciones que tienen que ir juntas, esas funciones eran los métodos de un objeto. Cómo corregirlo: vuelve a la razón 2. Un verbo es una función; varios verbos que comparten contexto son un sustantivo. El ReportWriter de la lección 4 —cuatro métodos que comparten un archivo abierto— es exactamente el caso donde la clase gana.

Creer que sin clases no hay patrón (conceptual). Qué pasa: en una revisión, alguien dice "esto no es una Strategy, es solo un diccionario de funciones", y la conversación se va a discutir nomenclatura en vez de diseño. O peor: alguien reescribe un diccionario de funciones perfectamente sano como jerarquía de clases "para que sea un patrón de verdad". Por qué pasa: los patrones se enseñan con diagramas de clases, y el diagrama se pega a la memoria más que la idea. Cómo detectarlo: si tu criterio para nombrar un patrón depende de la sintaxis y no de la estructura —contrato, implementaciones, punto de elección—, estás leyendo la forma. Cómo corregirlo: vuelve a la definición del módulo 1. Un patrón es una solución con nombre a un problema recurrente; el nombre describe qué resuelve, no con qué palabras clave. sorted(key=...) es una Strategy, aunque no haya ni una sola clase a la vista.

Ejercicios

Ejercicio 1 — Aplica la prueba de las cuatro razones. Para cada caso, decide si va como función o como clase, y di cuál de las cuatro razones lo justifica (estado entre llamadas / varias operaciones / configuración validada / identidad). Si no aplica ninguna, dilo.

(a) Una regla que valida si un cupón de descuento es aplicable a una orden. (b) Un exportador que escribe un archivo grande por partes: abre, escribe muchas filas, cierra. (c) Una función de ranking para ordenar los eventos de la página de inicio. (d) Un cliente de la API de un proveedor de pago, que mantiene la sesión HTTP abierta y expone charge, refund y get_status. (e) Una regla de comisión configurable con tramos, que se lee de un archivo de configuración y aparece en un panel de administración con su nombre visible.

Ver solución

(a) Función. Ninguna razón aplica: recibe una orden y un cupón, devuelve un booleano, no recuerda nada entre llamadas. def coupon_applies(coupon, order) -> bool.

(b) Clase — razón 1 (estado entre llamadas). El archivo abierto tiene que sobrevivir entre open, las filas y close. Con funciones sueltas habría que ir pasando el descriptor de archivo de una a otra, que es exactamente "un objeto escrito a mano". Es el CsvExporter de la lección 4, y ahí la clase estaba bien puesta.

(c) Función. Es el key= de sorted. Una expresión, sin estado, sin configuración. Si necesitas varias, un diccionario de funciones. Cero clases.

(d) Clase — razones 1 y 2. Estado (la sesión HTTP, quizá un token que se renueva) y tres operaciones que van juntas y comparten esa sesión. Es el caso de libro donde la clase se gana su lugar sin discusión.

(e) Híbrido — razones 3 y 4. Necesitas configuración validada (los tramos, que hay que verificar al cargar el archivo) e identidad (el nombre visible para el panel). Aquí la mejor solución no es una clase por regla ni una función suelta, sino el patrón que vimos: un dataclass con los metadatos y un campo calculate que guarda la función —construida con una clausura a partir de la configuración—. Tienes identidad y comportamiento sin escribir una clase por cada esquema de comisión.

Por qué funciona: dos de los cinco piden clase, dos piden función y uno pide la forma híbrida. Esa mezcla es la realista. La habilidad no es preferir un mecanismo: es tener un criterio para elegir en cada caso.

Ejercicio 2 — Aligera una jerarquía innecesaria. Reescribe esto en su forma más ligera. Después responde: ¿cuántas líneas quedaron?, ¿qué se perdió?, ¿bajo qué cambio del requerimiento volverías a las clases?

class ValidationRule(ABC):
    @abstractmethod
    def validate(self, order) -> str | None:
        """Devuelve un mensaje de error, o None si está bien."""


class HasTicketsRule(ValidationRule):
    def validate(self, order):
        if not order.ticket_ids:
            return "La orden no tiene boletos"
        return None


class PositiveTotalRule(ValidationRule):
    def validate(self, order):
        if order.total < 0:
            return "El total no puede ser negativo"
        return None


class MaxTicketsRule(ValidationRule):
    def validate(self, order):
        if len(order.ticket_ids) > 20:
            return "No se pueden comprar más de 20 boletos por orden"
        return None


VALIDATORS = [HasTicketsRule(), PositiveTotalRule(), MaxTicketsRule()]


def validate_order(order):
    errors = [r.validate(order) for r in VALIDATORS]
    return [e for e in errors if e is not None]
Ver solución
# Archivo: checkout/validation.py
from typing import Callable

ValidationRule = Callable[["Order"], str | None]


def has_tickets(order) -> str | None:
    if not order.ticket_ids:
        return "La orden no tiene boletos"
    return None


def positive_total(order) -> str | None:
    if order.total < 0:
        return "El total no puede ser negativo"
    return None


def max_tickets(order) -> str | None:
    if len(order.ticket_ids) > 20:
        return "No se pueden comprar más de 20 boletos por orden"
    return None


VALIDATORS: list[ValidationRule] = [has_tickets, positive_total, max_tickets]


def validate_order(order) -> list[str]:
    errors = (rule(order) for rule in VALIDATORS)
    return [e for e in errors if e is not None]

Cuántas líneas. De unas 30 a unas 22, y desaparecieron tres construcciones de objetos vacíos en VALIDATORS. Ninguna de las tres reglas tenía estado, ninguna tenía configuración, y el contrato es un solo método: no había ninguna de las cuatro razones.

Qué se perdió. El ABC con @abstractmethod garantizaba que una regla nueva implementara validate. Con funciones, si alguien mete algo con la firma equivocada, el error sale al ejecutar. En la práctica el alias ValidationRule más un verificador de tipos cubre eso en tiempo de desarrollo, que es cuando conviene enterarse. Y se perdió el nombre de tipo por regla, que aquí no lo usaba nadie.

Cuándo volvería a las clases. Tres cambios concretos lo justificarían.

Si cada regla necesitara configuración —"el máximo de boletos depende del evento"—, aunque ahí la clausura sigue ganando: make_max_tickets_rule(limit=20).

Si cada regla necesitara metadatos —un código de error, un nivel de severidad, si es bloqueante o solo advertencia—, ahí sí conviene subir de peso, y la forma híbrida es la mejor: un dataclass con code, severity y check: ValidationRule.

Y si el contrato creciera a dos operaciones —por ejemplo applies_to(order) además de validate(order), para saltarse reglas que no aplican a cierto tipo de orden—, ahí es la razón 2 y la clase gana limpiamente.

Por qué funciona: haces el camino de vuelta —de clases a funciones— y después enumeras las condiciones exactas del camino de ida. Esa simetría es el criterio. Quien solo sabe ir en una dirección no está decidiendo.

Ejercicio 3 — Encuentra las Strategy que ya usas sin saberlo. Sin escribir código, identifica en la biblioteca estándar de Python o en librerías que uses tres lugares donde se te pide pasar una función como argumento para cambiar el comportamiento. Para cada uno, nombra las tres piezas: contrato, implementaciones, punto de elección. Después responde: ¿qué habría hecho falta en un lenguaje sin funciones de primera clase?

Ver solución

No hay una lista única; estos tres son los más claros.

sorted(iterable, key=...). Contrato: Callable[[T], Any], una función que extrae la clave de comparación. Implementaciones: cualquier función o lambda que escribas. Punto de elección: quien llama, en cada llamada.

max, min, filter, functools.reduce. La misma forma: el algoritmo es fijo —recorrer, comparar, acumular— y el criterio se entrega desde fuera.

Los ganchos de las librerías HTTP y de las de gráficas: un hook, un callback, un on_error. Contrato: la firma que la librería documenta. Implementaciones: tu función. Punto de elección: tú, al configurar el cliente.

Y hay una cuarta que vale la pena porque además es la respuesta a la última pregunta: los decoradores. Un decorador es una función que recibe una función y devuelve otra que la envuelve. Eso es un Decorator —el patrón del módulo 5— convertido en sintaxis del lenguaje.

Qué habría hecho falta sin funciones de primera clase. En cada caso, una interfaz con un método y una clase por criterio. Java, antes de que tuviera lambdas, resolvía sorted exactamente así: una interfaz Comparator con un método compare, y una clase (a menudo anónima) por cada forma de ordenar. Diez líneas de ceremonia para expresar "por nombre".

Esa comparación es la lección entera en un ejemplo. El patrón es el mismo en los dos lenguajes; lo que cambia es cuánto código hace falta para escribirlo. Y cuando el lenguaje lo hace casi invisible, el patrón no desapareció: se volvió idioma.

Por qué funciona: descubrir que llevas años usando Strategy sin llamarla así hace dos cosas. Desmitifica el catálogo —resulta que ya sabías el patrón más importante— y te da el criterio definitivo para elegir el mecanismo: si la biblioteca estándar de Python resuelve esto con una función, tu código probablemente también puede.

Resumen y siguiente paso

En esta lección le hiciste al módulo la pregunta incómoda: ¿hacían falta las clases? Y la respuesta resultó ser "a veces sí, casi siempre no, y hay una forma de saberlo".

Viste de dónde viene la ceremonia: el catálogo se escribió para lenguajes donde una función no se podía pasar como argumento, así que había que envolverla en un objeto. Norvig lo señaló en 1996: 16 de los 23 patrones son cualitativamente más simples —o desaparecen— en un lenguaje dinámico. No es que el patrón sobre; es que parte de lo que resolvía era una carencia del lenguaje.

Reescribiste las reglas de precio de la lección 3 con funciones y un diccionario: un tercio menos de código, la misma estructura conceptual —contrato, implementaciones, punto de elección—, la misma Strategy. Y viste lo que se pierde, que siempre tiene que ver con identidad: el nombre de tipo, el lugar natural para metadatos, y un contrato que documenta mejor.

Te llevas las cuatro razones por las que una clase se gana su lugar: estado entre llamadas, varias operaciones relacionadas que comparten contexto, configuración que conviene validar al construir, e identidad. Y las que no cuentan: "es más orientado a objetos", "así se puede extender después" —falso, porque __call__ te deja subir de peso sin que nadie más cambie— y "el libro lo hace así". La convención del equipo sí cuenta, pero como razón de contexto.

Y tienes el catálogo de formas ligeras, para bajar por él y quedarte en la primera que resuelva tu problema: función suelta → diccionario de funciones → clausura → partial → objeto invocable → clase con métodos. Con la advertencia sobre partial: excelente para fijar dependencias, peligroso para unificar reglas de negocio que solo se parecen hoy.

Antes de avanzar deberías poder: aplicar la prueba de las cuatro razones a un caso concreto; convertir una jerarquía de un solo método en funciones y decir qué se perdió; y reconocer las Strategy que ya venías usando en sorted, filter y los ganchos de cualquier librería.

Nos queda la vacuna del módulo, y es la lección que más te va a servir en el trabajo. Hasta aquí supusimos que el condicional que tenías delante merecía algo: una Strategy, un método plantilla, una tabla de estados, o al menos una función extraída. La lección 7 cuestiona esa premisa. Un if de dos ramas que no va a crecer está bien como está. Vas a aprender a distinguir las señales reales de que un condicional pide extracción —crece con cada caso, se repite en varios lugares, mezcla decidir con hacer— de las falsas, que son casi todas las que la gente usa. Y con eso ya estarás listo para el proyecto.

Recursos