Ir al contenido
wannacode

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.

Seat
class Seat:
def __init__(self, row, number):
self.row = row
self.number = number
reservations = {}
reservations[Seat(5, 12)] = "María"
print(reservations.get(Seat(5, 12)))
print(Seat(5, 12) in reservations)
Salida
None
False
María está en la fila 5, asiento 12, pero get 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

Con @dataclass lo mismo ocupa unas pocas líneas:
from dataclasses import dataclass
@dataclass(frozen=True)
class Seat:
row: int
number: int
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}")
Salida
María
True
Seat(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 eso get ahora encuentra a María;
  • __repr__ con la forma Seat(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
  1. Un diccionario vacío: la clave es un asiento del cine, el valor es un nombre.
  2. La asignación añade la clave Seat(5, 12) con el valor «María».
  3. get recibe un objeto Seat nuevo. Es otro objeto en la memoria, no la clave de la asignación.
  4. El __hash__ de una dataclass se calcula con los campos, así que es el mismo: la búsqueda va al mismo lugar.
  5. El __eq__ de una dataclass compara campo por campo: fila 5 y 5, asiento 12 y 12. Son iguales, y get devuelve «María».
  6. in hace lo mismo con otro Seat nuevo más: True.
  7. La dataclass también escribe sola __repr__, y los campos se leen directamente: seat.row.
Más precisamente, para curiosos
  • Sin frozen=True los campos pueden cambiar, y __hash__ no se escribe. Entonces el objeto no puede ser clave de un diccionario:
from dataclasses import dataclass
@dataclass
class Seat:
row: int
number: int
seat = Seat(5, 12)
seat.row = 6
print(seat)
try:
reservations = {seat: "María"}
except TypeError as e:
print("TypeError:", e)
Salida
Seat(row=6, number=12)
TypeError: unhashable type: 'Seat'
  • Intentar cambiar un campo de un objeto frozen se detiene con un FrozenInstanceError. Y el tipo del campo es solo una sugerencia: Python no lo comprueba, y Seat("quinta", 12) pasa:
from dataclasses import dataclass
@dataclass(frozen=True)
class Seat:
row: int
number: int
seat = Seat(5, 12)
try:
seat.row = 6
except AttributeError as e:
print(type(e).__name__ + ":", e)
print(Seat("quinta", 12))
Salida
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)
Salida
Cart(items=['café', 'miel'])
  • En la lección «Códigos hash y funciones hash» escribiste __eq__ y __hash__ a mano. Con @dataclass ya los tienes.

La idea

Comprobaciones y métodos en una dataclass

Un precio no puede ser negativo. La comprobación va en el método __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)
Salida
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 lee self.amount. Si lanza una excepción, el objeto no se crea.
  • with_discount no cambia el precio. Devuelve un Price nuevo. Así se trabaja con objetos que no cambian: coffee sigue 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))
Respuesta

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.

La idea

match: caso por caso

Desde Python 3.10 existe 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} €")
Salida
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, como default en Java. Aquí lanza una excepción.
  • match no da un valor por sí mismo. Por eso está dentro de una función, y cada caso devuelve con return.
  • 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 devuelve None, sin error:
def delivery_price(method):
match method:
case "oficina":
return 4.50
case "mensajero":
return 6.90
print(delivery_price("correo"))
Salida
None

Un 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))
Salida
6.9
9.9
Má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 | CashOnDelivery

Cada 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

La comisión depende de la forma de pago. 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.00
print(fee(CardPayment("4111222233334444"), 200))
print(fee(BankTransfer("ES9121000418450200051332"), 200))
print(fee(CashOnDelivery(), 200))
Salida
3.0
0.5
2.0

case 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))
Salida
None

Así 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)
Salida
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()))
Salida
tarjeta ****4444
transferencia desde una cuenta española
transferencia desde el extranjero
contra reembolso
Má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:])
Salida
La tarjeta termina en 4444
Y con el nombre del campo: 4444
  • Los patrones se pueden anidar: case Order(Customer(name), total).

Pruébalo

¿Qué caso gana?

Los casos se comprueban de arriba abajo. Gana el primero que encaja.

¿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))
Respuesta
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))
Salida
contra reembolso

Python 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)
Salida
SyntaxError: wildcard makes remaining patterns unreachable

La 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 esquina
Café en grano 18.50 €
Miel 12.00 €
Total: 30.50 €
"""
print(receipt, end="")
name = "María"
total = 75.5
data = f'{{"customer": "{name}", "total": {total:.2f}}}'
print(data)
Salida
La tienda de la esquina
Café 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="")
Salida
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} €")
Salida
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.
Salida
círculo: 3.14
rectángulo: 6.00
cuadrado: 16.00
triángulo: 7.50
Total: 32.64
2. Precio del envío. Tres clases con @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.
Salida
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 €
3. Un punto como clave. El mensajero pasa por puntos del mapa: (2, 3), (5, 1), (2, 3), (0, 0), (5, 1), (2, 3). Haz 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.
Salida
Point(x=2, y=3): 3
Point(x=5, y=1): 2
Point(x=0, y=0): 1
Visitas a (2, 3): 3

Más tareas

  • Un estudiante como dataclass. Student con name y grade y un __post_init__ que rechace una nota fuera del rango de 2 a 6. Ordena una lista de estudiantes con sorted(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

1. ¿Qué obtienes ya hecho con @dataclass(frozen=True) encima de class Seat con los campos row: int y number: int?
2. ¿Por qué un Seat con @dataclass(frozen=True) sirve como clave en un diccionario y una clase normal sin nada añadido no?
3. ¿Qué hace case "mensajero" | "domicilio": en un match?
4. Una función con match no tiene caso para CashOnDelivery y recibe CashOnDelivery(). ¿Qué pasa?
5. ¿Qué hace case BankTransfer(iban) if iban.startswith("ES"):?

Creado por Ivo Gergov como parte de la campaña de Evo Tech por una educación accesible en programación ·