Diez partes de esta serie enseñaron métodos sueltos. Esta es la idea que hay debajo de todos ellos, y cabe en una sola frase:
La sintaxis de Python es un conjunto de preguntas, y los métodos dunder son la forma en que tu objeto responde.
len(x) no es una función que inspecciona x. Es Python haciéndole una pregunta a x —x.__len__()— y reportando la respuesta. Lo mismo con a + b, for i in x, with f:, x[0], if x:, str(x), x(). Ninguna de ellas trata a los tipos incorporados como un caso especial. list responde las mismas preguntas que puede responder tu clase.
Por eso una clase bien escrita en Python deja de sentirse pegada con cinta. No está imitando a un tipo incorporado. Está respondiendo las mismas preguntas.
Todo lo de abajo corre en Python 3.12 y la salida está pegada de una sesión real.
Viendo preguntar a Python
class Probe:
def __len__(self):
print(' Python asked __len__')
return 3
print(len(Probe()), bool(Probe()))
Python asked __len__
Python asked __len__
3 True
Se hicieron dos preguntas. len() preguntó directamente. bool() preguntó porque no había __bool__ y cae de vuelta a __len__: una longitud distinta de cero significa verdadero.
Esa cadena de respaldo es lo segundo que vale la pena saber del modelo de datos: casi toda pregunta tiene un respaldo. Python hace la pregunta específica, después una más general, y después aplica un valor por defecto.
Crear y destruir
__new__ reserva. __init__ llena. Vas a escribir __init__ todo el tiempo y __new__ casi nunca: importa para los tipos inmutables, donde no existe un «después de reservar» en el que asignar nada.
Sáltate __del__. Corre en un momento impredecible, puede saltarse por completo al salir del intérprete, y se traga las excepciones. La limpieza va en un context manager, más abajo.
Representación: tres preguntas, no dos
La parte 3 cubrió __repr__ y __str__. Hay una tercera, y es la que hace que tu objeto funcione con f-strings:
class Temp:
def __init__(self, c): self.c = c
def __str__(self): return f"{self.c}C"
def __format__(self, spec):
if spec == 'f': return f"{self.c * 9/5 + 32:.1f}F"
if spec == 'k': return f"{self.c + 273.15:.2f}K"
if not spec: return str(self)
return format(self.c, spec)
t = Temp(100)
print(f"{t} | {t:f} | {t:k} | {t:>8.1f}")
100C | 212.0F | 373.15K | 100.0
f"{t:f}" le pasa la cadena "f" a __format__ y tu objeto decide qué significa. Así es exactamente como datetime hace funcionar %Y: no es un caso especial de las f-strings, es datetime.__format__ eligiendo pasarle la especificación a strftime.
La última rama delega al número subyacente todo lo que no reconoce, y ese es el patrón de delegación que vale la pena copiar: interpreta las especificaciones que te importan y pasa el resto hacia abajo.
Comparación, y la que no puedes olvidar
from functools import total_ordering
@total_ordering
class Version:
def __init__(self, major, minor): self.major, self.minor = major, minor
def __repr__(self): return f"Version({self.major}, {self.minor})"
def __eq__(self, o): return (self.major, self.minor) == (o.major, o.minor)
def __lt__(self, o): return (self.major, self.minor) < (o.major, o.minor)
v1, v2 = Version(1, 2), Version(1, 10)
print(v1 < v2, v1 >= v2, v1 != v2)
print(sorted([Version(2,0), Version(1,10), Version(1,2)]))
True False True
[Version(1, 2), Version(1, 10), Version(2, 0)]
@total_ordering completa <=, > y >= a partir de __eq__ y __lt__. Fíjate que Version(1, 2) < Version(1, 10) da True: se comparan tuplas, no cadenas, y por eso ordenar versiones como texto pone 1.10 antes que 1.2.
!= salió gratis: Python lo deriva de __eq__ salvo que lo sobrescribas. No lo hagas.
Y la regla de la parte 7 que atrapa a todo el mundo: define __eq__ y tienes que definir __hash__, o tu objeto deja de poder usarse como clave.
Acceso a atributos
Esta es la capa que casi nadie toca, y explica @property.
class Loud:
def __getattr__(self, name):
return f"(no attribute {name!r}, and this ran instead)"
l = Loud()
l.real = 1
print(l.real)
print(l.missing)
1
(no attribute 'missing', and this ran instead)
__getattr__ es el respaldo: corre solo cuando la búsqueda normal falla. Eso lo hace barato y seguro para proxies y carga perezosa.
__getattribute__ es distinto y corre en cada acceso:
class Watch:
def __init__(self): self.x = 1
def __getattribute__(self, name):
if not name.startswith('_'):
print(f' looked up {name!r}')
return object.__getattribute__(self, name)
w = Watch()
_ = w.x
looked up 'x'
Fíjate que llama a object.__getattribute__ en lugar de a self.x, que se llamaría a sí mismo para siempre. La misma trampa aplica a __setattr__:
class Frozen:
def __init__(self, x):
object.__setattr__(self, 'x', x) # bypass our own __setattr__
def __setattr__(self, name, value):
raise AttributeError(f"{type(self).__name__} is read-only")
f = Frozen(1)
print(f.x)
try:
f.x = 2
except AttributeError as err:
print(type(err).__name__ + ':', err)
1
AttributeError: Frozen is read-only
__init__ tiene que esquivar su propio __setattr__ para poder asignar algo. Así es más o menos como funciona @dataclass(frozen=True).
Descriptores: qué es en realidad @property
Un descriptor es un objeto que define __get__ o __set__, y controla qué pasa cuando se accede a él como atributo de clase. @property es uno. También lo son los métodos, classmethod y staticmethod.
Escribir uno directamente vale la pena cuando la misma validación se repite en varios campos:
class Positive:
def __set_name__(self, owner, name):
self.name = '_' + name
def __get__(self, obj, objtype=None):
if obj is None: return self
return getattr(obj, self.name)
def __set__(self, obj, value):
if value <= 0:
raise ValueError(f"{self.name.lstrip('_')} must be positive, got {value}")
setattr(obj, self.name, value)
class Product:
price = Positive()
weight = Positive()
def __init__(self, price, weight):
self.price, self.weight = price, weight
p = Product(10, 2)
print(p.price, p.weight)
try:
Product(-1, 2)
except ValueError as err:
print(type(err).__name__ + ':', err)
10 2
ValueError: price must be positive, got -1
Dos campos, un validador, y el error nombra el campo correcto. __set_name__ es la forma en que el descriptor aprende cómo lo llamaron: Python le pasa el nombre del atributo al crear la clase.
Como dos pares de @property esto son doce líneas de código casi idéntico. Con cinco campos son treinta.
Iteración
class Countdown:
def __init__(self, n): self.n = n
def __iter__(self):
current = self.n
while current > 0:
yield current
current -= 1
print(list(Countdown(4)))
a, b, *rest = Countdown(5)
print(a, b, rest)
[4, 3, 2, 1]
5 4 [3, 2, 1]
__iter__ escrito como generador es la forma correcta más corta de hacer algo iterable: sin clase iteradora aparte, sin __next__, sin StopIteration que lanzar a mano.
Como construye un generador nuevo en cada llamada, el objeto se puede recorrer más de una vez. Devuelve self desde __iter__ y se agota tras una sola pasada, que es un error común y confuso.
El desempaquetado funciona gratis. También list(), sum(), max(), in, y toda comprensión.
Operadores, y el par reflejado
class Metres:
def __init__(self, v): self.v = v
def __repr__(self): return f"Metres({self.v})"
def __add__(self, other):
if isinstance(other, Metres): return Metres(self.v + other.v)
return NotImplemented
def __radd__(self, other):
if other == 0: return self # makes sum() work
return NotImplemented
print(Metres(1) + Metres(2))
print(sum([Metres(1), Metres(2), Metres(3)]))
Metres(3)
Metres(6)
Para a + b, Python le pregunta primero a a.__add__(b). Si eso devuelve NotImplemented, le pregunta a b.__radd__(a). Así es como 1 + tu_objeto puede funcionar aunque el entero no tenga idea de qué es tu tipo.
El __radd__ de aquí existe únicamente para que sum() funcione: sum empieza en 0 y suma, así que la primera operación es 0 + Metres(1), y solo tu __radd__ puede responderla.
También existen las formas en el lugar (__iadd__ para +=). Sáltatelas salvo que mutar en el lugar sea de verdad más rápido; sin una, += cae de vuelta a __add__ y reasigna, que suele ser lo que quieres.
Context managers
class Timer:
def __enter__(self):
self.events = ['enter']
return self
def __exit__(self, exc_type, exc, tb):
self.events.append('exit' if exc_type is None else f'exit({exc_type.__name__})')
return False
with Timer() as t2:
t2.events.append('body')
print(t2.events)
t3 = Timer()
try:
with t3:
raise ValueError('boom')
except ValueError:
pass
print(t3.events)
['enter', 'body', 'exit']
['enter', 'exit(ValueError)']
__exit__ corre pase lo que pase, y le dicen qué excepción va en curso. Devolver False la deja seguir; devolver True se la traga, cosa que deberías hacer pocas veces y a propósito.
Aquí es donde va la limpieza, no en __del__.
Creación de clases
class Plugin:
registry = {}
def __init_subclass__(cls, /, name=None, **kw):
super().__init_subclass__(**kw)
Plugin.registry[name or cls.__name__.lower()] = cls
class Csv(Plugin, name='csv'): pass
class Json(Plugin): pass
print(Plugin.registry)
{'csv': <class '__main__.Csv'>, 'json': <class '__main__.Json'>}
__init_subclass__ corre cuando alguien te hereda, y acepta argumentos por palabra clave puestos en la definición de la clase. Plugins que se registran solos, sin decorador y sin metaclase.
Este es el gancho que eliminó casi todos los usos legítimos de las metaclases. Si estabas por escribir una, revisa si __init_subclass__ y __set_name__ te alcanzan. Normalmente sí.
El pattern matching también hace una pregunta
match es la parte más nueva del modelo de datos, y tus clases pueden responderla, pero solo si le dices a Python qué significan las posiciones.
class Point:
__match_args__ = ('x', 'y')
def __init__(self, x, y): self.x, self.y = x, y
def describe(p):
match p:
case Point(0, 0): return 'origin'
case Point(0, y): return f'on the y axis at {y}'
case Point(x, 0): return f'on the x axis at {x}'
case Point(x, y) if x == y: return f'on the diagonal at {x}'
case Point(x, y): return f'at {x},{y}'
for p in [Point(0,0), Point(0,5), Point(3,0), Point(4,4), Point(1,2)]:
print(' ', describe(p))
origin
on the y axis at 5
on the x axis at 3
on the diagonal at 4
at 1,2
__match_args__ es una tupla que nombra a qué atributos corresponden los patrones posicionales. Sin ella, el emparejamiento posicional es un error en lugar de una falla silenciosa:
class NoMatch:
def __init__(self, x): self.x = x
try:
match NoMatch(1):
case NoMatch(1): pass
except TypeError as err:
print(type(err).__name__ + ':', err)
TypeError: NoMatch() accepts 0 positional sub-patterns (1 given)
Los patrones por palabra clave —case NoMatch(x=1)— funcionan sin ella, porque nombran el atributo directamente. Y las dataclasses la proveen gratis, en el orden de los campos:
@dataclass
class DC:
x: int
y: int
print('dataclass gets it free:', DC.__match_args__)
match DC(1, 2):
case DC(x=1, y=v): print(f' matched with y={v}')
dataclass gets it free: ('x', 'y')
matched with y=2
Una advertencia: __match_args__ fija un orden, y reordenarla después cambia en silencio lo que significa cada patrón posicional. Los patrones por palabra clave no tienen ese modo de falla, lo cual es una buena razón para preferirlos a partir de dos campos.
El protocolo asíncrono son las mismas preguntas, con await
Todo protocolo de arriba tiene un gemelo asíncrono. with se vuelve async with y pregunta por __aenter__ / __aexit__; for se vuelve async for y pregunta por __aiter__ / __anext__.
import asyncio
class Fetcher:
async def __aenter__(self):
self.events = ['open']
return self
async def __aexit__(self, *exc):
self.events.append('close')
return False
class Ticker:
def __init__(self, n): self.n = n
def __aiter__(self):
self.i = 0
return self
async def __anext__(self):
if self.i >= self.n:
raise StopAsyncIteration
self.i += 1
await asyncio.sleep(0)
return self.i
async def main():
async with Fetcher() as f:
f.events.append('body')
print(' ', f.events)
print(' ', [x async for x in Ticker(3)])
asyncio.run(main())
['open', 'body', 'close']
[1, 2, 3]
Dos detalles difieren de las versiones síncronas. __aiter__ no es una corrutina: devuelve el iterador asíncrono directamente, y solo __anext__ se espera con await. Y el centinela es StopAsyncIteration, no StopIteration; lanzar el equivocado produce un RuntimeError confuso en vez de una parada limpia.
Todo lo demás es la forma que ya conoces.
Las preguntas, reunidas
| Tú escribes | Python pregunta | Cae de vuelta a |
|---|---|---|
repr(x) |
__repr__ |
el <Class object at 0x…> por defecto |
str(x), print(x) |
__str__ |
__repr__ |
f"{x:spec}" |
__format__ |
__str__ con especificación vacía |
x == y |
__eq__ |
identidad (is) |
x != y |
__ne__ |
not __eq__ |
x < y |
__lt__ |
TypeError |
hash(x) |
__hash__ |
basado en id, salvo que se defina __eq__ |
len(x) |
__len__ |
TypeError |
if x: |
__bool__ |
__len__, y después siempre verdadero |
x[k] |
__getitem__ |
TypeError |
for i in x |
__iter__ |
__getitem__ desde el índice 0 |
k in x |
__contains__ |
__iter__, y después __getitem__ |
a + b |
__add__ |
b.__radd__(a) |
x() |
__call__ |
TypeError |
with x: |
__enter__ / __exit__ |
TypeError |
x.missing |
__getattribute__ |
__getattr__, y después AttributeError |
case C(a, b) |
__match_args__ |
TypeError para patrones posicionales |
async with x: |
__aenter__ / __aexit__ |
TypeError |
async for i in x |
__aiter__ / __anext__ |
TypeError |
Qué no implementar
El modelo de datos es grande y la mayor parte no es para ti.
__del__ — momento impredecible, se salta al salir, se traga las excepciones. Usa un context manager.
__getattribute__ — corre en cada acceso, es fácil volverlo infinitamente recursivo, y hace más lenta la clase. __getattr__ cubre casi todos los casos reales.
Metaclases — __init_subclass__ y __set_name__ cubren los motivos habituales.
__slots__ por defecto — parte 10. Tiene dos buenas razones y ninguna es «se ve más ordenado».
Y la regla general: cada dunder es una promesa sobre cómo se comporta tu objeto. Una promesa que nadie pidió es solo una más que hay que cumplir. Implementa __repr__ siempre, __eq__ y __hash__ para los tipos de valor, y agrega el resto cuando alguien de verdad quiera escribir esa sintaxis.
Las cinco cosas
-
La sintaxis de Python son preguntas; los métodos dunder son respuestas. Los tipos incorporados no reciben ningún trato especial.
-
Casi toda pregunta tiene una cadena de respaldo:
boolalen,strarepr,iteragetitem,addaradd. -
@propertyes un descriptor. Escribe un descriptor directamente cuando la misma validación se repita en varios campos. -
Devuelve
NotImplementeddesde los operadores que no puedas manejar, para que el otro operando tenga su turno. -
Implementar un dunder es una promesa. Haz solo las que alguien vaya a usar.
El resto del modelo de datos está en la referencia del lenguaje, y ahora se leerá como una lista de preguntas en vez de una lista de magia.