dataclass, match y patrones
En resumen: @dataclass crea una clase de datos para la que Python escribe __init__, __eq__ y __repr__, y match elige un caso según el tipo y los campos del objeto.
Problema
El asiento está ocupado, pero el diccionario no lo encuentra
Reservas en un cine: la clave es el asiento (fila y número), el valor es el nombre de la persona.
class Seat: def __init__(self, row, number): self.row = row self.number = numberreservations = {}reservations[Seat(5, 12)] = "María"
print(reservations.get(Seat(5, 12)))print(Seat(5, 12) in reservations)NoneFalseget devuelve None. Ya sabes por qué desde la lección «Códigos hash y funciones hash»: Seat(5, 12) escrito dos veces son dos objetos distintos, y Seat no dice cuándo dos de ellos son iguales. La solución es __eq__ y __hash__, más __repr__ para imprimir. Otras diez líneas más o menos para una clase que solo lleva dos números.La idea
@dataclass: una clase para datos
@dataclass lo mismo ocupa unas pocas líneas:from dataclasses import dataclass
@dataclass(frozen=True)class Seat: row: int number: intreservations = {}reservations[Seat(5, 12)] = "María"
print(reservations.get(Seat(5, 12)))print(Seat(5, 12) in reservations)
seat = Seat(5, 12)print(seat)print(f"Fila {seat.row}, asiento {seat.number}")MaríaTrueSeat(row=5, number=12)Fila 5, asiento 12@dataclass (del módulo dataclasses) delante de la clase dice que su trabajo es llevar datos. Los campos se escriben en el cuerpo con su tipo: row: int. A partir de ellos, Python escribe por ti:
- un constructor
__init__con todos los campos; __eq__, que compara los campos;- con
frozen=True: la prohibición de cambiar los campos después de crear el objeto, y un__hash__calculado con los campos. Por esogetahora encuentra a María; __repr__con la formaSeat(row=5, number=12).
Los campos se leen directamente: seat.row. Un objeto con frozen=True no cambia después de crearse.
reservations = {}reservations[Seat(5, 12)] = "María"
print(reservations.get(Seat(5, 12)))print(Seat(5, 12) in reservations)
seat = Seat(5, 12)print(seat)print(f"Fila {seat.row}, asiento {seat.number}")Paso a paso, en palabras
- Un diccionario vacío: la clave es un asiento del cine, el valor es un nombre.
- La asignación añade la clave Seat(5, 12) con el valor «María».
- get recibe un objeto Seat nuevo. Es otro objeto en la memoria, no la clave de la asignación.
- El __hash__ de una dataclass se calcula con los campos, así que es el mismo: la búsqueda va al mismo lugar.
- El __eq__ de una dataclass compara campo por campo: fila 5 y 5, asiento 12 y 12. Son iguales, y get devuelve «María».
- in hace lo mismo con otro Seat nuevo más: True.
- La dataclass también escribe sola __repr__, y los campos se leen directamente: seat.row.
Más precisamente, para curiosos
- Sin
frozen=Truelos campos pueden cambiar, y__hash__no se escribe. Entonces el objeto no puede ser clave de un diccionario:
from dataclasses import dataclass
@dataclassclass Seat: row: int number: int
seat = Seat(5, 12)seat.row = 6print(seat)try: reservations = {seat: "María"}except TypeError as e: print("TypeError:", e)Seat(row=6, number=12)TypeError: unhashable type: 'Seat'- Intentar cambiar un campo de un objeto
frozense detiene con unFrozenInstanceError. Y el tipo del campo es solo una sugerencia: Python no lo comprueba, ySeat("quinta", 12)pasa:
from dataclasses import dataclass
@dataclass(frozen=True)class Seat: row: int number: int
seat = Seat(5, 12)try: seat.row = 6except AttributeError as e: print(type(e).__name__ + ":", e)print(Seat("quinta", 12))FrozenInstanceError: cannot assign to field 'row'Seat(row='quinta', number=12)- La inmutabilidad es solo de un nivel. Si el campo es una lista, siempre apunta a la misma lista, pero la propia lista puede cambiar:
from dataclasses import dataclass
@dataclass(frozen=True)class Cart: items: list
cart = Cart(["café"])cart.items.append("miel")print(cart)Cart(items=['café', 'miel'])- En la lección «Códigos hash y funciones hash» escribiste
__eq__y__hash__a mano. Con@dataclassya los tienes.
La idea
Comprobaciones y métodos en una dataclass
__post_init__: @dataclass lo llama justo después de asignar los campos.from dataclasses import dataclass
@dataclass(frozen=True)class Price: amount: float currency: str
def __post_init__(self): if self.amount < 0: raise ValueError(f"El precio no puede ser negativo: {self.amount}")
def with_discount(self, percent): return Price(self.amount * (1 - percent / 100), self.currency)coffee = Price(18.50, "EUR")sale = coffee.with_discount(20)print(coffee)print(sale)
try: Price(-5, "EUR")except ValueError as e: print(e)Price(amount=18.5, currency='EUR')Price(amount=14.8, currency='EUR')El precio no puede ser negativo: -5- En
__post_init__los campos ya están asignados, así que la comprobación leeself.amount. Si lanza una excepción, el objeto no se crea. with_discountno cambia el precio. Devuelve unPricenuevo. Así se trabaja con objetos que no cambian:coffeesigue en 18,50 €.
Pruébalo
¿Son iguales?
Seat es una @dataclass(frozen=True), como en la tarjeta anterior.¿Qué imprimirá el programa?
a = Seat(5, 12)b = Seat(5, 12)print(a is b)print(a == b)print(hash(a) == hash(b))a y b son dos objetos, así que is es False: pregunta si son el mismo objeto. == llama al __eq__ de la dataclass, que compara los campos, y son los mismos. hash también se calcula con los campos, así que también es el mismo. Eso es justo lo que quiere el diccionario.Pruébalo
El envío sale caro
El envío a una oficina cuesta 4,50 €, pero el programa dice 6,90 €. ¿En qué línea está el error?
Elige la línea con el error.
method == "mensajero" or "domicilio" se lee como (method == "mensajero") or "domicilio". La primera parte es False, pero "domicilio" es una cadena no vacía, y eso es «truthy». La condición es verdadera para cualquier forma de envío, y la oficina nunca llega a su elif. La solución es method in ("mensajero", "domicilio"). Más claro todavía es un match, donde cada caso enumera sus valores. Eso es la siguiente tarjeta.La idea
match: caso por caso
match. Compara el valor con los casos uno por uno, de arriba abajo, y ejecuta el primero que encaja.def delivery_price(method): match method: case "oficina": return 4.50 case "mensajero" | "domicilio": return 6.90 case "tienda": return 0 case _: raise ValueError(f"Método de envío desconocido: {method}")
price = delivery_price("oficina")print(f"Envío: {price} €")Envío: 4.5 €- No hay caída: solo se ejecuta el código del caso que encaja.
|significa «o»:case "mensajero" | "domicilio".case _encaja con todo. Va al final y atrapa el resto, comodefaulten Java. Aquí lanza una excepción.matchno da un valor por sí mismo. Por eso está dentro de una función, y cada caso devuelve conreturn.- Python no comprueba que cada entrada esté cubierta. Sin
case _, ningún caso encaja con una forma desconocida: la función llega a su final y devuelveNone, sin error:
def delivery_price(method): match method: case "oficina": return 4.50 case "mensajero": return 6.90
print(delivery_price("correo"))NoneUn caso de varias líneas es un bloque normal con sangría:
def delivery(method, weight_kg): match method: case "oficina": return 4.50 case "mensajero": base = 6.90 return base + 3 if weight_kg > 10 else base case _: return 0
print(delivery("mensajero", 4))print(delivery("mensajero", 12))6.99.9Más precisamente, para curiosos
match es de Python 3.10. En código más antiguo lo mismo es una cadena de if y elif.
La idea
Una familia de tipos
La tienda acepta tres formas de pago: tarjeta, transferencia bancaria y contra reembolso. No hay otras. Cada forma es su propia clase:
from dataclasses import dataclass
@dataclass(frozen=True)class CardPayment: card_number: str
@dataclass(frozen=True)class BankTransfer: iban: str
@dataclass(frozen=True)class CashOnDelivery: pass
Payment = CardPayment | BankTransfer | CashOnDeliveryCada forma de pago son solo datos: un número de tarjeta, un IBAN o nada. Por eso son @dataclass. CashOnDelivery no tiene campos: su cuerpo es pass.
La última línea es una sugerencia de tipo: Payment es «uno de los tres». En Java, sealed hace que el compilador rechace un cuarto tipo. Python no tiene esa prohibición: la sugerencia solo describe la intención, y Python no la comprueba. Una cuarta clase puede aparecer en cualquier parte. Cómo protegerte de ella está en la siguiente tarjeta.
Míralo
Patrones en match
match también puede comprobar el tipo del objeto:def fee(payment, amount): match payment: case CardPayment(): return amount * 0.015 case BankTransfer(): return 0.50 case CashOnDelivery(): return 2.00print(fee(CardPayment("4111222233334444"), 200))print(fee(BankTransfer("ES9121000418450200051332"), 200))print(fee(CashOnDelivery(), 200))3.00.52.0case CardPayment(): significa «si el objeto es de tipo CardPayment». Los paréntesis aquí no crean un objeto: esto es un patrón, no una llamada. Es lo mismo que isinstance(payment, CardPayment), pero para muchos tipos a la vez.
Si olvidas un tipo, Python no dice nada. Ningún caso encaja, y la función devuelve None:
def fee(payment, amount): match payment: case CardPayment(): return amount * 0.015 case BankTransfer(): return 0.50
print(fee(CashOnDelivery(), 200))NoneAsí que pon al final un case _ que lance una excepción. Python igualmente no lo comprobará de antemano, pero al menos el programa se detendrá con un mensaje claro en el lugar del descuido:
def fee(payment, amount): match payment: case CardPayment(): return amount * 0.015 case BankTransfer(): return 0.50 case _: raise TypeError(f"Pago desconocido: {payment}")
try: fee(CashOnDelivery(), 200)except TypeError as e: print(e)Pago desconocido: CashOnDelivery()Un patrón de clase saca los campos directamente: CardPayment(number) pone el número de tarjeta en number. Esto funciona porque @dataclass también guarda el orden de los campos. El if después del patrón es una guarda: el caso se aplica solo si la condición es verdadera.
def describe(payment): match payment: case CardPayment(number): return f"tarjeta ****{number[-4:]}" case BankTransfer(iban) if iban.startswith("ES"): return "transferencia desde una cuenta española" case BankTransfer(iban): return "transferencia desde el extranjero" case CashOnDelivery(): return "contra reembolso"print(describe(CardPayment("4111222233334444")))print(describe(BankTransfer("ES9121000418450200051332")))print(describe(BankTransfer("DE89370400440532013000")))print(describe(CashOnDelivery()))tarjeta ****4444transferencia desde una cuenta españolatransferencia desde el extranjerocontra reembolsoMás precisamente, para curiosos
- Un solo pago también se puede comprobar con
isinstance. Y en el patrón también puedes nombrar el campo:CardPayment(card_number=number).
payment = CardPayment("4111222233334444")if isinstance(payment, CardPayment): print("La tarjeta termina en " + payment.card_number[12:])
match payment: case CardPayment(card_number=number): print("Y con el nombre del campo: " + number[-4:])La tarjeta termina en 4444Y con el nombre del campo: 4444- Los patrones se pueden anidar:
case Order(Customer(name), total).
Pruébalo
¿Qué caso gana?
¿Qué imprimirá el programa?
def label(payment, amount): match payment: case CashOnDelivery() if amount > 500: return "contra reembolso: no se acepta por encima de 500 €" case CashOnDelivery(): return "contra reembolso" case CardPayment() if amount > 1000: return "tarjeta: hace falta confirmación" case CardPayment(): return "tarjeta" case BankTransfer(): return "transferencia"
print(label(CashOnDelivery(), 650))print(label(CashOnDelivery(), 80))print(label(CardPayment("4111222233334444"), 650))if y llega al segundo. La tarjeta es por 650 €, y la confirmación hace falta por encima de 1000 €, así que gana el caso sin condición: «tarjeta».Más precisamente, para curiosos
El caso con guarda tiene que ir antes del general para el mismo tipo. Al revés no es un error: el caso general lo atrapa todo, y al de la guarda simplemente nunca se llega:
def label(payment, amount): match payment: case CashOnDelivery(): return "contra reembolso" case CashOnDelivery() if amount > 500: return "contra reembolso: no se acepta por encima de 500 €"
print(label(CashOnDelivery(), 650))contra reembolsoPython solo se detiene con un case _ que no es el último. Ese lo atrapa todo, y el código ni siquiera arranca:
code = """match payment: case _: print("todo") case CashOnDelivery(): print("contra reembolso")"""try: compile(code, "shop.py", "exec")except SyntaxError as e: print("SyntaxError:", e.msg)SyntaxError: wildcard makes remaining patterns unreachableLa idea
Texto en varias líneas
Un texto de varias líneas, como un recibo o un JSON, se lee mal con " y \n en cada línea. En Python el texto entre """ y """ puede ocupar varias líneas:
receipt = """\La tienda de la esquinaCafé en grano 18.50 €Miel 12.00 €Total: 30.50 €"""print(receipt, end="")
name = "María"total = 75.5data = f'{{"customer": "{name}", "total": {total:.2f}}}'print(data)La tienda de la esquinaCafé en grano 18.50 €Miel 12.00 €Total: 30.50 €{"customer": "María", "total": 75.50}- Una
\justo después de los"""de apertura quita el salto de línea de ahí, y el texto empieza en la línea siguiente. - La sangría se conserva tal como está escrita. Por eso las líneas aquí están pegadas a la izquierda.
- Las comillas de dentro no se escapan.
- La f-string rellena
{name}y{total:.2f}. Las llaves del propio texto se escriben dobles:{{y}}.
Más precisamente, para curiosos
En el cuerpo de una función las líneas tienen sangría. dedent del módulo textwrap quita la sangría común de la izquierda, como el bloque de texto en Java:
from textwrap import dedent
def receipt(): return dedent("""\ Café en grano 18.50 € Miel 12.00 € """)
print(receipt(), end="")Café en grano 18.50 €Miel 12.00 €En Java, var deja que el compilador deduzca el tipo de la variable. En Python no escribes el tipo de una variable: toda variable es como var, y el tipo pertenece al valor, no al nombre.
prices = {"café": 18.50, "miel": 12.00}for name, price in prices.items(): print(f"{name}: {price} €")café: 18.5 €miel: 12.0 €Ejercicio
Python moderno
Resuelve las tareas en tu editor.
1. Figuras. Tres clases con@dataclass(frozen=True): Circle con radius, Rectangle con width y height, y Triangle con base y height, más la sugerencia Shape = Circle | Rectangle | Triangle. Escribe area(shape) con match y patrones de clase, con un case _ que lance un TypeError. Escribe también name(shape), que dice «cuadrado» para un rectángulo con los lados iguales (con una guarda if). Imprime un círculo de radio 1, un rectángulo de 2 por 3, un cuadrado de 4 por 4, un triángulo de base 3 y altura 5, y el área total.círculo: 3.14rectángulo: 6.00cuadrado: 16.00triángulo: 7.50Total: 32.64@dataclass(frozen=True): ToOffice con office, ToAddress con city y weight_kg, y Pickup sin campos. El precio: a una oficina 4,50 €; a domicilio 5,90 € en Madrid y 6,90 € en otros lugares, más 1 € por cada kilogramo por encima de 5; recogida en la tienda 0 €. Imprime cada envío (con el __repr__ de la dataclass) y su precio.ToOffice(office='Valencia Centro') -> 4.50 €ToAddress(city='Madrid', weight_kg=2) -> 5.90 €ToAddress(city='Valencia', weight_kg=8) -> 9.90 €Pickup() -> 0.00 €Point con @dataclass(frozen=True) y los campos x e y, y cuenta cuántas veces estuvo en cada punto en un diccionario con visits.get(p, 0) + 1. Al final, busca Point(2, 3) en el diccionario.Point(x=2, y=3): 3Point(x=5, y=1): 2Point(x=0, y=0): 1Visitas a (2, 3): 3Más tareas
- Un estudiante como dataclass.
Studentconnameygradey un__post_init__que rechace una nota fuera del rango de 2 a 6. Ordena una lista de estudiantes consorted(students, key=lambda s: s.grade). - Día de la semana. Reescribe la tarea «día de la semana» de la lección «Condiciones» con
match:case 1 | 2 | 3 | 4 | 5: return "laborable". - Una carta. Con texto entre
"""y una f-string, haz una carta que confirme una reserva: nombre, fecha, número de invitados y precio en euros.
Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: dict y set · Códigos hash y funciones hash · Lambdas · Listas por comprensión y generadores
- Continúa en: Patrones de diseño · Bases de datos · Observer · Factory Method y Simple Factory
- Ejercicios sobre el tema: Python moderno