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

Banco de Dados com SQLite e SQLAlchemy
Banco de Dados com SQLite e SQLAlchemy

SQLite com sqlite3 e SQLAlchemy 2.0 em Python, do CRUD ao repositório e às…

Tratamento de Exceções e Erros
Tratamento de Exceções e Erros

Exceções em Python, do try básico ao encadeamento com from. Incluindo três…

Herança e Polimorfismo
Herança e Polimorfismo

Herança, super, polimorfismo e classes abstratas em Python. Com o diamante que…