Classes e Objetos: os fundamentos da Orientação a Objetos

Classes e Objetos: os fundamentos da Orientação a Objetos

Classes, objetos, métodos dunder e encapsulamento em Python. Com duas correções que quase todo material deixa passar: por que definir o método de igualdade impede o objeto de virar chave de dicionário, e por que o duplo sublinhado não cria atributo privado coisa nenhuma.
Python

• • 20 min de leitura

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

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.

Comentários

Mais em Python

Projeto 2: Pipeline de Data Science do Zero ao Deploy
Projeto 2: Pipeline de Data Science do Zero ao Deploy

Pipeline de Data Science de ponta a ponta: dados, limpeza, features, treino…

Laços de Repetição: for e while
Laços de Repetição: for e while

O for do Python não conta: percorre. A diferença parece cosmética e muda o…

Operadores e Expressões
Operadores e Expressões

Os operadores parecem a parte trivial da linguagem, e guardam algumas das…