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:
selfes 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
_prestadosno esconde nada —en Python no hayprivate— pero es una señal clara: «esto es interno, no lo toques». @propertyconvierte un método en algo que se lee como un atributo. Y, si no escribes el setter, queda de solo lectura, como enseña eseAttributeError.__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__, porqueprintrecurre 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 |
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.