Dez partes desta série ensinaram métodos individuais. Esta é a ideia por baixo de todos eles, e cabe numa frase:
A sintaxe do Python é um conjunto de perguntas, e os métodos dunder são como o seu objeto responde.
len(x) não é uma função que inspeciona x. É o Python fazendo uma pergunta a x — x.__len__() — e informando a resposta. O mesmo vale para a + b, for i in x, with f:, x[0], if x:, str(x), x(). Nenhum deles trata tipos embutidos como caso especial. list responde às mesmas perguntas que a sua classe pode responder.
É por isso que uma classe Python bem escrita deixa de parecer aparafusada por fora. Ela não está imitando um tipo embutido. Ela está respondendo às mesmas perguntas.
Tudo abaixo roda no Python 3.12 e a saída foi colada de uma sessão real.
Vendo o Python perguntar
class Probe:
def __len__(self):
print(' Python asked __len__')
return 3
print(len(Probe()), bool(Probe()))
Python asked __len__
Python asked __len__
3 True
Duas perguntas foram feitas. O len() perguntou diretamente. O bool() perguntou porque não havia __bool__ e ele cai para o __len__ — comprimento diferente de zero significa verdadeiro.
Essa cadeia de fallback é a segunda coisa que vale saber sobre o modelo de dados: quase toda pergunta tem um plano B. O Python faz a pergunta específica, depois uma mais geral, depois aplica um padrão.
Criando e destruindo
O __new__ aloca. O __init__ preenche. Você vai escrever __init__ o tempo todo e __new__ quase nunca — ele importa para tipos imutáveis, onde não existe um “depois da alocação” em que dê para atribuir alguma coisa.
Pule o __del__. Ele roda num momento imprevisível, pode ser ignorado por completo quando o interpretador encerra, e engole exceções. Limpeza é trabalho de context manager, mais abaixo.
Representação: três perguntas, não duas
A parte 3 cobriu __repr__ e __str__. Existe uma terceira, e é ela que faz o seu objeto funcionar com 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}" passa a string "f" para o __format__ e o seu objeto decide o que ela significa. É exatamente assim que o datetime faz o %Y funcionar — não é um caso especial nas f-strings, é o datetime.__format__ escolhendo entregar a especificação ao strftime.
O último ramo delega ao número por baixo tudo que ele não reconhece, e esse é o padrão de delegação que vale copiar: interprete as especificações que te interessam, repasse o resto.
Comparação, e a que você não pode esquecer
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)]
O @total_ordering preenche <=, > e >= a partir de __eq__ e __lt__. Repare que Version(1, 2) < Version(1, 10) é True — comparando tuplas, não strings, e é por isso que ordenar versões como texto coloca 1.10 antes de 1.2.
O != veio de graça: o Python deriva ele do __eq__ a menos que você sobrescreva. Não sobrescreva.
E a regra da parte 7 que pega todo mundo: definiu __eq__, precisa definir __hash__, ou o seu objeto deixa de poder ser hasheado.
Acesso a atributos
Essa é a camada que a maioria das pessoas nunca toca, e é ela que explica o @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)
O __getattr__ é o plano B — ele roda só quando a busca normal falha. Isso o torna barato e seguro para proxies e carregamento preguiçoso.
O __getattribute__ é diferente e roda em todo acesso:
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'
Repare que ele chama object.__getattribute__ em vez de self.x, que entraria em recursão infinita. A mesma armadilha vale para o __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
O __init__ precisa contornar o próprio __setattr__ para conseguir atribuir qualquer coisa. É mais ou menos assim que o @dataclass(frozen=True) funciona.
Descritores: o que o @property realmente é
Um descritor é um objeto que define __get__ ou __set__, e ele controla o que acontece quando é acessado como atributo de classe. O @property é um. Métodos, classmethod e staticmethod também são.
Escrever um diretamente compensa quando a mesma validação se repete em vários 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
Dois campos, um validador, e o erro nomeia o campo certo. O __set_name__ é como o descritor descobre o nome que recebeu — o Python passa o nome do atributo na criação da classe.
Como dois pares de @property isso seriam doze linhas de código quase idêntico. Com cinco campos, trinta.
Iteração
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__ como generator é o jeito correto mais curto de tornar algo iterável — sem classe separada de iterador, sem __next__, sem StopIteration para levantar na mão.
Como ele constrói um generator novo a cada chamada, o objeto pode ser iterado mais de uma vez. Devolva self do __iter__ e ele se esgota depois de uma passada, que é um bug comum e confuso.
Desempacotamento funciona de graça. list(), sum(), max(), in e toda comprehension também.
Operadores, e o par refletido
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, o Python pergunta primeiro a a.__add__(b). Se aquilo devolver NotImplemented, ele pergunta a b.__radd__(a). É assim que 1 + seu_objeto consegue funcionar quando o inteiro não faz ideia do que é o seu tipo.
O __radd__ aqui existe puramente para o sum() funcionar — o sum começa do 0 e vai somando, então a primeira operação é 0 + Metres(1), que só o seu __radd__ consegue responder.
Existem também as formas no lugar (__iadd__ para +=). Pule elas a menos que alterar no lugar seja genuinamente mais rápido; sem uma, o += cai para o __add__ e reaponta o nome, que normalmente é o que você quer.
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)']
O __exit__ roda aconteça o que acontecer, e recebe qual exceção está em curso. Devolver False deixa ela seguir; devolver True engole ela, o que você deve fazer raramente e de propósito.
É aqui que a limpeza mora — não no __del__.
Criação de classes
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'>}
O __init_subclass__ roda quando alguém te subclasse, e aceita argumentos nomeados passados na definição da classe. Plugins que se registram sozinhos, sem decorator e sem metaclasse.
Esse é o gancho que eliminou a maior parte dos usos legítimos de metaclasses. Se você estava prestes a escrever uma, veja se __init_subclass__ e __set_name__ resolvem. Normalmente resolvem.
Pattern matching também faz uma pergunta
O match é a parte mais nova do modelo de dados, e as suas classes podem responder a ele — mas só se você disser ao Python o que as posições significam.
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
O __match_args__ é uma tupla dizendo a quais atributos os padrões posicionais correspondem. Sem ele, casamento posicional é um erro em vez de uma falha 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)
Padrões nomeados — case NoMatch(x=1) — funcionam sem ele, porque nomeiam o atributo diretamente. E dataclasses fornecem ele de graça, na ordem dos 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
Um cuidado: o __match_args__ fixa uma ordem, e reordenar ele depois muda em silêncio o que todo padrão posicional significa. Padrões nomeados não têm esse modo de falha, o que é um bom motivo para preferi-los a partir de dois campos.
O protocolo assíncrono são as mesmas perguntas, com await
Todo protocolo acima tem um gêmeo assíncrono. O with vira async with e pergunta a __aenter__ / __aexit__; o for vira async for e pergunta a __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]
Dois detalhes diferem das versões síncronas. O __aiter__ não é uma corrotina — ele devolve o iterador assíncrono diretamente, e só o __anext__ recebe await. E o sinal de parada é StopAsyncIteration, não StopIteration; levantar o errado produz um RuntimeError confuso em vez de uma parada limpa.
Todo o resto é o formato que você já conhece.
As perguntas, reunidas
| Você escreve | O Python pergunta | Plano B |
|---|---|---|
repr(x) |
__repr__ |
o padrão <Class object at 0x…> |
str(x), print(x) |
__str__ |
__repr__ |
f"{x:spec}" |
__format__ |
__str__ se a especificação estiver vazia |
x == y |
__eq__ |
identidade (is) |
x != y |
__ne__ |
not __eq__ |
x < y |
__lt__ |
TypeError |
hash(x) |
__hash__ |
baseado no id, a menos que __eq__ exista |
len(x) |
__len__ |
TypeError |
if x: |
__bool__ |
__len__, depois sempre verdadeiro |
x[k] |
__getitem__ |
TypeError |
for i in x |
__iter__ |
__getitem__ a partir do índice 0 |
k in x |
__contains__ |
__iter__, depois __getitem__ |
a + b |
__add__ |
b.__radd__(a) |
x() |
__call__ |
TypeError |
with x: |
__enter__ / __exit__ |
TypeError |
x.missing |
__getattribute__ |
__getattr__, depois AttributeError |
case C(a, b) |
__match_args__ |
TypeError em padrões posicionais |
async with x: |
__aenter__ / __aexit__ |
TypeError |
async for i in x |
__aiter__ / __anext__ |
TypeError |
O que não implementar
O modelo de dados é grande e a maior parte dele não é para você.
__del__ — momento imprevisível, ignorado no encerramento, engole exceções. Use um context manager.
__getattribute__ — roda em todo acesso, fácil de tornar infinitamente recursivo, e deixa a classe mais lenta. O __getattr__ cobre quase todo caso real.
Metaclasses — __init_subclass__ e __set_name__ cobrem os motivos comuns.
__slots__ por padrão — parte 10. Ele tem dois bons motivos e nenhum deles é “parece mais organizado”.
E a regra geral: cada dunder é uma promessa sobre como o seu objeto se comporta. Uma promessa que ninguém pediu é só mais uma para manter verdadeira. Implemente __repr__ sempre, __eq__ e __hash__ para tipos de valor, e acrescente o resto quando alguém realmente quiser escrever aquela sintaxe.
As cinco coisas
-
A sintaxe do Python são perguntas; métodos dunder são respostas. Não há tratamento especial para tipos embutidos.
-
Quase toda pergunta tem uma cadeia de fallback —
boolparalen,strpararepr,iterparagetitem,addpararadd. -
O
@propertyé um descritor. Escreva um descritor diretamente quando a mesma validação se repete em vários campos. -
Devolva
NotImplementeddos operadores que você não sabe tratar, para que o outro operando tenha a vez dele. -
Implementar um dunder é uma promessa. Faça só as que alguém vai usar.
O resto do modelo de dados está na referência da linguagem, e agora ela vai se ler como uma lista de perguntas em vez de uma lista de mágica.