Blog

Métodos dunder que hacen que tus objetos se sientan nativos

len(x) llama a x.__len__(). a + b llama a a.__add__(b). for i in x llama a x.__iter__(). La sintaxis de Python es un conjunto de preguntas, y los métodos dunder son la forma en que tu objeto las responde.

Implementa los correctos y tu clase deja de sentirse pegada con cinta.

Un tipo de valor, bien hecho

class Money:
    def __init__(self, amount, currency='BRL'):
        self.amount, self.currency = amount, currency
    def __repr__(self):
        return f"Money({self.amount!r}, {self.currency!r})"
    def __eq__(self, other):
        if not isinstance(other, Money): return NotImplemented
        return (self.amount, self.currency) == (other.amount, other.currency)
    def __hash__(self):
        return hash((self.amount, self.currency))
    def __add__(self, other):
        if not isinstance(other, Money): return NotImplemented
        if other.currency != self.currency:
            raise ValueError(f"cannot add {other.currency} to {self.currency}")
        return Money(self.amount + other.amount, self.currency)
    def __lt__(self, other):
        return self.amount < other.amount

a, b = Money(10), Money(10)
print(a == b, a is b)
print(a + Money(5))
print(sorted([Money(30), Money(10), Money(20)]))
print({Money(10), Money(10), Money(20)})

Imprime:

True False
Money(15, 'BRL')
[Money(10, 'BRL'), Money(20, 'BRL'), Money(30, 'BRL')]
{Money(10, 'BRL'), Money(20, 'BRL')}

Cuatro ganancias distintas a partir de cuatro métodos. Igualdad por valor y no por identidad. Suma. Ordenamiento sin función key, solo con __lt__. Y deduplicación en un conjunto, gracias a __hash__.

__eq__ sin __hash__ rompe tu objeto

Define __eq__ y Python pone __hash__ en None, porque dos objetos que comparan iguales tienen que tener el mismo hash y Python no puede adivinar cómo:

class NoHash:
    def __init__(self, v): self.v = v
    def __eq__(self, other): return self.v == other.v

try:
    {NoHash(1)}
except TypeError as err:
    print(type(err).__name__ + ':', err)

Imprime:

TypeError: unhashable type: 'NoHash'

El objeto ya no puede ir en un conjunto ni ser clave de un diccionario. Si tu clase es un tipo de valor, define los dos, sobre los mismos campos, como hace Money. Si de verdad es mutable, dejarla sin hash es el resultado correcto.

Devuelve NotImplemented, no False

print(a == 'not money')
try:
    a + 5
except TypeError as err:
    print(type(err).__name__ + ':', err)

Imprime:

False
unsupported operand type(s) for +: 'Money' and 'int'

Devolver NotImplemented le dice a Python «no puedo con esto, prueba con el otro operando». Python entonces recurre a otra cosa: a la comparación por identidad para ==, y a un TypeError claro para +. Devuelve False desde __eq__ y le quitas al otro tipo su oportunidad de responder.

Ojo: NotImplemented es un valor que se devuelve. NotImplementedError es una excepción que se lanza, para métodos abstractos. Confundirlos es común.

Los contenedores reciben dos comportamientos gratis

class Deck:
    def __init__(self, cards): self._cards = list(cards)
    def __len__(self): return len(self._cards)
    def __getitem__(self, i): return self._cards[i]

d = Deck(['A', 'K', 'Q', 'J'])
print(len(d), d[0], d[-1], d[1:3])
print([c for c in d])          # iteration for free from __getitem__
print('K' in d)                # membership for free too

Imprime:

4 A J ['K', 'Q']
['A', 'K', 'Q', 'J']
True

Dos métodos compraron indexación, índices negativos, rebanado, iteración y in. La iteración y la pertenencia salen gratis porque Python recurre a __getitem__ cuando faltan __iter__ y __contains__. El rebanado funciona porque el índice se le pasa directo a la lista.

Valor de verdad

class Basket:
    def __init__(self, items): self.items = items
    def __len__(self): return len(self.items)

print(bool(Basket([])), bool(Basket(['apple'])))

Imprime:

False True

if basket: ahora significa «si la canasta tiene algo adentro». Python pregunta por __bool__, y si no está recurre a __len__. Sin ninguno de los dos, todo objeto es verdadero, y por eso if my_object: sobre una clase sin __len__ siempre da True y nunca es la comprobación que querías.

Objetos invocables

class Multiplier:
    def __init__(self, by): self.by = by
    def __call__(self, x): return x * self.by

triple = Multiplier(3)
print(triple(5), list(map(triple, [1, 2, 3])))

Imprime:

15 [3, 6, 9]

Un objeto que se comporta como una función pero lleva estado. Esto es lo que hace posibles los decoradores escritos como clases y los callbacks configurables.

Qué recordar

  • __repr__ siempre; __eq__ y __hash__ juntos para los tipos de valor.

  • Devuelve NotImplemented desde los operadores que no puedas manejar, nunca False.

  • __len__ y __getitem__ compran iteración, pertenencia y rebanado.

  • __bool__ (o __len__) para que if obj: signifique algo.

No implementes dunders que no te sirvan. Cada uno es una promesa sobre cómo se comporta tu objeto, y una promesa que nadie pidió es solo una más que hay que cumplir.

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.