Cuando escribes MiClase(), Python ya tiene un objeto listo antes de que tu código corra una sola línea. __init__ no crea nada: recibe un objeto ya construido por __new__ y lo inicializa, asignando los atributos que definen su estado inicial. Esa distinción importa más de lo que parece, porque significa que __init__ es un método normal con self perfectamente válido desde la primera instrucción.
Los otros dos, __repr__ y __str__, controlan cómo se ve ese objeto en distintos contextos. __repr__ es la representación técnica, orientada a quien está depurando o inspeccionando el sistema. El contrato no escrito (pero muy real) es que eval(repr(obj)) == obj siempre que sea posible: si puedes hacer que eso se cumpla, la representación es perfecta. __str__ es la representación legible para humanos, la que verías en una interfaz de usuario o en un log de negocio.
La distinción se vuelve concreta cuando entiendes qué llama a qué. print(obj) invoca str(), que busca __str__ primero y cae a __repr__ si no existe. El REPL, en cambio, llama directamente a repr(). Eso explica por qué puedes implementar solo __repr__ y funcionar razonablemente bien en ambos contextos, pero si implementas solo __str__, el REPL te mostrará <__main__.MiClase object at 0x7f...>, que es inútil.
Por eso __repr__ es el mínimo obligatorio en cualquier clase no trivial. Sin él, la depuración se convierte en arqueología.
from __future__ import annotations
from dataclasses import dataclass
from decimal import Decimal
@dataclass(eq=True, order=False, repr=False) # desactivamos el repr autogenerado
class Money:
amount: Decimal
currency: str
def __init__(self, amount: str | int | Decimal, currency: str) -> None:
# Normalizamos a Decimal aquí, no en __new__.
# El objeto ya existe; solo estamos dando forma a su estado.
self.amount = Decimal(str(amount))
self.currency = currency.upper()
def __repr__(self) -> str:
# eval(repr(obj)) == obj si el caller tiene Decimal y Money en scope.
# Usamos la cadena de amount para evitar notación científica.
return f"Money('{self.amount}', '{self.currency}')"
def __str__(self) -> str:
# Para el usuario final: legible, no reconstruible.
symbols = {"USD": "$", "EUR": "€", "GBP": "£"}
symbol = symbols.get(self.currency, self.currency + " ")
return f"{symbol}{self.amount:,.2f}"
def __add__(self, other: Money) -> Money:
if self.currency != other.currency:
raise ValueError(
f"Cannot add {self.currency} and {other.currency}"
)
return Money(self.amount + other.amount, self.currency)
# --- Ejercicio en el REPL / script ---
price = Money("19.99", "usd")
tax = Money("1.60", "USD")
total = price + tax
# El REPL llama repr() directamente:
print(repr(total)) # Money('21.59', 'USD')
# print() llama str(), que encuentra __str__:
print(total) # $21.59
# Si solo hubiera __repr__, print() también lo usaría como fallback:
class Bare:
def __repr__(self) -> str:
return "Bare()"
b = Bare()
print(b) # Bare() ← __repr__ sirve de respaldo para str()
print(repr(b)) # Bare()
Qué está pasando aquí
__init__ recibe amount como str | int | Decimal y lo normaliza a Decimal. Este es el lugar correcto para esa lógica: el objeto ya existe en memoria, y estamos definiendo qué significa ser un Money válido. Hacerlo en __new__ sería raro y solo estaría justificado si necesitaras retornar una instancia de una subclase diferente según los argumentos.
__repr__ produce Money('21.59', 'USD'). Fíjate que los argumentos son strings, no Decimal(...) directamente. Eso es intencional: el __init__ acepta strings, así que la representación sigue siendo eval-able sin importar Decimal. Si hubieras escrito Money(Decimal('21.59'), 'USD'), necesitarías que Decimal estuviera en el scope del eval, lo que es una dependencia adicional. Elige el formato más simple que mantenga el contrato.
__str__ no intenta ser reconstruible. Su trabajo es communication, no fidelidad técnica. El símbolo de moneda, los separadores de miles, los dos decimales fijos: todo está pensado para quien lee el output final, no para quien depura el sistema.
La clase Bare al final ilustra el mecanismo de fallback sin distracciones: cuando str() no encuentra __str__, sube la cadena de búsqueda hasta __repr__. Ese fallback es la razón por la que implementar solo __repr__ te cubre el 90% de los casos.
Errores que debes conocer
Error: devolver algo que no es str desde __repr__ o __str__. Python lanza TypeError en tiempo de ejecución, no en la definición, así que el bug aparece en el momento más inoportuno.
# ❌ Wrong
class Point:
def __repr__(self):
return (self.x, self.y) # tuple, no str
# ✅ Right
class Point:
def __repr__(self):
return f"Point({self.x}, {self.y})"
El type hint de retorno es -> str por una razón: ambos métodos deben devolver exactamente un string.
Error: usar __str__ para logging técnico y luego sorprenderse de que el REPL sea ilegible. Si defines __str__ pero no __repr__, repr(obj) cae al default del intérprete (<MiClase object at 0x...>), que no ayuda en ningún debugging.
# ❌ Wrong
class Config:
def __str__(self):
return f"Config(host={self.host})"
# sin __repr__ → el REPL muestra <Config object at 0x7f3c...>
# ✅ Right
class Config:
def __repr__(self):
return f"Config(host={self.host!r}, port={self.port!r})"
def __str__(self):
return f"{self.host}:{self.port}"
__repr__ primero, siempre. __str__ es el refinamiento opcional para el output de usuario.
Error: olvidar que f"{obj}" usa __str__ (vía str()), no __repr__. Si quieres la representación técnica dentro de un f-string, necesitas el modificador !r.
# ❌ Wrong — asumes que el f-string llama repr()
msg = f"Guardando objeto: {config}" # llama __str__
# ✅ Right — explícito sobre cuál representación quieres
msg = f"Guardando objeto: {config!r}" # llama __repr__
!r equivale a repr(config) dentro del f-string, y es exactamente la herramienta para mezclar representaciones técnicas en mensajes de log sin invocar repr() explícitamente.
Control Block:
N° 88