Métodos especiales, propiedades y classmethods
Cómo integrar tus clases con el lenguaje: __str__, __repr__, __eq__, operadores, @property, @classmethod y dataclasses
Los métodos especiales conectan tus clases con la sintaxis del lenguaje: print, len, == y + dejan de ser ajenos a tus objetos. Aquí escribes __str__ y __repr__, comparas y sumas instancias, validas atributos con @property y creas constructores alternativos con @classmethod.
- Escribir __repr__ y __str__ para que tus objetos sean legibles al depurar
- Comparar instancias por contenido con __eq__ y devolver NotImplemented cuando toca
- Implementar __len__ y operadores como __add__ sobre tus propias clases
- Validar atributos con @property y su setter, sin perder la asignación simple
- Crear constructores alternativos con @classmethod y utilidades con @staticmethod
- Reconocer cuándo una dataclass sustituye a decenas de líneas escritas a mano
Antes necesitas: Herencia, polimorfismo y abstracción
En esta lección
- ¿Qué son los métodos especiales?
- ¿Para qué sirven?
- ¿Cómo funciona por dentro?
- Sintaxis
- Ejemplo básico: un objeto que se imprime bien
- Ejemplo intermedio: comparar, sumar y medir
- Caso práctico: cargar empleados validados
- Visualización: una propiedad con validación
- Errores comunes
- Buenas prácticas
- Ejercicios
- Soluciones explicadas
- Resumen
- Evaluación
Hasta ahora, si imprimías un objeto tuyo, obtenías algo así: <__main__.Punto object at 0x104a3c0d0>. Tampoco podías sumar dos puntos ni compararlos por su contenido: p1 == p2 era False aunque tuvieran las mismas coordenadas. Eso no es una limitación del lenguaje, es que Python no sabe qué significan + o == para tu clase hasta que se lo dices.
Los métodos especiales, esos que empiezan y terminan con doble guion bajo, son el mecanismo para decírselo. Al implementarlos, tus objetos dejan de ser cajas opacas y se comportan como los tipos nativos: se imprimen bien, se comparan, se suman y se validan solos.
¿Qué son los métodos especiales?
Un método especial (o dunder, de double underscore) es un método con nombre reservado que Python llama en situaciones concretas de la sintaxis. Normalmente no los invocas tú: print(obj) llama a obj.__str__(), len(obj) a obj.__len__(), a == b a a.__eq__(b) y a + b a a.__add__(b).
Piensa en los enchufes de una casa: el aparato no sabe cómo está cableada la instalación, solo cumple la forma del enchufe. Tu clase no sabe cómo funciona print, solo cumple el protocolo __str__. Esa separación es la que permite que for funcione con listas, archivos, generadores y con la clase que escribas mañana.
| Método | Se ejecuta cuando... | Debe devolver |
|---|---|---|
__init__ | creas una instancia | None |
__str__ | usas print(obj) o str(obj) | un str legible |
__repr__ | miras el objeto en el REPL o dentro de una lista | un str que idealmente reconstruya el objeto |
__eq__ | comparas con == | True, False o NotImplemented |
__len__ | llamas a len(obj) | un entero no negativo |
__add__ | usas el operador + | un objeto nuevo |
¿Para qué sirven?
- Depurar sin adivinar: un
__repr__claro muestra los atributos y evita abrir el depurador para saber qué contiene una lista de objetos. - Comparar por valor: dos pedidos con los mismos artículos son iguales aunque sean objetos distintos en memoria.
- Usar la sintaxis natural:
len(carrito),pedido_a + pedido_b,producto in inventario. - Validar al asignar: un
@propertycon setter rechaza un salario negativo en el momento, no tres funciones después. - Constructores alternativos:
Empleado.desde_texto("Ana,1500")sin duplicar el__init__. - Interoperar: bibliotecas que ordenan, comparan o serializan objetos esperan estos protocolos.
¿Cómo funciona por dentro?
Python busca los métodos especiales en el tipo, no en la instancia. len(obj) mira type(obj).__len__, y por eso asignar obj.__len__ = lambda: 5 no cambia el resultado de len(obj). Esta regla permite que las operaciones integradas vayan rápido y que una instancia no pueda falsificar su propia longitud.
La relación entre __str__ y __repr__ es asimétrica: object.__str__ llama a __repr__ como respaldo, pero __repr__ no llama a __str__. Si tu __repr__ hace return str(self) y no existe un __str__ distinto, entras en recursión hasta RecursionError.
__eq__ debe devolver NotImplemented (el valor especial, no la excepción) cuando no reconoce el otro tipo. Python entonces intenta la comparación refleja y, si tampoco hay respuesta, cae a identidad. Devolver False a ciegas rompe comparaciones válidas con tipos que sí saben compararse contigo.
@property es un descriptor: un objeto que intercepta el acceso al atributo. El decorador convierte el método en un atributo calculado; el setter se registra con @nombre.setter. Si solo defines el getter, asignar lanza AttributeError (en Python 3.9 el mensaje es can't set attribute; en 3.11 y posteriores se indica que la propiedad no tiene setter). El atributo real suele guardarse con guion bajo, _celsius, y la propiedad es la puerta pública.
@classmethod recibe la clase como primer argumento (cls) y se usa sobre todo como constructor alternativo: al llamar cls(...) respeta las subclases. @staticmethod no recibe ni self ni cls: es una función corriente que vive dentro de la clase porque pertenece a su tema, no a sus datos.
Sintaxis
class Producto:
def __init__(self, nombre, precio):
self.nombre = nombre
self.precio = precio
def __repr__(self):
return f"Producto({self.nombre!r}, {self.precio})"
def __str__(self):
return f"{self.nombre}: S/ {self.precio:.2f}"
def __eq__(self, otro):
if not isinstance(otro, Producto):
return NotImplemented
return (self.nombre, self.precio) == (otro.nombre, otro.precio)
@property
def precio_con_igv(self):
return round(self.precio * 1.18, 2)
@classmethod
def desde_texto(cls, linea):
nombre, precio = linea.rsplit(",", 1)
return cls(nombre, float(precio))
@staticmethod
def es_precio_valido(valor):
return valor > 0
p = Producto.desde_texto("Laptop,2500")
print(p)
print(repr(p))
print(p.precio_con_igv)| Forma | Qué hace | Cuándo usarla |
|---|---|---|
@property | Convierte un método en atributo de lectura | Valores calculados o protegidos |
@x.setter | Registra la validación al asignar | Siempre que el atributo tenga reglas |
@classmethod | Recibe cls; puede crear instancias | Constructores alternativos |
@staticmethod | No recibe self ni cls | Funciones puras relacionadas con la clase |
@dataclass | Genera __init__, __repr__ y __eq__ | Clases que solo agrupan datos |
class Temperatura:
def __init__(self, celsius):
self.celsius = celsius
def __eq__(self, otra):
if not isinstance(otra, Temperatura):
return NotImplemented
return self.celsius == otra.celsius
def __str__(self):
return f"{self.celsius} C"
print(Temperatura(20) == Temperatura(20))
print(Temperatura(20))#include <iostream>
#include <stdexcept>
class Temperatura {
public:
explicit Temperatura(double celsius) : celsius_(celsius) {}
double celsius() const { return celsius_; }
void set_celsius(double valor) {
if (valor < -273.15) {
throw std::invalid_argument("por debajo del cero absoluto");
}
celsius_ = valor;
}
bool operator==(const Temperatura& otra) const {
return celsius_ == otra.celsius_;
}
friend std::ostream& operator<<(std::ostream& os, const Temperatura& t) {
return os << t.celsius_ << " C";
}
private:
double celsius_;
};
int main() {
Temperatura t{20.0};
t.set_celsius(25.0);
std::cout << t << '\n';
std::cout << std::boolalpha << (t == Temperatura{25.0}) << '\n';
return 0;
}En Python los protocolos son métodos con nombre fijo. En C++ son funciones libres u operadores: `operator==` y `operator<<`, y el getter/setter se escribe a mano. El efecto es el mismo: `cout << t` y `t == otra` funcionan.
Ejemplo básico: un objeto que se imprime bien
El primer protocolo que conviene implementar siempre es __repr__, porque es el que ves cuando algo falla.
class Punto:
def __init__(self, x, y):
self.x = x
self.y = y
def __repr__(self):
return f"Punto({self.x}, {self.y})"
def __str__(self):
return f"({self.x}, {self.y})"
p = Punto(3, 4)
print(p)
print(repr(p))
print([p])print(p) usa __str__ y muestra (3, 4). repr(p) y print([p]) usan __repr__ y muestran Punto(3, 4): cuando imprimes una lista, Python imprime el repr de cada elemento, no su str. Sin __repr__, esa última línea mostraría la dirección de memoria y no sabrías qué objeto es.
Ejemplo intermedio: comparar, sumar y medir
Un pedido se puede sumar con otro y se puede medir con len. Son operaciones que el usuario espera y que no requieren métodos nuevos con nombres inventados.
class Pedido:
def __init__(self, items):
self.items = list(items)
def __len__(self):
return len(self.items)
def __add__(self, otro):
return Pedido(self.items + otro.items)
def __eq__(self, otro):
if not isinstance(otro, Pedido):
return NotImplemented
return self.items == otro.items
def __repr__(self):
return f"Pedido({self.items!r})"
a = Pedido(["pan", "leche"])
b = Pedido(["café"])
print(a + b)
print(len(a + b))
print(a == Pedido(["pan", "leche"]))La salida es Pedido(['pan', 'leche', 'café']), luego 3 y True. __add__ devuelve un pedido nuevo: no modifica a ni b. len cuenta artículos y __eq__ compara el contenido de las listas, así que dos pedidos distintos en memoria son iguales si tienen lo mismo. __le__ o __lt__ no están definidos: ordenar estos pedidos lanzaría TypeError.
Caso práctico: cargar empleados validados
Recursos Humanos entrega un archivo de texto con líneas nombre,sueldo. Necesitas crear empleados, rechazar sueldos inválidos y formatear importes. Estos protocolos resuelven el caso completo sin funciones sueltas.
class Empleado:
def __init__(self, nombre, sueldo):
self.nombre = nombre
self.sueldo = sueldo # pasa por el setter
@property
def sueldo(self):
return self._sueldo
@sueldo.setter
def sueldo(self, valor):
if valor <= 0:
raise ValueError("el sueldo debe ser positivo")
self._sueldo = valor
@classmethod
def desde_texto(cls, linea):
nombre, sueldo = linea.split(",")
return cls(nombre, float(sueldo))
@staticmethod
def formato(valor):
return f"S/ {valor:,.2f}"
def __repr__(self):
return f"Empleado({self.nombre!r}, {self.sueldo})"
e = Empleado.desde_texto("Ana,1500")
print(e)
print(e.formato(e.sueldo))
try:
Empleado("Luis", -10)
except ValueError as error:
print("ValueError:", error)La salida es Empleado('Ana', 1500.0), S/ 1,500.00 y ValueError: el sueldo debe ser positivo. Fíjate en que __init__ asigna self.sueldo, no self._sueldo: así el setter valida también en la construcción, y no hay forma de crear un empleado con sueldo negativo. desde_texto es un constructor alternativo: si mañana existe class Gerente(Empleado), cls(...) crea un gerente sin cambiar una línea. formato no necesita el objeto, por eso es staticmethod.
Cuando la clase solo agrupa datos, escribir esos métodos a mano es trabajo repetido. Para eso existe dataclasses:
from dataclasses import dataclass
@dataclass
class Punto:
x: float
y: float = 0.0
p = Punto(3, 4)
print(p)
print(p == Punto(3, 4))
print(Punto(5))El decorador genera __init__, __repr__ y __eq__ a partir de las anotaciones. La salida es Punto(x=3, y=4), True y Punto(x=5, y=0.0). No genera validación: si necesitas reglas, escribe el __post_init__ o vuelve a la clase con @property. Y como define __eq__, la clase deja de ser hashable salvo que uses frozen=True.
Visualización: una propiedad con validación
Sigue el recorrido de t.celsius = 25 y de la asignación inválida final. Observa que el atributo público se llama celsius pero el dato vive en _celsius, y que print(t) y repr(t) usan métodos distintos.
class Temperatura: def __init__(self, celsius): self._celsius = celsius @property def celsius(self): return self._celsius @celsius.setter def celsius(self, valor): if valor < -273.15: raise ValueError("por debajo del cero absoluto") self._celsius = valor def __str__(self): return f"{self._celsius} °C" def __repr__(self): return f"Temperatura({self._celsius})"t = Temperatura(20)print(t)t.celsius = 25print(t.celsius)print(repr(t))try: t.celsius = -300except ValueError as error: print("Error:", error)celsius queda registrado como propiedad, no como un método normal: el decorador @property reemplaza el nombre por un descriptor.—
Avanza paso a paso: observa la línea resaltada, las variables y la salida.
Errores comunes
| Mensaje | Causa habitual | Cómo se arregla |
|---|---|---|
AttributeError: can't set attribute | La propiedad tiene getter pero no setter | Añade @nombre.setter o no asignes a ese atributo |
TypeError: __str__ returned non-string (type int) | __str__ devuelve un número en lugar de texto | Envuelve el valor en str(...) o en una f-string |
RecursionError: maximum recursion depth exceeded | El setter se asigna a sí mismo, o __repr__ llama a str(self) | Guarda en _atributo y usa object.__repr__(self) como base |
TypeError: unsupported operand type(s) for +: 'Pedido' and 'Pedido' | Falta __add__ para ese tipo | Implementa __add__ y devuelve un objeto nuevo |
TypeError: unhashable type: 'Punto' | Definiste __eq__ y Python anuló __hash__ | Define __hash__ o usa @dataclass(frozen=True) |
class Caja:
@property
def peso(self):
return self.peso
@peso.setter
def peso(self, valor):
self.peso = valor
c = Caja()
c.peso = 10class Caja:
@property
def peso(self):
return self._peso
@peso.setter
def peso(self, valor):
self._peso = valor
c = Caja()
c.peso = 10
print(c.peso)Dentro del setter, `self.peso = valor` vuelve a entrar en el setter: cada llamada abre otra hasta agotar la pila. El getter tiene el mismo problema. La convención es guardar en un atributo con otro nombre, `_peso`, y dejar `peso` como la puerta pública.
Los dos últimos errores de la tabla tienen la misma raíz: un método especial cambió una regla del lenguaje. Al definir __eq__ por contenido, Python ya no puede usar la identidad para calcular el hash y pone __hash__ a None. En una lista no importa; al meter el objeto en un set o usarlo como clave de un diccionario, falla.
Buenas prácticas
- Define siempre
__repr__: es tu red de seguridad al depurar. - Usa
__str__solo cuando la versión para personas sea distinta de la técnica. - En
__eq__, comprueba el tipo conisinstancey devuelveNotImplementedsi no lo reconoces. - Si defines
__eq__y vas a usar el objeto enseto como clave, define también__hash__. - No hagas cálculos costosos ni consultas de red dentro de
__repr__: se llama en logs y en el depurador. - Valida en el setter, no en cada método que use el atributo.
@classmethodpara construir,@staticmethodpara funciones puras afines; si no usasclsniself, no lo metas en la clase por inercia.- Para clases que solo transportan datos, evalúa
@dataclassantes de escribir veinte líneas.
Ejercicios
Cuenta con saldo protegido
CuentaBancaria(titular, saldo) donde saldo sea una propiedad con setter que rechace valores negativos con ValueError. Añade depositar(monto) y retirar(monto), que también validan montos positivos y saldo suficiente. Implementa __repr__ con el titular y el saldo. Prueba una secuencia de depósito, retiro y una asignación inválida.Pista
Dentro del setter guarda en self._saldo. En los métodos usa self.saldo += monto: eso lee el getter y escribe por el setter, así que la validación se aplica sola.
Inventario con len, suma y constructor alternativo
Inventario a partir de un diccionario producto → unidades. Implementa __len__ para que devuelva el total de unidades (no el número de productos), __add__ para combinar dos inventarios sumando cantidades, __eq__ por contenido y __repr__. Añade un @classmethod desde_lineas(lineas) que lea "pan,3" y un @staticmethod es_cantidad_valida(cantidad). Comprueba el resultado con dos inventarios.Pista
En __add__ copia el diccionario con dict(self.unidades) y recorre el otro con .items(). sum(self.unidades.values()) da el total de unidades.
class Termometro: def __init__(self, celsius): self._celsius = celsius @ def celsius(self): return self._celsius @celsius. def celsius(self, valor): self._celsius = valor t = Termometro(20) t.celsius = 25 print(t.celsius)
El primer decorador convierte el método en atributo; el segundo registra la escritura sobre ese mismo nombre.
Soluciones explicadas
En la cuenta bancaria, la propiedad no es un adorno: centraliza la única regla que no puede violarse. Si un compañero añade transferir dentro de seis meses, basta con escribir self.saldo -= monto para heredar la validación. La alternativa —comprobar el saldo en cada método— funciona hasta que alguien olvida una comprobación.
En el inventario hay una decisión que el código no puede tomar por ti: qué significa len. Devolver el número de productos distintos y devolver el total de unidades son ambas opciones válidas; lo importante es elegir una, nombrarla en el docstring y ser consistente. __add__ devuelve un objeto nuevo para que a + b no cambie a, igual que 1 + 2 no cambia el 1.
Una alternativa al inventario es collections.Counter, que ya suma diccionarios de cantidades. Y si la clase solo guardara el diccionario sin reglas, una dataclass con un campo unidades sería suficiente. Los métodos especiales valen cuando aportan una semántica clara al objeto, no cuando imitan a un contenedor existente.
Resumen
- Los métodos especiales conectan tu clase con
print,len,==,+y el resto de la sintaxis. __repr__es para quien programa y debe ser preciso;__str__es para quien usa el programa.- Si solo defines
__repr__,str()lo reutiliza; al revés no ocurre. __eq__compara por valor, comprueba el tipo y devuelveNotImplementedsi no lo reconoce.- Definir
__eq__anula__hash__: añádelo si usarás el objeto enseto como clave. @propertyexpone un atributo calculado o validado; el setter se registra con@nombre.setter.@classmethodrecibeclsy sirve como constructor alternativo;@staticmethodes una función con espacio de nombres.@dataclassgenera__init__,__repr__y__eq__cuando la clase solo agrupa datos.
Evaluación
¿Qué imprime print([Punto(1, 2)]) si la clase define __str__ como "(1, 2)" y __repr__ como "Punto(1, 2)"?
¿Por qué __eq__ debería devolver NotImplemented en lugar de False cuando el otro objeto no es del tipo esperado?
Tienes @property def saldo(self) sin setter. ¿Qué ocurre al ejecutar cuenta.saldo = 100?
¿Ya lo entendiste?
Márcala como completada para llevar tu progreso y desbloquear el siguiente paso de la ruta.