Até aqui trabalhamos com funções e estruturas de dados separadas. A Orientação a Objetos (OO) propõe uma forma diferente de organizar o código: agrupando dados e comportamentos relacionados em uma única unidade chamada objeto. É o paradigma dominante no desenvolvimento de software moderno e Python o suporta de forma elegante e completa.
O que é uma Classe?
Uma classe é um molde — uma definição de como um objeto deve ser estruturado. O objeto é uma instância concreta criada a partir desse molde.
A analogia clássica: a planta de uma casa é a classe; cada casa construída a partir dela é um objeto.
class Cachorro:
"""Representa um cachorro."""
def __init__(self, nome, raca, idade):
self.nome = nome
self.raca = raca
self.idade = idade
def latir(self):
return f"{self.nome} diz: Au au!"
def apresentar(self):
return f"{self.nome} é um {self.raca} de {self.idade} ano(s)."
# Criando objetos (instâncias)
rex = Cachorro("Rex", "Pastor Alemão", 3)
bolinha = Cachorro("Bolinha", "Poodle", 1)
print(rex.latir()) # Rex diz: Au au!
print(bolinha.apresentar()) # Bolinha é um Poodle de 1 ano(s).
print(rex.nome) # Rex
O Método init
O método __init__ é o construtor da classe — executado automaticamente quando um objeto é criado. Ele inicializa os atributos do objeto.
O parâmetro self representa o próprio objeto sendo criado. É sempre o primeiro parâmetro de qualquer método de instância — Python o passa automaticamente.
class Produto:
def __init__(self, nome, preco, estoque=0):
self.nome = nome
self.preco = preco
self.estoque = estoque
def disponivel(self):
return self.estoque > 0
def aplicar_desconto(self, percentual):
self.preco *= (1 - percentual / 100)
def __repr__(self):
return f"Produto('{self.nome}', R${self.preco:.2f}, estoque={self.estoque})"
teclado = Produto("Teclado Mecânico", 450.00, 15)
mouse = Produto("Mouse Gamer", 189.90)
print(teclado.disponivel()) # True
print(mouse.disponivel()) # False
teclado.aplicar_desconto(10)
print(teclado) # Produto('Teclado Mecânico', R$405.00, estoque=15)
Atributos de Instância vs. Atributos de Classe
Atributos de instância pertencem a cada objeto individualmente. Atributos de classe são compartilhados por todas as instâncias:
class Funcionario:
empresa = "TechCorp" # atributo de classe — compartilhado
_contador = 0 # conta quantos funcionários foram criados
def __init__(self, nome, salario):
self.nome = nome # atributo de instância
self.salario = salario
Funcionario._contador += 1
@classmethod
def total_funcionarios(cls):
return cls._contador
@staticmethod
def categoria_salarial(salario):
if salario < 3000:
return "Júnior"
elif salario < 7000:
return "Pleno"
return "Sênior"
f1 = Funcionario("Ana", 4500)
f2 = Funcionario("Bruno", 8200)
f3 = Funcionario("Carla", 2800)
print(Funcionario.empresa) # TechCorp
print(Funcionario.total_funcionarios()) # 3
print(Funcionario.categoria_salarial(4500)) # Pleno
print(f1.empresa) # TechCorp — acessível via instância
Ler pela instância funciona, mas escrever pela instância não altera a classe — cria um atributo de instância que passa a esconder o da classe apenas para aquele objeto:
f1.empresa = "OutraCorp"
print(f1.empresa) # OutraCorp
print(f2.empresa) # TechCorp — inalterado
print(Funcionario.empresa) # TechCorp — inalterado
print(vars(f1)) # {'nome': 'Ana', ..., 'empresa': 'OutraCorp'}
Para mudar o valor compartilhado é preciso atribuir na classe: Funcionario.empresa = "OutraCorp". O engano custa caro quando o atributo de classe é mutável. Com tags = [] na classe, obj.tags.append("x") não cria nada novo — apenas lê o atributo compartilhado e o altera, de modo que a etiqueta aparece em todas as instâncias. É o mesmo mecanismo do argumento padrão mutável, e a correção é a mesma: o que é de cada objeto nasce dentro do __init__.
Métodos Especiais (Dunder Methods)
Python usa métodos com duplo underscore para integrar objetos ao comportamento nativo da linguagem:
class Vetor:
def __init__(self, x, y):
self.x = x
self.y = y
def __repr__(self):
"""Representação técnica — usada no console."""
return f"Vetor({self.x}, {self.y})"
def __str__(self):
"""Representação legível — usada por print()."""
return f"({self.x}, {self.y})"
def __add__(self, outro):
"""Permite usar o operador +."""
return Vetor(self.x + outro.x, self.y + outro.y)
def __mul__(self, escalar):
"""Permite multiplicar por um número."""
return Vetor(self.x * escalar, self.y * escalar)
def __eq__(self, outro):
"""Permite comparar com ==."""
return self.x == outro.x and self.y == outro.y
def __len__(self):
"""Permite usar len()."""
return 2
def magnitude(self):
return (self.x ** 2 + self.y ** 2) ** 0.5
v1 = Vetor(3, 4)
v2 = Vetor(1, 2)
print(v1) # (3, 4)
print(repr(v1)) # Vetor(3, 4)
print(v1 + v2) # (4, 6)
print(v1 * 3) # (9, 12)
print(v1 == Vetor(3, 4)) # True
print(v1.magnitude()) # 5.0
O preço escondido de definir __eq__
A classe acima tem um defeito que não aparece em nenhum dos print do exemplo: ela deixou de ser hasheável. Quando uma classe define __eq__ e não define __hash__, o Python atribui None ao segundo automaticamente, e o objeto não pode mais entrar num conjunto nem virar chave de dicionário.
print(Vetor.__hash__) # None
{Vetor(3, 4)} # TypeError: cannot use 'Vetor' as a set element
# (unhashable type: 'Vetor')
A regra não é arbitrária: objetos iguais precisam ter o mesmo hash, e o Python não tem como adivinhar qual deve ser o novo hash depois que a igualdade foi redefinida. Ele prefere desligar a possibilidade a deixá-la incoerente.
O segundo problema é a ausência de verificação de tipo. Comparar com algo que não seja um Vetor não devolve False: quebra.
Vetor(3, 4) == "abc" # AttributeError: 'str' object has no attribute 'x'
"abc" == Vetor(3, 4) # também quebra: o Python tenta a comparação refletida
A forma correta devolve NotImplemented quando não sabe comparar — o que faz o Python cair para o comportamento padrão e responder False, em vez de estourar — e define o __hash__ sobre os mesmos campos usados na igualdade:
def __eq__(self, outro):
if not isinstance(outro, Vetor):
return NotImplemented
return (self.x, self.y) == (outro.x, outro.y)
def __hash__(self):
return hash((self.x, self.y))
Só defina __hash__ se o objeto for imutável naquilo que a igualdade usa: se x mudar depois de o objeto entrar num conjunto, ele fica perdido lá dentro, num balde que não corresponde mais ao seu hash. Quando a classe é apenas um agregado de dados, o caminho mais curto é o decorador @dataclass(frozen=True), que gera __init__, __repr__, __eq__ e __hash__ coerentes entre si, de graça.
Vale ainda notar que __mul__ sozinho só cobre um lado: v1 * 3 funciona e 3 * v1 levanta TypeError, porque o inteiro não sabe multiplicar por um Vetor. Quem quiser os dois sentidos precisa de __rmul__.
Encapsulamento
Encapsulamento é o princípio de proteger os dados internos de um objeto, expondo apenas o que é necessário. Python usa convenções de nomenclatura:
class ContaBancaria:
def __init__(self, titular, saldo_inicial=0):
self.titular = titular
self._saldo = saldo_inicial # _ indica "protegido" — convenção
self.__historico = [] # __ indica "privado" — name mangling
@property
def saldo(self):
"""Propriedade — acesso de leitura ao saldo."""
return self._saldo
def depositar(self, valor):
if valor <= 0:
raise ValueError("Valor de depósito deve ser positivo.")
self._saldo += valor
self.__historico.append(f"Depósito: +R${valor:.2f}")
def sacar(self, valor):
if valor <= 0:
raise ValueError("Valor de saque deve ser positivo.")
if valor > self._saldo:
raise ValueError("Saldo insuficiente.")
self._saldo -= valor
self.__historico.append(f"Saque: -R${valor:.2f}")
def extrato(self):
print(f"Titular: {self.titular}")
print(f"Saldo: R${self._saldo:.2f}")
print("Histórico:")
for item in self.__historico:
print(f" {item}")
conta = ContaBancaria("Ricardo", 1000)
conta.depositar(500)
conta.sacar(200)
conta.extrato()
print(conta.saldo) # 1300 — acesso via property
conta._saldo = 999999 # possível: o sublinhado é combinado, não imposto
conta.__historico # AttributeError — mas veja abaixo o que isso significa
É preciso desfazer um mal-entendido comum sobre o duplo sublinhado. Ele não torna o atributo privado — o Python não tem atributo privado. O que acontece chama-se name mangling: dentro da classe, self.__historico é reescrito para self._ContaBancaria__historico, e é esse o nome que fica guardado no objeto. O AttributeError acima é só o resultado de procurar por um nome que nunca existiu.
conta._ContaBancaria__historico # ['Depósito: +R$500.00', 'Saque: -R$200.00']
vars(conta)
# {'titular': 'Ricardo', '_saldo': 1300,
# '_ContaBancaria__historico': [...]}
O propósito real do duplo sublinhado é outro: evitar colisão de nomes na herança. Se uma subclasse criar seu próprio __historico, ele será gravado como _Subclasse__historico e não atropelará o da classe de cima. É um mecanismo de isolamento entre classes, não uma barreira contra quem programa. Em Python, a proteção de verdade é a combinação: um sublinhado simples diz isto é interno, não conte com ele, e essa mensagem basta entre profissionais. Por isso a convenção dominante é usar um sublinhado, e reservar o duplo para o caso específico de herança que ele resolve.
Exemplo Completo: Sistema de Biblioteca
class Livro:
def __init__(self, titulo, autor, isbn):
self.titulo = titulo
self.autor = autor
self.isbn = isbn
self._emprestado = False
@property
def disponivel(self):
return not self._emprestado
def __repr__(self):
status = "disponível" if self.disponivel else "emprestado"
return f"'{self.titulo}' por {self.autor} [{status}]"
class Biblioteca:
def __init__(self, nome):
self.nome = nome
self._acervo = {}
def adicionar(self, livro):
self._acervo[livro.isbn] = livro
print(f"Livro adicionado: {livro.titulo}")
def emprestar(self, isbn, usuario):
livro = self._acervo.get(isbn)
if not livro:
print("Livro não encontrado.")
return
if not livro.disponivel:
print(f"'{livro.titulo}' já está emprestado.")
return
livro._emprestado = True
print(f"'{livro.titulo}' emprestado para {usuario}.")
def devolver(self, isbn):
livro = self._acervo.get(isbn)
if livro and not livro.disponivel:
livro._emprestado = False
print(f"'{livro.titulo}' devolvido com sucesso.")
def listar(self):
print(f"\nAcervo — {self.nome}")
print("-" * 40)
for livro in self._acervo.values():
print(f" {livro}")
biblioteca = Biblioteca("Biblioteca Central")
l1 = Livro("Python Fluente", "Luciano Ramalho", "978-1492056355")
l2 = Livro("Python Crash Course", "Eric Matthes", "978-1593279288")
l3 = Livro("Grokking Algorithms", "Aditya Bhargava", "978-1617292231")
biblioteca.adicionar(l1)
biblioteca.adicionar(l2)
biblioteca.adicionar(l3)
biblioteca.emprestar("978-1492056355", "Ana")
biblioteca.emprestar("978-1492056355", "Bruno") # já emprestado
biblioteca.listar()
biblioteca.devolver("978-1492056355")
biblioteca.listar()
Classe é o molde e objeto é a instância, mas o que importa de verdade nesta virada é outra coisa: dados e o comportamento que age sobre eles passam a morar juntos, e o objeto vira responsável pela própria coerência. É daí que sai o valor do @property, que permite controlar a leitura sem mudar a forma de acessar, e o dos métodos dunder, que fazem uma classe própria responder aos operadores e às funções nativas como se fosse parte da linguagem. O self não tem nada de especial: é apenas o primeiro parâmetro, que o Python preenche com o objeto pelo qual o método foi chamado.
Duas correções de entendimento valem mais que a lista de recursos. A primeira é que definir __eq__ cobra um preço silencioso — o objeto deixa de ser hasheável, porque o Python atribui None ao __hash__ em vez de manter uma igualdade e um hash incoerentes entre si; e um __eq__ que não verifica o tipo do outro lado não devolve False, quebra. A segunda é que o duplo sublinhado não cria atributo privado: ele renomeia, e o dado continua acessível por _Classe__nome. Existe para evitar colisão de nomes entre uma classe e suas subclasses, não para barrar quem programa — em Python a proteção é combinada, e o sublinhado simples que diz isto é interno costuma bastar.
Fontes e leituras recomendadas
- Classes — documentação oficial — https://docs.python.org/3/tutorial/classes.html
- Modelo de dados e métodos especiais — https://docs.python.org/3/reference/datamodel.html
- Property e descritores — https://docs.python.org/3/howto/descriptor.html
- PEP 8 — convenções para classes — https://peps.python.org/pep-0008/#class-names
- RAMALHO, Luciano. Fluent Python. 2. ed. O'Reilly Media, 2022. Cap. 1 e 11 — modelo de dados e interfaces de objetos Python.
- MATTHES, Eric. Python Crash Course. 3. ed. No Starch Press, 2023. Cap. 9 — introdução prática a classes.
- PHILLIPS, Dusty; LOTT, Steven F. Python Object-Oriented Programming. 4. ed. Packt, 2021. Cap. 1–3 — OO em Python do básico ao avançado.
Exercícios
Exercício 1
Uma classe Coordenada define __eq__ para comparar latitude e longitude. Tudo funciona até alguém tentar usar coordenadas como chaves de um dicionário de cidades e receber TypeError: unhashable type: 'Coordenada'. Nada no código menciona hash. Explique de onde veio a restrição e como resolver.
Ver resposta
✓ Resposta: A restrição veio de definir __eq__. Quando uma classe redefine a igualdade e não define __hash__, o Python atribui None ao __hash__ automaticamente — é possível confirmar imprimindo Coordenada.__hash__ e vendo None —, e um objeto sem hash não entra em conjunto nem vira chave de dicionário. A regra existe para preservar uma invariante que essas estruturas dependem: objetos iguais precisam ter o mesmo hash, porque é o hash que decide em qual balde o valor é guardado. O hash padrão, herdado de object, é derivado da identidade do objeto na memória, de modo que duas coordenadas iguais em conteúdo teriam hashes diferentes; guardar uma e procurar pela outra falharia em encontrar, mesmo com == devolvendo True. Diante da escolha entre um comportamento silenciosamente quebrado e uma recusa explícita, o Python recusa. A correção é definir __hash__ sobre exatamente os mesmos campos usados na igualdade: def __hash__(self): return hash((self.lat, self.lon)), aproveitando que a tupla já sabe se hashear a partir dos elementos. Há uma condição que não pode ser esquecida: esses campos precisam ser imutáveis na prática. Se alguém alterar lat depois de a coordenada entrar num conjunto, o objeto continuará no balde antigo enquanto o hash novo aponta para outro, e ele some da própria estrutura — está lá, ocupando memória, e in responde False. Para uma classe que é só um agregado de dados, como esta, o caminho mais curto e mais seguro é @dataclass(frozen=True), que gera __init__, __repr__, __eq__ e __hash__ coerentes entre si e ainda impede a alteração dos campos.
Exercício 2
Uma classe Produto define __eq__ comparando self.codigo == outro.codigo. Um teste faz assert produto not in [None, "", 0] para garantir que a variável foi preenchida, e o teste explode com AttributeError em vez de passar ou falhar. Explique e mostre a forma correta de escrever o __eq__.
Ver resposta
✓ Resposta: O operador in sobre uma lista compara o item com cada elemento usando ==, o que aciona o __eq__ da classe passando None, depois "", depois 0 como argumento. O corpo do método faz outro.codigo sem verificar nada, e nenhum desses três tem atributo codigo: sai AttributeError: 'NoneType' object has no attribute 'codigo'. O detalhe que costuma surpreender é que inverter a ordem não salva — escrever None == produto também quebra, porque o Python tenta primeiro o __eq__ do tipo da esquerda, recebe NotImplemented de NoneType, e então tenta a comparação refletida, caindo no mesmo método defeituoso. A forma correta é verificar o tipo e devolver NotImplemented quando não souber comparar: if not isinstance(outro, Produto): return NotImplemented, e só então comparar os códigos. Devolver NotImplemented não é o mesmo que devolver False, e a diferença importa: NotImplemented avisa o interpretador de que este método não sabe responder, e ele então tenta o método refletido do outro operando; se também não houver, aí sim o Python decide por conta própria, comparando identidade — o que dá False para objetos distintos e é a resposta certa. Devolver False diretamente cortaria esse caminho e impediria que uma classe irmã, escrita depois, soubesse comparar-se com esta. A lição geral, que vale para todos os métodos de operador: o argumento vem de fora e pode ser de qualquer tipo, então __eq__, __lt__, __add__ e companhia precisam sempre decidir o que fazer com o inesperado, e a resposta quase sempre é NotImplemented.
Exercício 3
Uma classe Pedido tem itens = [] declarado logo abaixo do class, fora do __init__. Cada pedido criado adiciona seus próprios itens com pedido.itens.append(...). Em produção, todos os pedidos aparecem com os itens de todos os outros. Explique o mecanismo e diga por que trocar por pedido.itens = [novo] resolveria o sintoma pelo motivo errado.
Ver resposta
✓ Resposta: Declarado no corpo da classe, itens é um atributo de classe: existe uma única lista, criada uma vez quando a classe foi definida, compartilhada por todas as instâncias. A expressão pedido.itens.append(x) não cria nada — ela lê o atributo, não o encontra na instância, sobe para a classe, obtém aquela lista única e a altera. Todos os pedidos enxergam a mesma lista porque é literalmente a mesma. Sobre a segunda parte: pedido.itens = [novo] de fato faz o sintoma sumir, mas por um mecanismo diferente do que o programador imagina. Atribuir pela instância não altera o atributo de classe; cria um atributo de instância novo, que passa a esconder o da classe apenas para aquele objeto — é possível confirmar com vars(pedido), que passa a mostrar a chave itens. O atributo de classe continua lá, intacto, e continua compartilhado por todo pedido que ainda não tiver recebido a atribuição. O resultado é um código em que alguns objetos têm lista própria e outros dividem a original, conforme a ordem em que os métodos foram chamados — o que é pior que o defeito inicial, porque agora falha de forma intermitente. A correção é criar a lista no __init__, com self.itens = [], de modo que cada objeto nasça com a sua. O atributo de classe continua sendo a ferramenta certa para o que é genuinamente compartilhado e imutável, como uma constante ou um contador de instâncias. A regra prática: valor mutável no corpo da classe é quase sempre um defeito esperando a hora, e é o mesmo erro do argumento padrão mutável em funções, pela mesma razão — o objeto é criado uma vez, na definição, e não a cada uso.
Exercício 4
Um desenvolvedor usa self.__saldo com duplo sublinhado por considerá-lo privado e seguro. Um colega, sem acesso ao código-fonte original, altera o saldo de fora da classe. Explique como isso é possível, o que o duplo sublinhado de fato faz, e para que ele realmente serve.
Ver resposta
✓ Resposta: Python não tem atributo privado, e o duplo sublinhado não cria um. O que ele aciona é o name mangling: dentro do corpo da classe, toda referência a self.__saldo é reescrita pelo compilador para self._NomeDaClasse__saldo, e é esse o nome que fica gravado no objeto. Quando alguém de fora escreve conta.__saldo e recebe AttributeError, não encontrou uma barreira — procurou um nome que nunca existiu. Basta usar o nome verdadeiro, conta._ContaBancaria__saldo, para ler e escrever à vontade, e nem é preciso adivinhar: vars(conta) lista todos os atributos com seus nomes reais, o mangling à mostra. O propósito verdadeiro do duplo sublinhado é evitar colisão de nomes na herança. Quando uma classe base guarda estado interno em __cache e uma subclasse escrita por outra pessoa também usa __cache para outra coisa, sem o mangling as duas escreveriam no mesmo lugar e se atropelariam de formas difíceis de diagnosticar. Com ele, uma vira _Base__cache e a outra _Sub__cache, e cada classe fica com o seu. É um mecanismo de isolamento entre classes, não de proteção contra programadores. Em Python a proteção é combinada, e isso não é uma deficiência: um sublinhado simples comunica isto é interno, não conte com ele, e essa mensagem basta entre profissionais, porque quem a ignora assume conscientemente o risco de quebrar na próxima versão. A consequência prática é que esconder dados não é a defesa certa — o que protege de verdade um saldo é não existir caminho público que o deixe inválido, o que se obtém expondo leitura por @property e escrita apenas por métodos que validam, como depositar e sacar fazem neste artigo.
Exercício 5
No exemplo da biblioteca deste artigo, a classe Biblioteca empresta um livro escrevendo livro._emprestado = True — alcançando diretamente um atributo marcado como interno de outra classe. Avalie essa decisão de projeto e proponha uma alternativa, explicando o que se ganha.
Ver resposta
✓ Resposta: A decisão contradiz o princípio que o próprio artigo acabou de apresentar. O sublinhado em _emprestado declara que aquele atributo é assunto interno da classe Livro, e a Biblioteca o escreve de fora. O código funciona, porque em Python nada impede, e é exatamente por isso que a violação passa despercebida. O problema não é estético, é de responsabilidade: quem decide se um livro pode mudar de estado deveria ser o próprio livro, e enquanto a regra for apenas inverter um booleano isso parece exagero. A conta chega quando a regra cresce — registrar a data do empréstimo, guardar quem pegou, impedir empréstimo de livro reservado, limitar prazo. Cada uma dessas regras teria de ser lembrada em todo ponto que escreve o atributo, e como o atributo é alcançável de qualquer lugar, não existe lista desses pontos. Repare que a Biblioteca já expõe o sintoma: ela verifica livro.disponivel antes de escrever, isto é, a regra de negócio do livro vazou para dentro de quem o usa. A alternativa é dar ao Livro os métodos que mudam seu estado — emprestar(usuario) e devolver() —, cada um validando a própria pré-condição e levantando exceção quando ela não vale, com _emprestado ficando restrito ao interior da classe. A Biblioteca passa a coordenar: encontra o livro pelo ISBN, delega, e trata o erro. O que se ganha é que a regra passa a ter um lugar só, e alterá-la deixa de exigir auditoria do sistema inteiro — que é a promessa do encapsulamento, e a razão de ele existir. Vale notar o ganho secundário na mensagem de erro: Livro.emprestar sabe dizer este exemplar já está com outro usuário, enquanto quem só escreve um booleano de fora não tem como produzir essa frase.