Dataclasses y anotaciones de tipo
Qué escribe `@dataclass` por ti, por qué hace falta `field(default_factory=...)`, qué da `frozen=True`, anotaciones de tipo en funciones, y qué significa que las pistas no hacen nada mientras el programa se ejecuta pero lo son todo para un comprobador de tipos.
¿Cómo quieres aprender?
El problema que vamos a resolver
Escrita por completo, la clase Book del último capítulo queda así:
class Book:
def __init__(self, title, author, pages):
self.title = title
self.author = author
self.pages = pages
def __repr__(self):
return f"Book({self.title!r}, {self.author!r}, {self.pages!r})"
def __eq__(self, other):
return (self.title, self.author, self.pages) == (other.title, other.author, other.pages)
a = Book("one", "rafi", 300)
print(a)
print(a == Book("one", "rafi", 300))Book('one', 'rafi', 300)
TrueTrece líneas, y ni una sola decisión real en ellas. El nombre de cada campo se escribe una y otra vez — como parámetro de __init__, en la línea self., en __repr__ y en __eq__.
Añadir un cuarto campo significa ponerlo en todos esos sitios. Olvida uno y no salta nada — __repr__ o __eq__ simplemente se quedan incompletos en silencio.
from dataclasses import dataclass
@dataclass
class Book:
title: str
author: str
pages: int
a = Book("one", "rafi", 300)
print(a)
print(a == Book("one", "rafi", 300))Book(title='one', author='rafi', pages=300)
TrueSiete líneas, y cada campo nombrado una sola vez.
Al terminar este capítulo podrás
- Escribir un
@dataclass, y decir qué genera - Usar
field(default_factory=...), y decir por qué hace falta - Decir qué da
frozen=True - Escribir anotaciones de tipo en funciones —
list[str],dict[str, float],int | None - Decir que las pistas no hacen nada mientras el programa se ejecuta, pero mucho para un comprobador de tipos
- Enviar un dataclass a JSON con
asdict
Lo que necesitas primero: Clases.
Qué escribe @dataclass por ti
A cambio de esas siete líneas obtienes:
- Un
__init__cuyos parámetros siguen los campos, en orden - Un
__repr__que muestra cada campo con su nombre - Un
__eq__que dice que dos objetos son iguales cuando todos sus campos coinciden
Tres de los problemas del último capítulo — <object at 0x...>, a == b dando False, y escribir los mismos nombres una y otra vez — se resuelven juntos.
Los métodos se escriben exactamente como antes:
from dataclasses import dataclass
@dataclass
class OrderLine:
name: str
price: float
quantity: int
def total(self) -> float:
return round(self.price * self.quantity * 1.15, 2)
line = OrderLine("pen", 15.0, 3)
print(line)
print(line.total())OrderLine(name='pen', price=15.0, quantity=3)
51.75Los valores por defecto también funcionan. La regla es la misma que en el capítulo 20: los campos con valor por defecto van al final.
from dataclasses import dataclass
@dataclass
class Book:
title: str
pages: int = 1
print(Book("one"))
print(Book("one", 300))Book(title='one', pages=1)
Book(title='one', pages=300)Las pistas no hacen nada mientras el programa se ejecuta
Esto tiene que estar claro pronto, porque el nombre sugiere otra cosa.
from dataclasses import dataclass
@dataclass
class Book:
title: str
pages: int
b = Book("one", "three hundred")
print(b)
print(b.pages + 1)Book(title='one', pages='three hundred')
TypeError: can only concatenate str (not "int") to strSe escribe pages: int, y aun así entró una cadena directamente. Python no comprueba las pistas. Solo las guarda; usarlas es trabajo de otro.
El error llegó mucho después, cuando algo intentó calcular con ese valor. Mientras tanto se imprimió — en un programa real también podría haberse registrado, o escrito a un archivo.
Aquí es donde ayuda un comprobador de tipos. mypy es un programa aparte que lee el código sin ejecutarlo. Guarda el código anterior como main.py y ejecuta mypy main.py:
main.py:10: error: Argument 2 to "Book" has incompatible type "str"; expected "int" [arg-type]El número de línea, qué argumento, qué se dio y qué se esperaba — y el programa nunca se ejecutó.
La idea entera en dos frases: las pistas no hacen nada mientras el programa se ejecuta; para un comprobador de tipos lo significan todo. Escribir una pista es una promesa al comprobador de tipos y al lector, no al intérprete.
Las pistas guardadas se pueden mirar:
def shout(text: str) -> str:
return text.upper()
print(shout("pen"))
print(shout.__annotations__)PEN
{'text': <class 'str'>, 'return': <class 'str'>}El valor por defecto mutable, una tercera vez
En el capítulo 21 era un argumento por defecto de una función. En el capítulo 26 era un atributo a nivel de clase. Ahora es un campo de dataclass — y esta vez Python lo detiene solo:
from dataclasses import dataclass
@dataclass
class Shelf:
books: list = []ValueError: mutable default <class 'list'> for field books is not allowed: use default_factoryFíjate que no se creó ningún objeto — el error llegó mientras se definía la clase. Python ha visto este error tantas veces que aquí simplemente se niega.
El camino correcto es dar una fábrica — no la cosa construida, sino qué construir:
from dataclasses import dataclass, field
@dataclass
class Shelf:
name: str
books: list[str] = field(default_factory=list)
a = Shelf("a")
b = Shelf("b")
a.books.append("one")
print(a)
print(b)Shelf(name='a', books=['one'])
Shelf(name='b', books=[])field(default_factory=list) significa "llama a list() una vez por cada objeto nuevo". Hace el mismo trabajo que el if basket is None: basket = [] del capítulo 21, en una línea y sin riesgo de equivocarse.
frozen=True
from dataclasses import dataclass
@dataclass(frozen=True)
class Point:
x: int
y: int
p = Point(1, 2)
print(p)
print({p, Point(1, 2)})
p.x = 5Point(x=1, y=2)
{Point(x=1, y=2)}
dataclasses.FrozenInstanceError: cannot assign to field 'x'Sucedieron dos cosas a la vez, y están conectadas.
El objeto ya no se puede cambiar. p.x = 5 se paró con un error — igual que una tupla.
Y el objeto ahora puede usarse en un conjunto. Recuerda el problema final del último capítulo: escribir __eq__ quitaba el hash. Aquí dos puntos "iguales" se volvieron uno en el conjunto, sin error ninguno.
Sin frozen:
from dataclasses import dataclass
@dataclass
class Point:
x: int
y: int
print({Point(1, 2)})TypeError: unhashable type: 'Point'La razón viene del capítulo 16: lo que puede cambiar no es seguro para hashear. Si cambiara después de ponerse en un conjunto, nunca se volvería a encontrar.
frozen=True dice "esto no va a cambiar", así que Python está dispuesto a dar el hash.
La regla es simple: congela cualquier objeto que no deba cambiar después de crearse. Obtienes una garantía, y con ella la capacidad de usar el objeto en un conjunto o como clave de diccionario.
Pistas en funciones
def totals(rows: list[tuple[str, float]]) -> dict[str, float]:
result: dict[str, float] = {}
for name, amount in rows:
result[name] = result.get(name, 0.0) + amount
return resultlist[str], dict[str, float], tuple[str, float] — así también puedes decir qué hay dentro, y eso es lo que hace que las pistas sean útiles.
Un list simple dice solo "una lista". list[tuple[str, float]] dice qué obtendrá cada vuelta del bucle.
Dale lo equivocado — digamos que la línea 9 de main.py es totals("pen") — y mypy dice:
main.py:9: error: Argument 1 to "totals" has incompatible type "str"; expected "list[tuple[str, float]]" [arg-type]Para "puede que haya uno o puede que no", usa |:
def find(names: list[str], wanted: str) -> str | None:
for name in names:
if name == wanted:
return name
return None
print(find(["pen"], "pen"))
print(find(["pen"], "bag"))pen
NoneAquí es donde las pistas ayudan más. Usa el resultado sin comprobar None — digamos que la línea 8 se vuelve print(find(["pen"], "pen").upper()) — y mypy dice:
main.py:8: error: Item "None" of "str | None" has no attribute "upper" [union-attr]Eso es realmente AttributeError: 'NoneType' object has no attribute 'upper' — uno de los errores que más se ven en la práctica. Pero aquí se atrapa antes de que el programa se ejecute, y sin encontrar jamás la entrada que produciría None.
De dataclass a JSON
import json
from dataclasses import dataclass, asdict
@dataclass
class Book:
title: str
pages: int
print(asdict(Book("one", 300)))
print(json.dumps(asdict(Book("one", 300))))
print(json.dumps(Book("one", 300))){'title': 'one', 'pages': 300}
{"title": "one", "pages": 300}
TypeError: Object of type Book is not JSON serializableRecuerda la lista del capítulo 25: los objetos tuyos no van directo a JSON. asdict es el puente. También convierte dataclasses anidados en diccionarios.
Un ejemplo completo
El pedido del último capítulo, rehecho con dataclasses y pistas.
"""The order of chapter twenty-six, rebuilt with dataclasses and hints."""
import json
from dataclasses import dataclass, field, asdict
TAX_RATE = 0.15
@dataclass(frozen=True)
class OrderLine:
"""Frozen: a line never changes once made, so it can live in a set."""
name: str
price: float
quantity: int
def __post_init__(self) -> None:
if self.quantity < 1:
raise ValueError(f"quantity must be at least 1: {self.quantity}")
def total(self) -> float:
return round(self.price * self.quantity * (1 + TAX_RATE), 2)
@dataclass
class Order:
"""Not frozen: lines are added over time."""
customer: str
lines: list[OrderLine] = field(default_factory=list)
def add(self, name: str, price: float, quantity: int) -> "Order":
self.lines.append(OrderLine(name, price, quantity))
return self
def total(self) -> float:
return round(sum(line.total() for line in self.lines), 2)
def largest(self) -> OrderLine | None:
if not self.lines:
return None
return max(self.lines, key=lambda line: line.total())
def main() -> None:
order = Order("rafi")
order.add("pen", 15.0, 3)
order.add("bag", 850.0, 1)
order.add("ink", 120.0, 2)
for line in order.lines:
print(f"{line.name:<6} {line.total():>9.2f}")
print(f"{'total':<6} {order.total():>9.2f}")
print()
print(order.largest())
print(json.dumps(asdict(order)))
print(len({OrderLine("pen", 15.0, 3), OrderLine("pen", 15.0, 3)}))
try:
order.add("clip", 5.0, 0)
except ValueError as err:
print("rejected:", err)
if __name__ == "__main__":
main()pen 51.75
bag 977.50
ink 276.00
total 1305.25
OrderLine(name='bag', price=850.0, quantity=1)
{"customer": "rafi", "lines": [{"name": "pen", "price": 15.0, "quantity": 3}, {"name": "bag", "price": 850.0, "quantity": 1}, {"name": "ink", "price": 120.0, "quantity": 2}]}
1
rejected: quantity must be at least 1: 0Cinco cosas que merecen la pena ver.
__post_init__ es donde va la validación. @dataclass genera __init__ automáticamente, así que ahí no hay sitio para una comprobación. __post_init__ cubre ese hueco — se ejecuta justo después de que se establecen los campos.
Así que la garantía del último capítulo sigue valiendo: tener un OrderLine significa que su cantidad es al menos uno.
Uno está congelado y el otro no — a propósito. Una línea no debería cambiar una vez hecha. Pero un pedido va recibiendo líneas nuevas. La pregunta es la misma cada vez: ¿cambia esta cosa después de crearse?
El 1 demuestra que congelar funcionó. Dos OrderLine idénticos se volvieron uno en el conjunto.
Ejecutar la misma línea con el OrderLine del último capítulo imprimiría 2. No habría error: esa clase no tenía __eq__, así que conservaba el hash por defecto, pero dos objetos separados nunca eran iguales — el conjunto simplemente guardaba los dos.
list[OrderLine] no es decoración. Es cómo el comprobador de tipos sabe que line.total() es válido. Si escribieras line.totl(), lo pillaría — sin ejecutar el código.
"Order" está entre comillas. Cuando el nombre Order se escribe en la firma de add, la clase aún no ha terminado de definirse — el nombre no existe aún. Dentro de comillas, Python no busca el nombre en ese momento.
En Python 3.12, la versión que usa este curso, hay dos formas de usar el nombre propio de una clase en una pista dentro de esa clase: poner el nombre entre comillas, o escribir from __future__ import annotations al principio del archivo.
Cuando falla
ValueError: mutable default ... use default_factory Se escribió = [] o = {}. Usa field(default_factory=list).
TypeError: non-default argument follows default argument Un campo con valor por defecto está puesto antes de uno obligatorio. La regla del capítulo 20 otra vez: campos obligatorios primero.
dataclasses.FrozenInstanceError Se asignó algo en un objeto frozen=True. Cuando se necesite un cambio, dataclasses.replace(obj, x=5) da un objeto nuevo.
TypeError: unhashable type Un dataclass sin frozen=True no puede ir en un conjunto.
Un tipo equivocado entró y no pasó nada Como era de esperar — las pistas no hacen nada mientras el programa se ejecuta. Ejecuta mypy.
mypy no mira mi archivo Dale la ruta — mypy main.py. Y si la primera ejecución muestra un aluvión de mensajes, empieza con mypy --ignore-missing-imports.
TypeError: Object of type X is not JSON serializable Envía asdict(obj).
NameError por el nombre de mi propia clase dentro de sí misma Pon el nombre entre comillas: -> "Order". O escribe from __future__ import annotations al principio del archivo.
Paso 4 de 6 — Predice
Comprueba lo que has entendido
Un dataclass de siete líneas. ¿Qué imprimen las dos líneas?
from dataclasses import dataclass
@dataclass
class Book:
title: str
pages: int
print(Book("one", 300))
print(Book("one", 300) == Book("one", 300))- A<__main__.Book object at 0x...> False
- BBook(title='one', pages=300) True
- CBook(title='one', pages=300) False
- DBook('one', 300) True
No se crea ningún objeto, solo se escribe la clase. ¿Qué pasa?
from dataclasses import dataclass
@dataclass
class Shelf:
books: list = []- ANada; el problema aparece cuando se hace el primer objeto
- BUn `ValueError` — mientras se define la clase
- CUn `TypeError`
- DNada; todas las instancias compartirán una lista
Se pone frozen=True. ¿Qué pasa?
from dataclasses import dataclass
@dataclass(frozen=True)
class Point:
x: int
y: int
p = Point(1, 2)
print({p, Point(1, 2)})
p.x = 5- ASe imprime un conjunto de uno, luego un `FrozenInstanceError`
- BSe imprime un conjunto de dos, luego un `FrozenInstanceError`
- C`TypeError: unhashable type` — en el conjunto
- DSe imprime un conjunto de uno, luego `p.x` se vuelve `5`
Para responder necesitas una cuenta
Inicia sesión para comprobar tus respuestas
Las preguntas están arriba, y resolverlas mentalmente es lo que de verdad importa. Inicia sesión para ver las respuestas, las explicaciones y las pistas de tres niveles.
Tu turno
Reescribe el Book y el Shelf del capítulo 26 como dataclasses.
Book es frozen=True con title: str, author: str, pages: int, y un __post_init__ que lanza ValueError cuando pages < 1. Shelf tiene name: str y books: list[Book] = field(default_factory=list), más add, total_pages() -> int, longest() -> Book | None y by_author(name: str) -> list[Book].
Luego pip install mypy, ejecuta mypy library.py, y arregla cosas hasta que no reporte nada.
Luego seis experimentos:
- Pasa una cadena para
pages—Book("one", "rafi", "300"). ¿El programa se ejecuta? ¿Y qué dicemypy? - Sustituye el
field(default_factory=list)debookspor= []. ¿Cuándo llega el error — cuando se crea un objeto, o antes? - Haz dos
Bookidénticos y mételos en un conjunto. ¿Cuántos hay? Luego quitafrozen=Truey vuelve a probar. - Usa el resultado de
longest()directamente —shelf.longest().title— en una estantería vacía. ¿Qué dice el programa, y qué había dichomypyantes? asdict(shelf), escríbelo conjson.dumps, y léelo de vuelta conjson.loads. ¿Lo que vuelve es unShelf?- Lee un atributo mal escrito —
book.pagse. ¿Lo detectamypy?
Esos dos últimos muestran el límite y la fuerza de este capítulo juntos. Lo que vuelve de JSON es un diccionario, no un dataclass — la traducción va en un sentido, y el camino de vuelta tienes que escribirlo tú. Pero la brecha que el capítulo 26 no pudo cerrar — un nombre equivocado atrapado solo cuando el código se ejecuta — el comprobador de tipos la cierra antes de que el código se ejecute.
Paso 6 de 6
Reto — el quiz del capítulo
Diez preguntas de fácil a difícil. Las últimas son difíciles a propósito.
Inicia sesión para hacer el quiz