Clases y dataclasses

Python tiene clases desde siempre, pero desde la 3.7 casi nunca hace falta escribirlas enteras. Esta lección enseña una a mano, para entender las piezas, y luego la sustituye por un decorador de una línea.

Una clase, a mano

class Libro:
    def __init__(self, titulo, autor):
        if not titulo.strip():
            raise ValueError("el título no puede estar vacío")
        self.titulo = titulo
        self.autor = autor
        self._prestados = 0

    def prestar(self):
        self._prestados += 1

    @property
    def prestados(self):
        return self._prestados

    def __repr__(self):
        return f"Libro({self.titulo!r}, {self.autor!r})"

    def __str__(self):
        return f"«{self.titulo}», de {self.autor}"
salidastr : «Rayuela», de Cortázar
repr: Libro('Rayuela', 'Cortázar')
prestados: 2
asignar a una property: AttributeError: property 'prestados' of 'Libro' object has no setter
rechazado: el título no puede estar vacío

Lo que hay ahí, por orden de importancia:

  • self es el primer parámetro de todos los métodos y lo pone Python al llamar. No es una palabra reservada: es una convención tan firme que nadie la rompe.
  • __init__ no es el constructor, es el inicializador: el objeto ya existe cuando se llama. Es el sitio para validar.
  • El guion bajo de _prestados no esconde nada —en Python no hay private— pero es una señal clara: «esto es interno, no lo toques».
  • @property convierte un método en algo que se lee como un atributo. Y, si no escribes el setter, queda de solo lectura, como enseña ese AttributeError.
  • __repr__ y __str__: el primero es para ti, depurando, y debería poder copiarse y pegarse; el segundo es para el usuario. Si solo escribes uno, que sea __repr__, porque print recurre a él si no hay __str__.

Y aquí llega el problema

salidaa == b ? False   (sin __eq__, compara identidad)

Dos libros con el mismo título y el mismo autor no son iguales, porque el == que se hereda compara si son el mismo objeto. Para arreglarlo hay que escribir __eq__, y con él __hash__, y mantenerlos al día cada vez que se añade un campo.

dataclass: lo mismo sin la ceremonia

from dataclasses import dataclass

@dataclass(frozen=True, slots=True)
class Punto:
    x: int
    y: int

    def __post_init__(self):
        if self.x < 0 or self.y < 0:
            raise ValueError(f"nada de negativos: {self.x},{self.y}")

    @property
    def distancia(self):
        return (self.x**2 + self.y**2) ** 0.5
salidaPunto(x=3, y=4)
p == q ? True    hash igual? True
en un set: 1 elemento(s)
distancia: 5.0
frozen: FrozenInstanceError: cannot assign to field 'x'
__post_init__: nada de negativos: -1,0

El decorador escribe por ti __init__, __repr__, __eq__ y —con frozen=True— también __hash__. Por eso dos puntos iguales ocupan una sola posición en un conjunto.

Opción Qué hace
frozen=True inmutable, y por tanto utilizable como clave o en un set
slots=True ahorra memoria y evita crear atributos por error
order=True añade <, >… comparando campo a campo
__post_init__ se ejecuta tras asignar: el sitio para validar
field(default_factory=list) el valor por omisión mutable, bien hecho
Y aquí reaparece la trampa de la lección anterior. Un campo items: list = [] en una dataclass lo rechaza el propio Python, precisamente porque sería un mutable compartido:

salidaValueError: mutable default <class 'list'> for field items is not allowed: use default_factory

Hay que escribir items: list = field(default_factory=list).

Herencia, y por qué se usa poco

class Animal:
    def habla(self): raise NotImplementedError

class Perro(Animal):
    def habla(self): return "guau"

class Pato:                      # ni hereda ni le hace falta
    def habla(self): return "cuac"

for x in (Perro(), Pato()):
    print(x.habla())
salidaPerro: guau
Pato: cuac

El Pato no hereda de nada y funciona igual. A eso se le llama duck typing: a Python no le importa de qué clase eres, solo si tienes el método que va a llamar. Por eso la herencia aquí se usa bastante menos que en Java o C#: casi nunca hace falta un ancestro común para que dos cosas sean intercambiables.

Donde sí sirve es para compartir implementación entre cosas que de verdad son variantes de lo mismo. Si lo que quieres es solo declarar «esto tiene que tener estos métodos», mira typing.Protocol, que lo dice sin obligar a heredar.

Si el objeto… Escribe
Es un puñado de datos una @dataclass
Tiene que servir de clave o ir en un set @dataclass(frozen=True)
Debe validarse al crearse __post_init__, o __init__
Tiene lógica y estado que cambia una clase normal
Expone un valor calculado @property
Se va a imprimir al depurar __repr__

Todo el código de esta lección se ejecutó con Python 3.14 en un contenedor limpio antes de publicarla; las salidas y los errores están copiados de esa ejecución.