AulaML Python · C++
Python intermedio · 38 min · Programación orientada a objetos

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.

Lección 3 de 4 del módulo
Al terminar esta lección podrás
  • 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
  1. ¿Qué son los métodos especiales?
  2. ¿Para qué sirven?
  3. ¿Cómo funciona por dentro?
  4. Sintaxis
  5. Ejemplo básico: un objeto que se imprime bien
  6. Ejemplo intermedio: comparar, sumar y medir
  7. Caso práctico: cargar empleados validados
  8. Visualización: una propiedad con validación
  9. Errores comunes
  10. Buenas prácticas
  11. Ejercicios
  12. Soluciones explicadas
  13. Resumen
  14. 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étodoSe ejecuta cuando...Debe devolver
__init__creas una instanciaNone
__str__usas print(obj) o str(obj)un str legible
__repr__miras el objeto en el REPL o dentro de una listaun 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 @property con 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

PYTHON Los protocolos más frecuentes en una sola clase
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)
FormaQué haceCuándo usarla
@propertyConvierte un método en atributo de lecturaValores calculados o protegidos
@x.setterRegistra la validación al asignarSiempre que el atributo tenga reglas
@classmethodRecibe cls; puede crear instanciasConstructores alternativos
@staticmethodNo recibe self ni clsFunciones puras relacionadas con la clase
@dataclassGenera __init__, __repr__ y __eq__Clases que solo agrupan datos
Imprimir y comparar un objeto
Python
PYTHON temperatura.py
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))
C++
CPP temperatura.cpp
#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.

PYTHON punto.py
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.

PYTHON pedidos.py
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.

PYTHON empleados.py
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:

PYTHON punto_dataclass.py
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.

Ejecución visual ·Getter, setter y las dos representaciones
1 / 8
1 class Temperatura:
2 def __init__(self, celsius):
3 self._celsius = celsius
4
5 @property
6 def celsius(self):
7 return self._celsius
8
9 @celsius.setter
10 def celsius(self, valor):
11 if valor < -273.15:
12 raise ValueError("por debajo del cero absoluto")
13 self._celsius = valor
14
15 def __str__(self):
16 return f"{self._celsius} °C"
17
18 def __repr__(self):
19 return f"Temperatura({self._celsius})"
20
21 t = Temperatura(20)
22 print(t)
23 t.celsius = 25
24 print(t.celsius)
25 print(repr(t))
26
27 try:
28 t.celsius = -300
29 except ValueError as error:
30 print("Error:", error)
Se define la clase. celsius queda registrado como propiedad, no como un método normal: el decorador @property reemplaza el nombre por un descriptor.
Variables
Temperatura = <class 'Temperatura'>
Salida
—

Avanza paso a paso: observa la línea resaltada, las variables y la salida.

Errores comunes

MensajeCausa habitualCómo se arregla
AttributeError: can't set attributeLa propiedad tiene getter pero no setterAñade @nombre.setter o no asignes a ese atributo
TypeError: __str__ returned non-string (type int)__str__ devuelve un número en lugar de textoEnvuelve el valor en str(...) o en una f-string
RecursionError: maximum recursion depth exceededEl 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 tipoImplementa __add__ y devuelve un objeto nuevo
TypeError: unhashable type: 'Punto'Definiste __eq__ y Python anuló __hash__Define __hash__ o usa @dataclass(frozen=True)
El setter que se llama a sí mismo
Incorrecto
PYTHON Recursión infinita
class Caja:
    @property
    def peso(self):
        return self.peso

    @peso.setter
    def peso(self, valor):
        self.peso = valor

c = Caja()
c.peso = 10
Correcto
PYTHON Atributo interno distinto
class 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 con isinstance y devuelve NotImplemented si no lo reconoces.
  • Si defines __eq__ y vas a usar el objeto en set o 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.
  • @classmethod para construir, @staticmethod para funciones puras afines; si no usas cls ni self, no lo metas en la clase por inercia.
  • Para clases que solo transportan datos, evalúa @dataclass antes de escribir veinte líneas.

Ejercicios

Ejercicio 1

Cuenta con saldo protegido

Crea 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.

Ejercicio 2

Inventario con len, suma y constructor alternativo

Diseña 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.

Completar código Completa la propiedad
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 devuelve NotImplemented si no lo reconoce.
  • Definir __eq__ anula __hash__: añádelo si usarás el objeto en set o como clave.
  • @property expone un atributo calculado o validado; el setter se registra con @nombre.setter.
  • @classmethod recibe cls y sirve como constructor alternativo; @staticmethod es una función con espacio de nombres.
  • @dataclass genera __init__, __repr__ y __eq__ cuando la clase solo agrupa datos.

Evaluación

Evaluación 1

¿Qué imprime print([Punto(1, 2)]) si la clase define __str__ como "(1, 2)" y __repr__ como "Punto(1, 2)"?

Evaluación 2

¿Por qué __eq__ debería devolver NotImplemented en lugar de False cuando el otro objeto no es del tipo esperado?

Evaluación 3

Tienes @property def saldo(self) sin setter. ¿Qué ocurre al ejecutar cuenta.saldo = 100?

Tus notas

¿Ya lo entendiste?

Márcala como completada para llevar tu progreso y desbloquear el siguiente paso de la ruta.