Blog

El modelo de datos de Python, de punta a punta

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 xx.__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: bool a len, str a repr, iter a getitem, add a radd.

  • @property es un descriptor. Escribe un descriptor directamente cuando la misma validación se repita en varios campos.

  • Devuelve NotImplemented desde 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.

How useful was this post?

Click on a heart to rate it!

Average rating 0 / 5. Vote count: 0

No votes so far! Be the first to rate this post.