DEV Community

Cover image for Modelo de Dados Python - Parte 2: Protocolos e métodos especiais
Bruno Teixeira Lopes
Bruno Teixeira Lopes

Posted on

Modelo de Dados Python - Parte 2: Protocolos e métodos especiais

🌐 Read this article in English here.

1. Da primeira parte aos protocolos da linguagem

Na Parte 1, construímos a base do modelo de dados do Python: objetos possuem identidade, tipo e valor; nomes guardam referências para esses objetos; mutabilidade determina quais mudanças podem acontecer sem substituir o objeto; e containers armazenam referências para outros objetos.

Agora vamos avançar uma camada.

O tipo de um objeto não determina apenas quais valores ele pode representar. Ele também determina quais operações aquele objeto suporta: se possui tamanho, se pode ser percorrido, comparado, indexado, chamado como função, usado em um with e assim por diante (PYTHON SOFTWARE FOUNDATION, 2026a).

É aqui que entram os métodos especiais.

Uma observação importante antes de continuar: na Parte 1 usamos o endereço de memória como modelo mental para identidade. De forma mais rigorosa, Python garante que a identidade de um objeto é estável durante sua existência. No CPython, especificamente, id(obj) corresponde ao endereço de memória do objeto; isso é um detalhe da implementação de referência, não uma garantia da linguagem (PYTHON SOFTWARE FOUNDATION, 2026a).

A mesma distinção será importante quando falarmos sobre garbage collection e finalização: Python especifica o comportamento da linguagem; CPython é uma implementação desse comportamento.

Versão de referência

Os comportamentos e referências deste artigo foram revisados com base na documentação oficial do Python 3.14.7. Detalhes exclusivos do CPython serão identificados explicitamente.


2. O que são métodos especiais

Na documentação oficial, nomes como __len__, __iter__ e __add__ são chamados de special methods, ou métodos especiais, na comunidade também é comum encontrar os termos métodos mágicos ou dunder methods (dunder vem de double underscore por causa do padrão __nome__).

Eles permitem que classes definidas por nós participem de operações que fazem parte da própria sintaxe e das funções embutidas da linguagem.

Por exemplo:

Você escreve Comportamento que Python precisa resolver Métodos relacionados
repr(obj) representação oficial __repr__
str(obj) / print(obj) representação informal __str__, com fallback para __repr__
len(obj) tamanho __len__
obj[chave] subscrição __getitem__
obj[chave] = valor atribuição por subscrição __setitem__
for item in obj iteração __iter__, __next__
a == b comparação de igualdade __eq__
a + b operação aritmética __add__, possivelmente __radd__
obj(...) chamada __call__
with obj: gerenciamento de contexto __enter__, __exit__

Essa tabela é um mapa conceitual, não uma tradução literal linha por linha do que o interpretador executa. É justamente essa diferença que precisamos entender.

2.1. Operação primeiro, método depois

Considere len().

Quando escrevemos:

len([10, 20, 30])
Enter fullscreen mode Exit fullscreen mode

obtemos:

3
Enter fullscreen mode Exit fullscreen mode

O mesmo acontece com uma string:

len("Python")
# 6
Enter fullscreen mode Exit fullscreen mode

Mas len() não precisa possuir uma grande sequência de verificações como:

# modelo que NÃO representa o funcionamento de len()

if isinstance(obj, list):
    ...
elif isinstance(obj, str):
    ...
elif isinstance(obj, tuple):
    ...
Enter fullscreen mode Exit fullscreen mode

Uma classe criada por nós também pode participar desse comportamento:

class Caixa:
    def __len__(self) -> int:
        return 3


caixa = Caixa()

len(caixa)
# 3
Enter fullscreen mode Exit fullscreen mode

Caixa não herda de list, tuple ou str.

O que importa é que seu tipo oferece o comportamento esperado pela operação de tamanho.

Podemos pensar no caminho conceitualmente assim:

len(caixa)
     ↓
operação de tamanho
     ↓
protocolo correspondente
     ↓
__len__
Enter fullscreen mode Exit fullscreen mode

É esse tipo de contrato comportamental que chamaremos de protocolo ao longo do artigo.

Python não precisa conhecer antecipadamente todas as classes que poderão existir. A classe informa à linguagem quais operações suporta através dos métodos especiais apropriados.

2.2. O lookup de métodos especiais é diferente

Existe, porém, uma nuance fundamental.

Uma primeira aproximação útil é imaginar:

len(obj) ≈ type(obj).__len__(obj)
Enter fullscreen mode Exit fullscreen mode

O símbolo é proposital. Isso é um modelo mental, e não uma reescrita literal do código executado pelo interpretador.

Invocações implícitas de métodos especiais possuem regras próprias de lookup. Para classes definidas pelo usuário, Python procura esses métodos no tipo do objeto, e não simplesmente no dicionário de atributos da instância (PYTHON SOFTWARE FOUNDATION, 2026b).

Veja a diferença:

class Caixa:
    def __len__(self) -> int:
        return 3


caixa = Caixa()

caixa.__len__ = lambda: 99
Enter fullscreen mode Exit fullscreen mode

Agora temos:

caixa.__len__()
# 99
Enter fullscreen mode Exit fullscreen mode

A chamada acima é explícita. Estamos acessando normalmente o atributo __len__ da instância.

Mas:

len(caixa)
# 3
Enter fullscreen mode Exit fullscreen mode

continua usando o método definido em Caixa.

Podemos observar o modelo aproximado:

type(caixa).__len__(caixa)
# 3
Enter fullscreen mode Exit fullscreen mode

Portanto:

caixa.__len__()
Enter fullscreen mode Exit fullscreen mode

e:

len(caixa)
Enter fullscreen mode Exit fullscreen mode

não possuem exatamente o mesmo mecanismo de resolução.

Detalhe importante

O lookup implícito de métodos especiais geralmente ignora atributos definidos diretamente na instância e também evita parte do mecanismo normal de __getattribute__.

Por isso, para que operações como len(obj) funcionem consistentemente, o método especial correspondente deve ser definido na classe, isto é, no tipo do objeto (PYTHON SOFTWARE FOUNDATION, 2026b).

Essa regra explica por que dunder methods não devem ser encarados simplesmente como métodos comuns com nomes estranhos. Eles são pontos de integração entre nossos tipos e o modelo de dados da linguagem.

2.3. Um mapa dos principais grupos

Não precisamos decorar todos os special methods existentes. É mais útil agrupá-los pelo comportamento que permitem implementar:

Categoria Métodos principais Documentação oficial
Criação e inicialização __new__, __init__ Basic customization
Finalização __del__ object.del
Representação __repr__, __str__, __format__ Basic customization
Acesso a atributos __getattribute__, __getattr__, __setattr__ Customizing attribute access
Descritores __get__, __set__, __delete__ Implementing Descriptors
Containers e sequências __len__, __getitem__, __setitem__, __contains__ Emulating container types
Iteração __iter__, __next__ Iterator Types
Comparações __eq__, __lt__, __gt__ etc. Basic customization
Operações numéricas __add__, __mul__, __rmul__, __abs__ etc. Emulating numeric types
Chamabilidade __call__ Emulating callable objects
Gerenciadores de contexto __enter__, __exit__ With Statement Context Managers

A tabela é deliberadamente incompleta. O objetivo não é transformar esta parte da série em um catálogo de dunders, mas entender por que eles existem e como diferentes métodos formam comportamentos coerentes.

2.4. Criação: __new__ e __init__

Uma das primeiras distinções importantes aparece na própria criação de objetos.

É comum dizer que "__init__ cria o objeto", mas isso não é exatamente o que acontece.

Conceitualmente:

Classe(...)
    ↓
__new__(cls, ...)
    ↓
nova instância
    ↓
__init__(instância, ...)
    ↓
objeto pronto para o chamador
Enter fullscreen mode Exit fullscreen mode

__new__ é chamado para criar e retornar a nova instância.

__init__ recebe uma instância que já existe e realiza sua inicialização ou customização (PYTHON SOFTWARE FOUNDATION, 2026c).

Considere:

from typing import Self


class Conexao:
    def __new__(cls, host: str) -> Self:
        print("1. __new__ criou a instância")
        instancia = super().__new__(cls)
        return instancia

    def __init__(self, host: str) -> None:
        print("2. __init__ inicializou a instância")
        self.host = host


Conexao("db.local")

Enter fullscreen mode Exit fullscreen mode

O resultado é:

1. __new__ criou a instância
2. __init__ inicializou a instância

Enter fullscreen mode Exit fullscreen mode

Observe que __new__ recebe cls, e não self.

Isso ocorre porque a instância ainda está sendo criada. O argumento cls informa qual classe foi solicitada, permitindo que o método devolva uma instância apropriada inclusive quando herança está envolvida.

Na implementação mais comum:

super().__new__(cls)
Enter fullscreen mode Exit fullscreen mode

delegamos a criação efetiva para o método da classe base.

É tentador resumir esse processo dizendo:

"__new__ aloca memória."

Mas essa formulação mistura níveis diferentes.

__new__ é o hook de criação de instâncias exposto pelo modelo de dados Python. O trabalho de baixo nível necessário para produzir o objeto, incluindo detalhes de alocação, pertence ao runtime e à implementação utilizada. Portanto, é mais preciso dizer que __new__ controla a criação e o objeto que será retornado, e não tratá-lo como sinônimo do allocator de memória.

Existe outra regra importante.

__init__ só é chamado automaticamente se __new__ retornar uma instância da classe solicitada — ou de uma subclasse dela (PYTHON SOFTWARE FOUNDATION, 2026c).

Por exemplo:

class Fabrica:
    def __new__(cls) -> str:
        return "já existe"

    def __init__(self) -> None:
        print("esta linha não será executada")


resultado = Fabrica()

resultado
# 'já existe'

type(resultado)
# <class 'str'>

Enter fullscreen mode Exit fullscreen mode

Como __new__ retornou uma str, e não uma instância de Fabrica, o __init__ de Fabrica não é executado.

__new__ ganha importância especial ao criar subclasses de tipos imutáveis, como int, str e tuple. Como o objeto imutável não pode ter seu valor alterado depois de criado, mudanças que afetam seu valor frequentemente precisam acontecer durante a própria criação da instância (PYTHON SOFTWARE FOUNDATION, 2026c).

Para classes comuns, entretanto, __init__ continua sendo o ponto de customização necessário na grande maioria dos casos.

2.5. Finalização: __del__ não é um gerenciador de recursos

No outro extremo do ciclo de vida existe:

__del__
Enter fullscreen mode Exit fullscreen mode

A documentação o chama de finalizer. O termo “destrutor” é usado informalmente, mas pode induzir a um modelo mental incorreto (PYTHON SOFTWARE FOUNDATION, 2026c).

Para entender o problema, precisamos separar alguns conceitos.

Um objeto pode deixar de ser alcançável pelo programa:

objeto
↓
nenhum caminho acessível do programa chega até ele
↓
objeto tornou-se inalcançável
Enter fullscreen mode Exit fullscreen mode

Isso não significa, como regra geral da linguagem:

inalcançável = destruído imediatamente
Enter fullscreen mode Exit fullscreen mode

Python permite que uma implementação adie a coleta de lixo ou até deixe de realizá-la em determinadas situações. O que a linguagem garante é que objetos ainda alcançáveis não sejam coletados (PYTHON SOFTWARE FOUNDATION, 2026a).

No CPython, especificamente, existe um esquema baseado principalmente em contagem de referências, complementado por um garbage collector capaz de detectar ciclos. Por isso muitos objetos são liberados rapidamente quando deixam de possuir referências. Esse comportamento, porém, é um detalhe do CPython, não um contrato universal de Python (PYTHON SOFTWARE FOUNDATION, 2026a).

__del__ também possui outras dificuldades:

  • pode ser executado durante o encerramento do interpretador;
  • outros objetos globais dos quais ele depende podem já estar indisponíveis;
  • exceções levantadas dentro de __del__ não são propagadas normalmente;
  • sua execução pode ocorrer em circunstâncias delicadas, tornando operações bloqueantes — como adquirir um lock — particularmente perigosas (PYTHON SOFTWARE FOUNDATION, 2026c).

Isso torna __del__ uma escolha ruim para ser o único responsável pela liberação de recursos externos importantes.

Exemplos:

  • arquivos;
  • sockets;
  • locks;
  • conexões com bancos de dados;
  • transações;
  • handles fornecidos pelo sistema operacional.

Esses recursos normalmente precisam de um momento determinístico de liberação.

É exatamente aqui que entra with. Por exemplo:

with open("dados.txt", "r") as arquivo:
    conteudo = arquivo.read()
Enter fullscreen mode Exit fullscreen mode

O bloco possui um ponto bem definido de entrada e saída.

Conceitualmente:

with recurso:
      ↓
__enter__()
      ↓
executa o bloco
      ↓
__exit__()
Enter fullscreen mode Exit fullscreen mode

Mesmo quando uma exceção ocorre dentro do bloco, o protocolo de context manager fornece um ponto apropriado para executar a lógica de saída (PYTHON SOFTWARE FOUNDATION, 2026d).

Essa diferença é fundamental:

__del__
→ finalização ligada ao ciclo de vida do objeto

with / context manager
→ gerenciamento explícito do ciclo de vida de um recurso
Enter fullscreen mode Exit fullscreen mode

Para recursos que precisam ser liberados em um momento previsível, prefira context managers ou APIs explícitas, não dependa exclusivamente de __del__.

2.6. Aritmética: NotImplemented e operações refletidas

Métodos especiais também permitem que objetos participem de operadores.

Alguns exemplos:

Operação Método
a + b __add__
a - b __sub__
a * b __mul__
a / b __truediv__
a ** b __pow__
-a __neg__
abs(a) __abs__

Mas implementar operadores corretamente exige mais do que simplesmente escrever o cálculo.

Considere uma duração armazenada em minutos:

from __future__ import annotations

from dataclasses import dataclass
from types import NotImplementedType


@dataclass(frozen=True)
class Duracao:
    minutos: int

    def __add__(self, outra: object) -> Duracao | NotImplementedType:
        if not isinstance(outra, Duracao):
            return NotImplemented

        return Duracao(self.minutos + outra.minutos)

    def __mul__(self, fator: object) -> Duracao | NotImplementedType:
        if not isinstance(fator, int):
            return NotImplemented

        return Duracao(self.minutos * fator)

    __rmul__ = __mul__

    def __str__(self) -> str:
        horas, minutos = divmod(self.minutos, 60)
        return f"{horas}h{minutos:02d}"
Enter fullscreen mode Exit fullscreen mode

Agora:

Duracao(90) + Duracao(45)
# Duracao(minutos=135)

3 * Duracao(50)
# Duracao(minutos=150)

print(Duracao(135))
# 2h15
Enter fullscreen mode Exit fullscreen mode

Mas:

Duracao(90) + 45
# TypeError
Enter fullscreen mode Exit fullscreen mode

O ponto central está nesta linha:

return NotImplemented
Enter fullscreen mode Exit fullscreen mode

NotImplemented é um objeto singleton especial utilizado pelos métodos numéricos e pelas comparações quando aquele método não implementa a operação para o par de operandos recebido (PYTHON SOFTWARE FOUNDATION, 2026e).

Isso é diferente de dizer:

“Nunca levante uma exceção dentro de um operador.”

Essa regra seria excessivamente ampla.

Se o problema é:

“Meu __add__ não sabe somar Duracao com esse tipo de objeto.”

então NotImplemented é normalmente a resposta apropriada.

Se a operação é suportada, mas um valor recebido viola alguma regra legítima do domínio, uma exceção adequada ainda pode fazer sentido.

E por que existe __rmul__?

Considere:

3 * Duracao(50)
Enter fullscreen mode Exit fullscreen mode

Neste caso específico, int está à esquerda.

Para esse par de tipos:

int.__mul__(3, Duracao(50))
Enter fullscreen mode Exit fullscreen mode

não sabe produzir um resultado e devolve:

NotImplemented
Enter fullscreen mode Exit fullscreen mode

Isso permite que Python tente a forma refletida da operação no outro operando:

Duracao.__rmul__(Duracao(50), 3)
Enter fullscreen mode Exit fullscreen mode

Nosso método aceita o fator inteiro e devolve:

Duracao(150)
Enter fullscreen mode Exit fullscreen mode

Um modelo simplificado é:

a * b
  ↓
método apropriado suporta a operação?
  ↓ não
NotImplemented
  ↓
tentativa refletida
  ↓
suporta?
  ├─ sim → resultado
  └─ não → TypeError
Enter fullscreen mode Exit fullscreen mode

Há uma nuance importante: essa não é sempre uma regra rígida de “lado esquerdo primeiro, lado direito depois”.

Quando os operandos possuem tipos diferentes e o tipo da direita é uma subclasse do tipo da esquerda, o método refletido da subclasse pode receber prioridade. Isso permite que a implementação mais específica tenha a oportunidade de controlar o resultado (PYTHON SOFTWARE FOUNDATION, 2026f).

Para int e Duracao, entretanto, não existe essa relação de herança, e o fluxo descrito acima representa corretamente:

3 * Duracao(50)
Enter fullscreen mode Exit fullscreen mode

Neste exemplo:

__rmul__ = __mul__
Enter fullscreen mode Exit fullscreen mode

é válido porque multiplicar uma duração por um escalar possui a mesma lógica independentemente da ordem sintática.

Isso não deve ser copiado automaticamente para qualquer operador: operações como subtração e divisão não são comutativas.

NotImplemented não é NotImplementedError

NotImplemented é um valor retornado por protocolos como operações numéricas e comparações para indicar que aquela combinação de operandos não é suportada.

NotImplementedError é uma exceção, normalmente utilizada quando uma API ou implementação deliberadamente deixa determinada operação sem implementação.

A documentação oficial explicita que os dois não são intercambiáveis (PYTHON SOFTWARE FOUNDATION, 2026g).

2.7. Atributos: __getattr__ e __setattr__

O acesso:

obj.nome
Enter fullscreen mode Exit fullscreen mode

também passa pelo modelo de dados.

Antes de chegar a __getattr__, Python realiza o mecanismo normal de busca de atributos, que envolve a instância, sua classe, a hierarquia de classes e, quando aplicável, descriptors.

Só quando essa busca termina em AttributeError, __getattr__ funciona como fallback (PYTHON SOFTWARE FOUNDATION, 2026h).

Conceitualmente:

obj.nome
   ↓
busca normal de atributos
   ↓
encontrou?
├─ sim → devolve o valor
└─ não
    ↓
__getattr__(obj, "nome")
Enter fullscreen mode Exit fullscreen mode

Isso é importante porque __getattr__ não participa de toda leitura de atributos.

Existe outro método:

__getattribute__
Enter fullscreen mode Exit fullscreen mode

que participa incondicionalmente do acesso a atributos de instâncias e permite controlar o processo de forma muito mais ampla.

Não vamos aprofundá-lo aqui, mas a distinção é importante:

__getattribute__
→ participa de toda leitura normal

__getattr__
→ fallback quando a busca falha
Enter fullscreen mode Exit fullscreen mode

Agora podemos construir uma configuração somente leitura:

class Config:
    _dados: dict[str, str]

    def __init__(self, **valores: str) -> None:
        object.__setattr__(self, "_dados", valores)

    def __getattr__(self, nome: str) -> str:
        try:
            return self._dados[nome]
        except KeyError:
            raise AttributeError(f"'{nome}' não existe na configuração") from None

    def __setattr__(self, nome: str, valor: str) -> None:
        raise AttributeError("Config é somente leitura")
Enter fullscreen mode Exit fullscreen mode

Uso:

cfg = Config(
    host="localhost",
    porta="5432",
)

cfg.host
# 'localhost'

cfg.porta
# '5432'
Enter fullscreen mode Exit fullscreen mode

host e porta não existem como atributos normais da instância.

Quando:

cfg.host
Enter fullscreen mode Exit fullscreen mode

não é encontrado pela busca normal, __getattr__ recebe:

nome == "host"
Enter fullscreen mode Exit fullscreen mode

e procura a informação em:

self._dados
Enter fullscreen mode Exit fullscreen mode

Por que _dados começa com underscore?

Um único underscore inicial é uma convenção de uso interno usada para comunicar:

“Este nome faz parte da implementação interna e não deveria ser tratado como API pública.”

Ele não cria verdadeiro controle de acesso.

Ainda é possível escrever:

cfg._dados
Enter fullscreen mode Exit fullscreen mode

Python não bloqueia esse acesso.

Isso é diferente de:

__dados
Enter fullscreen mode Exit fullscreen mode

Dois underscores iniciais ativam um mecanismo chamado name mangling, que transforma o nome internamente para reduzir colisões acidentais, principalmente em cenários de herança.

Para nosso exemplo, _dados comunica exatamente o que queremos: trata-se de um detalhe não público da implementação.

E por que usamos object.__setattr__?

Agora observe:

def __setattr__(self, nome: str, valor: str) -> None:
    raise AttributeError("Config é somente leitura")
Enter fullscreen mode Exit fullscreen mode

__setattr__ é chamado quando tentamos realizar uma atribuição de atributo (PYTHON SOFTWARE FOUNDATION, 2026h).

Portanto:

cfg.host = "outro"
Enter fullscreen mode Exit fullscreen mode

resulta em:

AttributeError: Config é somente leitura
Enter fullscreen mode Exit fullscreen mode

O mesmo aconteceria dentro do próprio __init__ se escrevêssemos:

self._dados = valores
Enter fullscreen mode Exit fullscreen mode

Essa instrução também é uma atribuição de atributo:

self._dados = valores
        ↓
self.__setattr__("_dados", valores)
        ↓
AttributeError
Enter fullscreen mode Exit fullscreen mode

Por isso fazemos:

object.__setattr__(self, "_dados", valores)
Enter fullscreen mode Exit fullscreen mode

Não estamos “desligando” o modelo de atributos inteiro. Estamos chamando diretamente a implementação da classe base e, com isso, evitando nosso Config.__setattr__ customizado.

A própria documentação recomenda esse padrão quando um __setattr__ precisa realizar uma atribuição real na instância (PYTHON SOFTWARE FOUNDATION, 2026h).

Armadilha de recursão

Uma implementação como:

def __setattr__(self, nome, valor):
    self.nome = valor
Enter fullscreen mode Exit fullscreen mode

chamaria novamente __setattr__, que executaria outra atribuição, que chamaria __setattr__ outra vez...

Quando é necessário delegar ao comportamento base, use explicitamente object.__setattr__.

Por que transformar KeyError em AttributeError?

Dentro de __getattr__ temos:

try:
    return self._dados[nome]
except KeyError:
    raise AttributeError(f"'{nome}' não existe na configuração") from None
Enter fullscreen mode Exit fullscreen mode

O dicionário expressa ausência através de:

KeyError
Enter fullscreen mode Exit fullscreen mode

Mas estamos implementando acesso a atributos.

Nesse protocolo, a ausência deve ser comunicada através de:

AttributeError
Enter fullscreen mode Exit fullscreen mode

Isso é importante porque outros mecanismos de Python também dependem dessa exceção para determinar se um atributo existe. from None suprime a exibição automática do encadeamento com o KeyError anterior no traceback, deixando visível apenas a exceção que representa corretamente a abstração pública da classe (PYTHON SOFTWARE FOUNDATION, 2026j). Sem from None, Python mostraria também o contexto da exceção anterior.

A próxima camada: descriptors

Até aqui customizamos operações diretamente sobre a instância.

Descriptors resolvem outro problema: permitir que um atributo armazenado na classe controle o que acontece quando é lido, escrito ou removido.

O protocolo utiliza:

__get__
__set__
__delete__
Enter fullscreen mode Exit fullscreen mode

Conceitualmente:

obj.atributo
    ↓
Python encontra um objeto descriptor na classe
    ↓
__get__(...)
Enter fullscreen mode Exit fullscreen mode

ou, durante escrita:

obj.atributo = valor
        ↓
__set__(...)
Enter fullscreen mode Exit fullscreen mode

Esse mecanismo está por trás de recursos fundamentais da própria linguagem, incluindo property, métodos ligados e classmethod (PYTHON SOFTWARE FOUNDATION, 2026k).

Por exemplo:

class Pessoa:
    def __init__(self, nome: str) -> None:
        self._nome = nome

    @property
    def nome(self) -> str:
        return self._nome
Enter fullscreen mode Exit fullscreen mode

O objeto criado por property participa do protocolo de descriptors.

Não precisamos aprofundar a mecânica inteira agora. O ponto importante é perceber que acesso a atributos também é extensível através de protocolos do modelo de dados.

2.8. Protocolos são mais do que nomes de métodos

Podemos agora refinar a definição.

Um protocolo não significa apenas:

“Existe um método com determinado nome.”

Ele também envolve o contrato semântico esperado daquele método.

Por exemplo:

  • __len__ deve representar tamanho e retornar um inteiro não negativo;
  • __getitem__ de uma sequência deve usar exceções apropriadas para índices inválidos;
  • um iterador deve sinalizar seu fim através de StopIteration;
  • __eq__ pode devolver NotImplemented para operandos que não sabe comparar;
  • __hash__ precisa permanecer coerente com igualdade.

É possível escrever um método com o nome correto e ainda implementar o protocolo de maneira ruim.

Por isso, vamos começar aplicando esse princípio a uma única classe, que continuará evoluindo na próxima parte da série.

Nossa base será um vetor N-dimensional:

class Vector:
    def __init__(self, *componentes: float) -> None:
        self._componentes = list(componentes)
Enter fullscreen mode Exit fullscreen mode

Agora podemos criar:

Vector(3, 4)
Vector(1, 2, 3)
Vector(2, 5, 8, 13)
Enter fullscreen mode Exit fullscreen mode

Os componentes ficam armazenados em uma coleção interna:

_componentes
Enter fullscreen mode Exit fullscreen mode

O Vector deste artigo terá duas decisões de projeto importantes:

  1. Os valores dos componentes poderão ser alterados;
  2. A quantidade de componentes permanecerá fixa depois da construção.

Portanto, ele será mutável quanto aos valores, mas não oferecerá operações que adicionem ou removam dimensões.

Para fins didáticos também permitiremos:

Vector()
Enter fullscreen mode Exit fullscreen mode

representando um vetor sem componentes. Um domínio real poderia rejeitar essa construção caso ela não fizesse sentido.

A partir daqui, cada novo método deve respeitar essas mesmas decisões. Nesta parte, começaremos pela representação textual; na próxima, continuaremos evoluindo o mesmo Vector através dos protocolos de tamanho, indexação, iteração e comparação.


3. Representação: __repr__ e __str__

Comecemos pelo comportamento mais visível.

Sem __repr__ ou __str__ customizados:

v = Vector(3, 4)

v
Enter fullscreen mode Exit fullscreen mode

no CPython normalmente produz algo parecido com:

<__main__.Vector object at 0x...>
Enter fullscreen mode Exit fullscreen mode

O formato exato dessa representação padrão não deve ser tratado como parte do contrato da nossa classe.

O que queremos responder é:

Como um Vector deve se representar textualmente?

Python oferece dois métodos principais para isso.

Aspecto __repr__ __str__
Papel na documentação representação oficial representação informal ou facilmente imprimível
Objetivo comum rica em informação e pouco ambígua conveniente e legível
Uso típico REPL, debugging, logs técnicos apresentação para leitura humana
Acionado por repr(v), f"{v!r}" str(v), print(v), f"{v}", f"{v!s}"

A distinção “desenvolvedor versus usuário final” pode ser uma analogia útil, mas não é a definição normativa.

A documentação define __repr__ como responsável pela representação “oficial” e recomenda, quando possível, uma forma parecida com uma expressão Python capaz de recriar um objeto equivalente. Já __str__ pode produzir uma representação mais conveniente ou concisa (PYTHON SOFTWARE FOUNDATION, 2026c).

Vamos começar por __repr__:

class Vector:
    # ... __init__ da seção anterior

    def __repr__(self) -> str:
        componentes = ", ".join(
            repr(componente)
            for componente in self._componentes
        )

        return f"Vector({componentes})"
Enter fullscreen mode Exit fullscreen mode

Agora:

v = Vector(3, 4)

repr(v)
# 'Vector(3, 4)'

v
# Vector(3, 4)
Enter fullscreen mode Exit fullscreen mode

E para dimensões diferentes:

Vector(1, 2, 3)
# Vector(1, 2, 3)

Vector()
# Vector()
Enter fullscreen mode Exit fullscreen mode

A representação acompanha os dados realmente armazenados. Não existe mais nenhuma hipótese hardcoded de que um vetor precise possuir apenas x e y.

Agora adicionamos uma forma mais compacta para __str__:

class Vector:
    # ...

    def __str__(self) -> str:
        componentes = ", ".join(
            str(componente)
            for componente in self._componentes
        )

        return f"({componentes})"
Enter fullscreen mode Exit fullscreen mode

Com isso:

v = Vector(3, 4)

repr(v)
# 'Vector(3, 4)'

str(v)
# '(3, 4)'

print(v)
# (3, 4)

f"{v}"
# '(3, 4)'

f"{v!s}"
# '(3, 4)'

f"{v!r}"
# 'Vector(3, 4)'
Enter fullscreen mode Exit fullscreen mode

O fallback de __str__

Existe ainda uma relação importante entre os dois métodos.

Se definirmos __repr__, mas não __str__, a representação oficial também é utilizada quando uma representação informal é necessária (PYTHON SOFTWARE FOUNDATION, 2026c).

Por isso, uma classe com apenas:

def __repr__(self) -> str:
    return "..."
Enter fullscreen mode Exit fullscreen mode

já melhora tanto:

repr(obj)
Enter fullscreen mode Exit fullscreen mode

quanto:

str(obj)
print(obj)
Enter fullscreen mode Exit fullscreen mode

O contrário não possui a mesma relação: implementar apenas __str__ não substitui a necessidade de uma representação oficial apropriada.

E os containers?

Observe:

v = Vector(3, 4)

[v]
# [Vector(3, 4)]
Enter fullscreen mode Exit fullscreen mode

Mesmo que:

str(v)
Enter fullscreen mode Exit fullscreen mode

produza:

(3, 4)
Enter fullscreen mode Exit fullscreen mode

a representação de uma list utiliza a representação oficial de seus elementos.

Isso é útil para debugging:

vetores = [
    Vector(1, 2),
    Vector(3, 4),
]

vetores
# [Vector(1, 2), Vector(3, 4)]
Enter fullscreen mode Exit fullscreen mode

O container consegue mostrar claramente quais objetos contém, sem depender da versão mais informal produzida por __str__.


4. Conclusão

Ao longo desta parte, saímos da ideia de que métodos como __len__, __repr__ ou __add__ são apenas nomes especiais que Python chama automaticamente. O ponto central é mais amplo: eles são pontos de integração entre os nossos tipos e os protocolos da linguagem.

Quando escrevemos:

len(obj)
Enter fullscreen mode Exit fullscreen mode

ou:

a * b
Enter fullscreen mode Exit fullscreen mode

ou ainda:

obj.nome
Enter fullscreen mode Exit fullscreen mode

não estamos apenas chamando funções ou operadores isolados. Estamos** acionando comportamentos definidos pelo modelo de dados, cada um com suas próprias regras de resolução e seus próprios contratos**.

Foi por isso que, ao longo do artigo, vimos que:

  • len(obj) não é equivalente a uma chamada comum de obj.__len__();
  • __new__ e __init__ participam de momentos diferentes da criação de uma instância;
  • __del__ não deve ser confundido com gerenciamento determinístico de recursos;
  • NotImplemented faz parte da negociação entre operandos;
  • __getattr__ funciona como fallback no acesso a atributos;
  • descriptors formam outro protocolo por trás de mecanismos como property;
  • __repr__ e __str__ representam contratos diferentes para a representação textual de um objeto.

Esses exemplos também mostram por que implementar um dunder method não significa apenas usar o nome correto. Para participar bem de um protocolo, a classe precisa respeitar a semântica esperada daquela operação.

O Vector introduzido no final desta parte será nosso ponto de continuidade.

Até aqui, definimos como ele armazena seus componentes e como deve se representar:

Vector(3, 4)
Enter fullscreen mode Exit fullscreen mode

ou, em sua forma informal:

(3, 4)
Enter fullscreen mode Exit fullscreen mode

Na próxima parte, a pergunta muda.

Em vez de observar protocolos de forma mais isolada, vamos acompanhar o que acontece quando vários deles precisam coexistir no mesmo tipo. Vamos decidir, entre outras coisas:

len(v)
Enter fullscreen mode Exit fullscreen mode

deve representar o quê?

Como:

v[0]
v[-1]
v[1:3]
Enter fullscreen mode Exit fullscreen mode

devem se comportar?

O que torna:

for componente in v:
    ...
Enter fullscreen mode Exit fullscreen mode

possível?

E se o Vector for mutável, como isso afeta:

v1 == v2
Enter fullscreen mode Exit fullscreen mode

e sua possibilidade de ser usado em um set ou como chave de dict?

É nesse ponto que os protocolos deixam de parecer recursos independentes e passam a revelar algo ainda mais importante sobre o modelo de dados: decisões de comportamento em uma parte da classe podem impor consequências sobre várias outras.

Na Parte 3, continuaremos justamente daí, evoluindo o mesmo Vector através dos protocolos de tamanho, indexação, iteração, igualdade e hashing.


Referências

PYTHON SOFTWARE FOUNDATION. 3.1. Objects, values and types. In: Python 3.14.7 documentation. [S. l.]: Python Software Foundation, 2026a. Disponível em: https://docs.python.org/3.14/reference/datamodel.html#objects-values-and-types. Acesso em: 14 ago. 2026.

PYTHON SOFTWARE FOUNDATION. 3.3.13. Special method lookup. In: Python 3.14.7 documentation. [S. l.]: Python Software Foundation, 2026b. Disponível em: https://docs.python.org/3.14/reference/datamodel.html#special-method-lookup. Acesso em: 14 ago. 2026.

PYTHON SOFTWARE FOUNDATION. 3.3.1. Basic customization. In: Python 3.14.7 documentation. [S. l.]: Python Software Foundation, 2026c. Disponível em: https://docs.python.org/3.14/reference/datamodel.html#basic-customization. Acesso em: 14 ago. 2026.

PYTHON SOFTWARE FOUNDATION. 3.3.9. With Statement Context Managers. In: Python 3.14.7 documentation. [S. l.]: Python Software Foundation, 2026d. Disponível em: https://docs.python.org/3.14/reference/datamodel.html#with-statement-context-managers. Acesso em: 14 ago. 2026.

PYTHON SOFTWARE FOUNDATION. 3.2.2. NotImplemented. In: Python 3.14.7 documentation. [S. l.]: Python Software Foundation, 2026e. Disponível em: https://docs.python.org/3.14/reference/datamodel.html#notimplemented. Acesso em: 14 ago. 2026.

PYTHON SOFTWARE FOUNDATION. 3.3.8. Emulating numeric types. In: Python 3.14.7 documentation. [S. l.]: Python Software Foundation, 2026f. Disponível em: https://docs.python.org/3.14/reference/datamodel.html#emulating-numeric-types. Acesso em: 14 ago. 2026.

PYTHON SOFTWARE FOUNDATION. NotImplementedError. In: Built-in Exceptions — Python 3.14.7 documentation. [S. l.]: Python Software Foundation, 2026g. Disponível em: https://docs.python.org/3.14/library/exceptions.html#NotImplementedError. Acesso em: 14 ago. 2026.

PYTHON SOFTWARE FOUNDATION. 3.3.2. Customizing attribute access. In: Python 3.14.7 documentation. [S. l.]: Python Software Foundation, 2026h. Disponível em: https://docs.python.org/3.14/reference/datamodel.html#customizing-attribute-access. Acesso em: 14 ago. 2026.

PYTHON SOFTWARE FOUNDATION. 9.6. Private Variables. In: The Python Tutorial — Python 3.14.7 documentation. [S. l.]: Python Software Foundation, 2026i. Disponível em: https://docs.python.org/3.14/tutorial/classes.html#private-variables. Acesso em: 14 ago. 2026.

PYTHON SOFTWARE FOUNDATION. 7.8. The raise statement. In: Python Language Reference — Python 3.14.7 documentation. [S. l.]: Python Software Foundation, 2026j. Disponível em: https://docs.python.org/3.14/reference/simple_stmts.html#the-raise-statement. Acesso em: 14 ago. 2026.

PYTHON SOFTWARE FOUNDATION. Descriptor Guide — Descriptor protocol. In: Python 3.14.7 documentation. [S. l.]: Python Software Foundation, 2026k. Disponível em: https://docs.python.org/3.14/howto/descriptor.html#descriptor-protocol. Acesso em: 14 ago. 2026.

Nota editorial: Este artigo foi desenvolvido e revisado pelo autor com apoio de IA nas etapas de pesquisa, auditoria técnica e revisão editorial. As afirmações técnicas foram verificadas com base nas fontes oficiais indicadas nas referências.

Top comments (0)