Desde 05-01 arrastramos una deuda: print(odisea) muestra <__main__.Libro object at 0x...>. Y hay más carencias silenciosas: dos objetos Libro con el mismo título no son iguales para ==, sorted() no sabe ordenarlos sin key=, y len() los rechaza. Los tipos integrados hacen todo eso con naturalidad porque implementan métodos mágicos (o dunder methods, por double underscore): métodos con nombre __asi__ que Python invoca automáticamente cuando usas operadores, print, len, in, sorted... En esta lección tus clases aprenderán ese idioma, con un vehículo nuevo del hilo de Papyrus: la clase Carrito, la cesta de la compra de la tienda de Ana.

Contenido

  1. Qué es un método mágico (y uno que ya conoces)
  2. __str__ vs __repr__: saldar el print feo
  3. __eq__: cuándo dos libros son el mismo libro
  4. __lt__: ordenar con sorted() sin key=
  5. La clase Carrito de Papyrus
  6. __len__, __contains__ y __add__ en el carrito
  7. Tabla de los dunder más usados
  8. No abusar de la magia
  9. Errores comunes y consejos
  10. Ejercicios con soluciones

Qué es un método mágico (y uno que ya conoces)

Un método mágico es un método con doble guion bajo al principio y al final que tú defines pero no llamas: lo llama Python al usar cierta sintaxis. Ya conoces uno: __init__, que se ejecuta solo al escribir Libro(...). La tabla mental es siempre la misma:

Tú escribes... Python ejecuta...
Libro("Hamlet", 9.95) Libro.__init__(objeto, "Hamlet", 9.95)
print(libro) / str(libro) libro.__str__()
libro_a == libro_b libro_a.__eq__(libro_b)
libro_a < libro_b libro_a.__lt__(libro_b)
len(carrito) carrito.__len__()
libro in carrito carrito.__contains__(libro)
carrito_a + carrito_b carrito_a.__add__(carrito_b)

Aquí está el mecanismo que 05-03 dejó anunciado: len() es polimórfica porque cada tipo trae su __len__. Los operadores de Python son, en el fondo, mensajes polimórficos.

__str__ vs __repr__: saldar el print feo

Python distingue dos representaciones en texto de un objeto:

  • __str__: para personas. La usa print(), str() y las f-strings. Debe ser legible y agradable.
  • __repr__: para programadores. La usa la consola interactiva, los mensajes de depuración y las colecciones (print(lista_de_libros) usa el __repr__ de cada elemento). La convención de oro: que parezca el código necesario para recrear el objeto.
class Libro:
    IVA_LIBROS = 0.04
    DESCUENTO_SOCIO = 0.05

    def __init__(self, titulo, precio, stock=0):
        self.titulo = titulo
        self.precio = precio
        self.stock = stock

    def precio_final(self, socio=False):
        descuento = Libro.DESCUENTO_SOCIO if socio else 0
        return round(self.precio * (1 - descuento) * (1 + Libro.IVA_LIBROS), 2)

    def __str__(self):
        return f"{self.titulo} ({self.precio:.2f} €, stock: {self.stock})"

    def __repr__(self):
        return f"Libro({self.titulo!r}, {self.precio}, {self.stock})"
odisea = Libro("La Odisea", 12.50, 4)

print(odisea)              # La Odisea (12.50 €, stock: 4)          ← __str__
print(f"Nuevo: {odisea}")  # Nuevo: La Odisea (12.50 €, stock: 4)   ← __str__
print([odisea])            # [Libro('La Odisea', 12.5, 4)]          ← __repr__
odisea                     # Libro('La Odisea', 12.5, 4)  (en la consola interactiva)

Gancho de 05-01 saldado. Dos apuntes: el !r dentro de la f-string pide el repr del valor (por eso el título sale con comillas), y si solo defines __repr__, print() lo usará como respaldo — por eso, si solo vas a escribir uno, que sea __repr__.

__eq__: cuándo dos libros son el mismo libro

Sin __eq__, == compara identidad (¿son el mismo objeto en memoria?), no contenido:

a = Libro("Hamlet", 9.95, 6)
b = Libro("Hamlet", 9.95, 6)
print(a == b)   # False (!) — objetos distintos, aunque idénticos

Para Papyrus decidimos una regla de negocio: dos libros son el mismo si su título normalizado coincide — la misma normalización strip().casefold() que buscar_libro() usa desde 04-05:

    def __eq__(self, otro):
        if not isinstance(otro, Libro):
            return NotImplemented
        return self.titulo.strip().casefold() == otro.titulo.strip().casefold()
  • NotImplemented (sin lanzar error) le dice a Python "yo no sé compararme con eso": así odisea == 42 devuelve False limpiamente en lugar de romperse, y Python puede darle la oportunidad al otro operando. Es uno de los usos legítimos de isinstance (05-03): comprobar antes de comparar, no decidir comportamiento.
  • Con __eq__ definido, != funciona solo (Python lo deriva).
print(Libro("Hamlet", 9.95) == Libro("  hamlet ", 12.00))   # True: mismo título normalizado

Un efecto colateral que debes conocer: al definir __eq__, Python desactiva el hash por defecto y el objeto deja de poder guardarse en sets o como clave de diccionario (04-04). Es deliberado: igualdad y hash deben ser coherentes. La solución (definir __hash__) queda anotada en la tabla final; la dataclass de la próxima lección lo gestionará por nosotros.

__lt__: ordenar con sorted() sin key=

sorted() solo necesita saber si un elemento es "menor que" otro: exactamente lo que expresa __lt__ (less than). Definimos el orden natural de los libros de Papyrus como el alfabético por título normalizado:

    def __lt__(self, otro):
        if not isinstance(otro, Libro):
            return NotImplemented
        return self.titulo.casefold() < otro.titulo.casefold()
estanteria = [Libro("Hamlet", 9.95, 6), Libro("Fausto", 21.00, 0),
              Libro("La Odisea", 12.50, 4), Libro("El Quijote", 15.90, 8)]

for libro in sorted(estanteria):        # ¡sin key=!
    print(libro)
El Quijote (15.90 €, stock: 8)
Fausto (21.00 €, stock: 0)
Hamlet (9.95 €, stock: 6)
La Odisea (12.50 €, stock: 4)

min(), max() y list.sort() también funcionan ya sin key=. Criterio de diseño: define __lt__ solo si tu clase tiene un orden natural evidente. Si unas veces ordenas por precio y otras por stock, no hay orden natural: sigue usando key= con lambdas (03-03), que para eso está.

La clase Carrito de Papyrus

Los clientes de Ana llenan cestas, y una cesta pide a gritos comportarse como colección: tener longitud, poder preguntarle "¿está este libro?", sumarse con otra. Es composición (05-02): el carrito tiene libros.

class Carrito:
    """La cesta de la compra de un cliente de Papyrus."""

    def __init__(self, cliente, libros=None):
        self.cliente = cliente
        self._libros = list(libros) if libros else []   # copia defensiva (04-01)

    def agregar(self, libro):
        self._libros.append(libro)

    def total(self, socio=False):
        return round(sum(libro.precio_final(socio) for libro in self._libros), 2)

    def __len__(self):
        return len(self._libros)

    def __contains__(self, buscado):
        return any(libro == buscado for libro in self._libros)

    def __add__(self, otro):
        if not isinstance(otro, Carrito):
            return NotImplemented
        nombre = f"{self.cliente} y {otro.cliente}"
        return Carrito(nombre, self._libros + otro._libros)   # ¡carrito NUEVO!

    def __str__(self):
        return f"Carrito de {self.cliente}: {len(self)} artículos, {self.total():.2f} €"

    def __repr__(self):
        return f"Carrito({self.cliente!r}, {self._libros!r})"

__len__, __contains__ y __add__ en el carrito

odisea = Libro("La Odisea", 12.50, 4)
hamlet = Libro("Hamlet", 9.95, 6)
quijote = Libro("El Quijote", 15.90, 8)

carrito_julia = Carrito("Júlia", [odisea, hamlet])
carrito_omar = Carrito("Omar", [quijote])

print(len(carrito_julia))              # 2      → __len__
print(hamlet in carrito_julia)         # True   → __contains__
print(quijote in carrito_julia)        # False

pedido_conjunto = carrito_julia + carrito_omar     # __add__
print(pedido_conjunto)                 # Carrito de Júlia y Omar: 3 artículos, 39.89 €
print(len(carrito_julia))              # 2 → los originales quedan intactos

Tres decisiones de diseño que conviene subrayar:

  • __contains__ reutiliza __eq__: como Libro compara por título normalizado, Libro(" hamlet ", 0) in carrito_julia da True. Los dunder se apoyan unos en otros — coherencia gratis.
  • __add__ devuelve un carrito NUEVO y no modifica los operandos, igual que [1, 2] + [3] crea una lista nueva. Un + que mutara sus operandos sería una trampa para quien lo use.
  • __len__ regala truthiness (02-01): un carrito con __len__ a 0 es falso en un if, así que if carrito: significa "si tiene algo" — exactamente como listas y diccionarios.
  • ¿Y sumar carritos tenía sentido? Aquí sí (Júlia y Omar comparten pedido para ahorrar envío): el + tiene un significado evidente en el dominio. Esa es la vara de medir, como verás enseguida.

Tabla de los dunder más usados

Método Lo activa Debe devolver En Papyrus
__init__ Clase(...) None Inicializar libro/carrito
__str__ print(), str(), f-strings str legible "La Odisea (12.50 €, stock: 4)"
__repr__ Consola, depuración, colecciones str recreable "Libro('La Odisea', 12.5, 4)"
__eq__ ==, !=, in (vía contains por defecto) bool o NotImplemented Mismo título normalizado
__lt__ <, sorted(), min(), max() bool o NotImplemented Orden alfabético
__hash__ sets, claves de dict int coherente con __eq__ Necesario si defines __eq__ y quieres sets
__len__ len(), truthiness int ≥ 0 Artículos del carrito
__contains__ x in obj bool ¿Libro en el carrito?
__add__ + Objeto nuevo o NotImplemented Fusionar carritos
__getitem__ obj[clave], slicing El elemento (No lo necesitamos... aún)
__iter__ for x in obj Un iterador Llegará con los generadores (M8)

No abusar de la magia

Los métodos mágicos son azúcar potentísimo, y por eso mismo peligroso. La regla: implementa un dunder solo si la operación tiene un significado obvio e indiscutible en tu dominio.

  • ¿carrito_a + carrito_b? Claro: juntar pedidos. ✔
  • ¿libro_a + libro_b? ¿Qué sería eso: un tomo doble, sumar precios, concatenar títulos? Ambiguo → método explícito con nombre (pack_con(otro) o similar). ✘
  • ¿carrito - libro? Discutible; carrito.quitar(libro) se entiende sin abrir la documentación. ✘
  • ¿libro * 3? No: libro.precio_final() * 3 o un método subtotal(unidades=3). ✘

Quien lee a + b no puede consultar el manual de cada + del programa. Si dudas entre un operador y un método con nombre, gana el nombre. Los dunder de representación (__str__, __repr__) son la excepción: defínelos casi siempre, nunca estorban.

Errores Comunes y Consejos

  • Llamar a los dunder directamente (libro.__str__(), carrito.__len__()): funciona, pero es estilo pobre. Escribe str(libro) y len(carrito); los dunder son para definir, no para llamar.
  • Devolver algo que no es cadena en __str__/__repr__: TypeError: __str__ returned non-string. Devuelve siempre str, no hagas print dentro.
  • Lanzar error en __eq__ ante tipos ajenos en lugar de devolver NotImplemented: rompe cosas tan básicas como libro in lista_mixta. Compara con isinstance y delega con NotImplemented.
  • Definir __eq__ y olvidar el efecto sobre __hash__: tus objetos dejarán de entrar en sets y dicts (TypeError: unhashable type). Si los necesitas ahí, define __hash__ sobre los mismos datos que la igualdad: def __hash__(self): return hash(self.titulo.strip().casefold()).
  • Mutar self dentro de __add__: + debe crear un objeto nuevo. Si quieres mutar, el operador es += (__iadd__) o, mejor al empezar, un método agregar().
  • __str__ que oculta información en depuración: para inspeccionar usa repr() (o !r en f-strings), que para eso existe la pareja.
  • Consejo: el orden de escritura sano en una clase nueva es: __init____repr____str__ → y solo después, los operadores que el dominio pida a gritos.

Ejercicios

Ejercicio 1: Socio presentable y comparable

Da a la clase Socio (nombre, codigo) un __repr__ canónico, un __str__ tipo "Luis (LUIS-001)" y un __eq__ que considere iguales dos socios con el mismo codigo (da igual el nombre). Comprueba que Socio("Luis", "LUIS-001") == Socio("Luís", "LUIS-001") es True.

Ejercicio 2: la biblioteca medible y ordenada

Crea la clase Biblioteca que contenga objetos Libro (composición) con: __len__ (número de ejemplares totales, sumando stocks — ojo, no número de títulos), __contains__ (por igualdad de libros) y __str__ ("Biblioteca Papyrus: 18 ejemplares de 4 títulos"). Pruébala con el catálogo canónico (stocks 4, 6, 8, 0).

Ejercicio 3: sumar una lista de carritos

Al cierre del día, Ana quiere fusionar todos los carritos pendientes con sum(lista_de_carritos). Tal cual, falla: sum empieza sumando 0 + primer_carrito, y ni int ni nuestro __add__ saben resolver eso. Implementa en Carrito el método mágico __radd__ (suma reflejada) mínimo para que funcione: debe aceptar el caso 0 + carrito devolviendo el propio carrito, y delegar con NotImplemented en cualquier otro.

Soluciones

Solución 1:

class Socio:
    def __init__(self, nombre, codigo):
        self.nombre = nombre
        self.codigo = codigo

    def __repr__(self):
        return f"Socio({self.nombre!r}, {self.codigo!r})"

    def __str__(self):
        return f"{self.nombre} ({self.codigo})"

    def __eq__(self, otro):
        if not isinstance(otro, Socio):
            return NotImplemented
        return self.codigo == otro.codigo

print(Socio("Luis", "LUIS-001") == Socio("Luís", "LUIS-001"))   # True

El código de socio es el identificador de negocio; el nombre es solo presentación. Decidir qué significa la igualdad es diseño, no sintaxis.

Solución 2:

class Biblioteca:
    def __init__(self, nombre, libros=None):
        self.nombre = nombre
        self._libros = list(libros) if libros else []

    def __len__(self):
        return sum(libro.stock for libro in self._libros)

    def __contains__(self, buscado):
        return any(libro == buscado for libro in self._libros)

    def __str__(self):
        return f"Biblioteca {self.nombre}: {len(self)} ejemplares de {len(self._libros)} títulos"

papyrus = Biblioteca("Papyrus", [
    Libro("La Odisea", 12.50, 4), Libro("Hamlet", 9.95, 6),
    Libro("El Quijote", 15.90, 8), Libro("Fausto", 21.00, 0),
])
print(papyrus)                                   # Biblioteca Papyrus: 18 ejemplares de 4 títulos
print(Libro("fausto", 0) in papyrus)             # True (igualdad por título normalizado)

Fíjate en que len() responde una pregunta de negocio (ejemplares), no la trivial (títulos): tú decides la semántica, pero documéntala, porque quien lea len(papyrus) asumirá algo.

Solución 3:

class Carrito:
    # ... todo lo anterior ...

    def __radd__(self, otro):
        if otro == 0:                # sum() arranca con 0: 0 + primer_carrito
            return self
        return NotImplemented

total = sum([carrito_julia, carrito_omar])
print(total)   # Carrito de Júlia y Omar: 3 artículos, 39.89 €

__radd__ (suma reflejada) se invoca cuando el operando izquierdo no sabe sumar con nosotros: 0 + carrito falla en int.__add__ y Python prueba carrito.__radd__(0). Devolver self en ese caso hace que sum() encadene después los __add__ normales.

Conclusión

Deuda saldada: print(odisea) ya habla claro gracias a __str__, con __repr__ cubriendo la depuración; __eq__ fijó la regla de negocio de Papyrus (mismo título normalizado, mismo libro) devolviendo NotImplemented ante extraños; __lt__ dio a sorted() el orden alfabético sin key=; y el Carrito se comporta como una colección de pleno derecho con __len__, __contains__ y un __add__ que crea carritos nuevos sin mutar los originales. También aprendiste el freno: solo operadores con significado indiscutible, y nombres explícitos para todo lo demás. Pero seamos honestos con el precio pagado: entre __init__, __repr__, __eq__ y __lt__, la clase Libro acumula ya un montón de código repetitivo que solo recita sus atributos una y otra vez. Python lo sabe, y trae la solución de serie: las dataclasses, que generan toda esa maquinaria automáticamente en una línea. Con ellas cerraremos el módulo — y dejaremos el catálogo de Papyrus en su forma definitiva.

Curso de Programación en Python

Módulo 1: Introducción a Python

Módulo 2: Estructuras de Control

Módulo 3: Funciones y Módulos

Módulo 4: Estructuras de Datos

Módulo 5: Programación Orientada a Objetos

Módulo 6: Manejo de Archivos

Módulo 7: Manejo de Errores y Excepciones

Módulo 8: Temas Avanzados

Módulo 9: Pruebas y Depuración

Módulo 10: Desarrollo Web con Python

Módulo 11: Ciencia de Datos con Python

Módulo 12: Proyecto Final

© Copyright 2026. Todos los derechos reservados