8  Programação Orientada a Objetos

A Programação Orientada a Objetos (POO) em Python ajuda a estruturar o código agrupando dados e comportamentos relacionados em objetos. Você começa definindo classes — que funcionam como moldes — e então cria objetos a partir delas. A POO simplifica a modelagem de conceitos do mundo real e permite construir sistemas mais reutilizáveis e escaláveis.

Ao final deste capítulo, você será capaz de:

8.1 O que é Programação Orientada a Objetos?

A POO é um paradigma de programação que estrutura os programas em torno de objetos — entidades que combinam dados e comportamentos relacionados.

Por exemplo, um objeto pode representar um consumidor com atributos como nome e renda, e comportamentos como consumir e poupar. Ou pode representar uma empresa com atributos como receita e custos, e comportamentos como contratar e produzir.

A POO é frequentemente descrita em torno de quatro pilares:

Pilar Descrição
Encapsulamento Agrupa dados e comportamentos em uma classe, controlando o acesso e protegendo a integridade dos dados
Herança Permite criar hierarquias de classes, onde subclasses herdam atributos e métodos da classe pai
Abstração Esconde detalhes de implementação, expondo apenas a interface essencial do objeto
Polimorfismo Permite que objetos de classes diferentes respondam ao mesmo método de formas distintas

Ideia central: em outros paradigmas, objetos apenas representam dados. Na POO, eles também informam a estrutura geral do programa.

8.2 Por que usar classes?

Tipos primitivos — como números, strings e listas — são projetados para representar informações simples. Mas e quando precisamos representar algo mais complexo?

Por exemplo, suponha que queremos registrar contas bancárias com titular, saldo, tipo e número. Uma abordagem ingênua seria usar listas:

conta1 = ["João", 1500, "corrente", "001-5"]
conta2 = ["Maria", 3200, "poupança", "002-8"]
conta3 = ["Pedro", "corrente"]  # saldo esquecido!

# Problema 1: é preciso memorizar o que cada índice significa
print(f"Titular: {conta1[0]}")    # ok — mas exige memorizar índice 0 = titular
print(f"Saldo:   {conta1[1]}")    # ok — mas exige memorizar índice 1 = saldo

# Problema 2: listas incompletas causam erros silenciosos
print(f"Saldo de Pedro: {conta3[1]}")  # retorna "corrente" em vez do saldo!
Titular: João
Saldo:   1500
Saldo de Pedro: corrente

Existem dois problemas com essa abordagem:

  1. Legibilidade ruim: conta1[0] representa o titular, mas isso só é óbvio se você lembrar a posição de cada campo.
  2. Fragilidade: se uma lista tiver menos elementos (como conta3, sem saldo), acessar índices fixos retorna valores errados — silenciosamente.

Uma classe resolve esses problemas ao nomear explicitamente cada atributo e garantir que todo objeto tenha a mesma estrutura.

8.3 Definindo uma classe

8.3.1 Classes vs. instâncias

Uma classe é como um formulário em branco: define os campos e as regras, mas não contém dados reais. Uma instância é como um formulário preenchido — um objeto concreto, com valores próprios.

Assim como muitos clientes podem abrir uma conta no mesmo banco com informações diferentes, você pode criar muitas instâncias a partir de uma única classe.

Toda definição começa com class, o nome (em CamelCase) e dois-pontos. O corpo é indentado. O exemplo mais simples usa pass como corpo provisório:

class ContaBancaria:
    pass

# Instanciando a classe: criando objetos
a = ContaBancaria()
b = ContaBancaria()

print(a)      # <__main__.ContaBancaria object at 0x...>
print(b)
print(a == b) # False — são objetos distintos na memória
<__main__.ContaBancaria object at 0x103ac5940>
<__main__.ContaBancaria object at 0x11fa00910>
False

8.3.2 O método __init__ e atributos de instância

Para que cada objeto tenha dados próprios, definimos o método especial __init__. Ele é chamado automaticamente toda vez que um novo objeto é criado.

  • O primeiro parâmetro é sempre self — uma referência ao próprio objeto que está sendo criado.
  • self.titular = titular cria um atributo de instância: um dado específico àquele objeto.
  • Atributos de instância podem ter valores diferentes para cada objeto.

Além dos atributos de instância, podemos definir atributos de classe — valores compartilhados por todos os objetos da classe, definidos diretamente no corpo da classe (fora do __init__).

class ContaBancaria:
    banco = "Banco Python"   # atributo de CLASSE (compartilhado por todos)

    def __init__(self, titular, saldo=0):  # método construtor
        self.titular = titular  # atributo de INSTÂNCIA (específico do objeto)
        self.saldo   = saldo    # atributo de INSTÂNCIA (específico do objeto)

8.4 Instanciando uma classe

Para criar um objeto, chame o nome da classe com os argumentos necessários (self é preenchido automaticamente pelo Python). Depois, acesse atributos com a notação de ponto (.).

Objetos são mutáveis por padrão: você pode alterar um atributo após a criação.

c1 = ContaBancaria("João Silva", 1500)
c2 = ContaBancaria("Maria Santos", 3200)

# Atributos de instância: variam por objeto
print(c1.titular)  # João Silva
print(c1.saldo)    # 1500

# Atributo de classe: igual em todos os objetos
print(c1.banco)    # Banco Python
print(c2.banco)    # Banco Python

# Objetos são mutáveis: atributos podem ser alterados após a criação
c1.saldo = 2000
print(c1.saldo)    # 2000
João Silva
1500
Banco Python
Banco Python
2000

8.5 Métodos de instância

Métodos de instância são funções definidas dentro de uma classe que operam sobre o objeto. Como __init__, seu primeiro parâmetro é sempre self.

class ContaBancaria:
    banco = "Banco Python"

    def __init__(self, titular, saldo=0):
        self.titular = titular
        self.saldo   = saldo

    def depositar(self, valor):
        self.saldo += valor
        print(f"{self.titular}: depósito de R$ {valor:,.2f} | saldo: R$ {self.saldo:,.2f}")

    def sacar(self, valor):
        if valor > self.saldo:
            print(f"Saldo insuficiente! Saldo atual: R$ {self.saldo:,.2f}")
        else:
            self.saldo -= valor
            print(f"{self.titular}: saque de R$ {valor:,.2f} | saldo: R$ {self.saldo:,.2f}")

c1 = ContaBancaria("João Silva", 1000)
c1.depositar(500)
c1.sacar(200)
c1.sacar(2000)
João Silva: depósito de R$ 500.00 | saldo: R$ 1,500.00
João Silva: saque de R$ 200.00 | saldo: R$ 1,300.00
Saldo insuficiente! Saldo atual: R$ 1,300.00

8.5.1 Métodos especiais (dunder methods)

Métodos com duplo sublinhado (__nome__) são chamados de dunder methods (double underscore). Eles permitem que os objetos se integrem naturalmente ao Python — em impressão, comparação, aritmética e muito mais.

O mais útil para visualização é __str__, chamado automaticamente por print(). Sem ele, imprimir um objeto exibe o endereço de memória:

print(c1)   # <__main__.ContaBancaria object at 0x...>

Com __str__, controlamos exatamente o que é exibido:

class ContaBancaria:
    banco = "Banco Python"

    def __init__(self, titular, saldo=0):
        self.titular = titular
        self.saldo   = saldo

    def __str__(self):
        return f"ContaBancaria({self.titular}, saldo=R${self.saldo:,.2f})"

    def depositar(self, valor):
        self.saldo += valor
        print(f"{self.titular}: depósito de R$ {valor:,.2f} | saldo: R$ {self.saldo:,.2f}")

    def sacar(self, valor):
        if valor > self.saldo:
            print(f"Saldo insuficiente! Saldo atual: R$ {self.saldo:,.2f}")
        else:
            self.saldo -= valor
            print(f"{self.titular}: saque de R$ {valor:,.2f} | saldo: R$ {self.saldo:,.2f}")

c1 = ContaBancaria("João Silva", 1000)
c1.depositar(500)
print(c1)
João Silva: depósito de R$ 500.00 | saldo: R$ 1,500.00
ContaBancaria(João Silva, saldo=R$1,500.00)

8.6 Os tipos embutidos são classes

Embora não tenhamos mencionado explicitamente antes, usamos classes e objetos ao longo de todo este curso. Os tipos básicos do Python são, na verdade, classes. Cada valor que criamos é um objeto — uma instância dessas classes:

Tipo Classe Exemplos de métodos
Inteiro int abs(-5), int("42")
Texto str "olá".upper(), "olá".split()
Lista list lista.append(x), lista.sort()
Dicionário dict d.keys(), d.values()

Quando escrevemos lista.append(item), estamos chamando o método append() de um objeto da classe list — exatamente como chamamos c1.depositar(500) acima. A função type() revela a classe de qualquer objeto:

print(type(50))        # <class 'int'>
print(type("python"))  # <class 'str'>
print(type([]))        # <class 'list'>
print(type({}))        # <class 'dict'>
print(type(c1))        # <class '__main__.ContaBancaria'>
<class 'int'>
<class 'str'>
<class 'list'>
<class 'dict'>
<class '__main__.ContaBancaria'>

8.7 Herança

Herança é o processo pelo qual uma classe adquire os atributos e métodos de outra. A classe original é chamada de classe pai (ou base); a nova é chamada de subclasse (ou filha).

Para criar uma subclasse, basta colocar o nome da classe pai entre parênteses:

class ClassePai:
    banco = "Banco Python"

class ClasseFilha(ClassePai):
    pass

print(ClasseFilha.banco)   # Banco Python — herdado!

8.7.1 Exemplo: tipos de conta

Contas-correntes e contas-poupança compartilham as características de uma ContaBancaria: titular e saldo. Mas cada tipo tem comportamentos distintos:

  • ContaCorrente: permite saque além do saldo (cheque especial com limite)
  • ContaPoupanca: acumula rendimento mensal sobre o saldo

A solução é criar subclasses. Abaixo está a classe base que usaremos:

class ContaBancaria:
    banco = "Banco Python"

    def __init__(self, titular, saldo=0):
        self.titular = titular
        self.saldo   = saldo

    def depositar(self, valor):
        self.saldo += valor
        print(f"{self.titular}: depósito de R$ {valor:,.2f} | saldo: R$ {self.saldo:,.2f}")

    def sacar(self, valor):
        if valor > self.saldo:
            print(f"Saldo insuficiente! Saldo atual: R$ {self.saldo:,.2f}")
        else:
            self.saldo -= valor
            print(f"{self.titular}: saque de R$ {valor:,.2f} | saldo: R$ {self.saldo:,.2f}")

    def __str__(self):
        return f"ContaBancaria({self.titular}, saldo=R${self.saldo:,.2f})"

8.7.2 Classes pai e classes filha

Subclasses herdam todos os atributos e métodos da classe pai, mas podem sobrescrever métodos ou adicionar novos comportamentos.

Para reaproveitar a lógica já escrita na classe pai (e não duplicar código), usamos super() — que delega a chamada ao método da classe pai. Assim, se o comportamento mudar em ContaBancaria, as subclasses se beneficiam automaticamente.

class ContaCorrente(ContaBancaria):
    def __init__(self, titular, saldo=0, limite=500):
        super().__init__(titular, saldo)  # reutiliza __init__ da classe pai
        self.limite = limite              # atributo novo

    def sacar(self, valor):               # sobrescreve: permite saque com limite
        if valor > self.saldo + self.limite:
            print(f"Limite excedido! Valor disponível: R$ {self.saldo + self.limite:,.2f}")
        else:
            self.saldo -= valor
            print(f"{self.titular}: saque de R$ {valor:,.2f} | saldo: R$ {self.saldo:,.2f}")

    def __str__(self):
        return f"ContaCorrente({self.titular}, saldo=R${self.saldo:,.2f}, limite=R${self.limite:,.2f})"


class ContaPoupanca(ContaBancaria):
    taxa_rendimento = 0.005  # 0,5% ao mês (atributo de classe)

    def render(self, meses=1):  # método novo
        saldo_inicial = self.saldo
        self.saldo *= (1 + self.taxa_rendimento) ** meses
        rendimento = self.saldo - saldo_inicial
        print(f"{self.titular}: rendimento de R$ {rendimento:,.2f} em {meses} meses | saldo: R$ {self.saldo:,.2f}")

    def __str__(self):
        return f"ContaPoupanca({self.titular}, saldo=R${self.saldo:,.2f})"


joao  = ContaCorrente("João", 1000)
maria = ContaPoupanca("Maria", 3000)

joao.depositar(200)   # depositar() herdado de ContaBancaria
joao.sacar(1400)      # sacar() sobrescrito — usa limite
joao.sacar(1400)      # limite excedido

print()
maria.depositar(500)
maria.render(5)       # render() próprio de ContaPoupanca

print()
# isinstance() verifica a hierarquia de herança
print(isinstance(joao, ContaCorrente))  # True
print(isinstance(joao, ContaBancaria))  # True — herda de ContaBancaria!
print(isinstance(joao, ContaPoupanca))  # False
João: depósito de R$ 200.00 | saldo: R$ 1,200.00
João: saque de R$ 1,400.00 | saldo: R$ -200.00
Limite excedido! Valor disponível: R$ 300.00

Maria: depósito de R$ 500.00 | saldo: R$ 3,500.00
Maria: rendimento de R$ 88.38 em 5 meses | saldo: R$ 3,588.38

True
True
False

8.8 Polimorfismo

Polimorfismo significa que objetos de classes diferentes respondem ao mesmo método de formas distintas. Em Python, isso acontece naturalmente quando subclasses sobrescrevem métodos da classe pai — o Python sempre chama a versão mais específica do objeto.

ContaBancaria, ContaCorrente e ContaPoupanca todas implementam __str__(), mas cada uma retorna uma representação diferente:

contas = [
    ContaBancaria("Ana Silva", 500),
    ContaCorrente("Bruno Lima", 800, limite=1000),
    ContaPoupanca("Carla Souza", 5000),
]

# O Python chama o __str__() correto para cada objeto automaticamente
for conta in contas:
    print(conta)
ContaBancaria(Ana Silva, saldo=R$500.00)
ContaCorrente(Bruno Lima, saldo=R$800.00, limite=R$1,000.00)
ContaPoupanca(Carla Souza, saldo=R$5,000.00)

8.9 Aplicação: Modelo de Solow

O Modelo de Solow descreve como o capital por trabalhador \(k_t\) evolui ao longo do tempo em direção a um estado estacionário \(k^*\).

A dinâmica é dada pela equação de acumulação de capital:

\[k_{t+1} = s \cdot f(k_t) + (1 - \delta - n) \cdot k_t\]

onde \(f(k) = k^\alpha\) é a função de produção Cobb-Douglas, \(s\) é a taxa de poupança, \(\delta\) é a taxa de depreciação e \(n\) é a taxa de crescimento populacional. O estado estacionário satisfaz \(k_{t+1} = k_t = k^*\):

\[k^* = \left(\frac{s}{\delta + n}\right)^{\frac{1}{1-\alpha}}\]

Com POO, encapsulamos toda a lógica em uma classe ModeloSolow:

  • Os parâmetros são atributos — facilitando comparar economias com diferentes configurações.
  • As equações são métodosproducao(), atualizar(), estado_estacionario().
  • O histórico da simulação é armazenado no próprio objeto, pronto para visualização.
import matplotlib.pyplot as plt

class ModeloSolow:
    """
    Modelo de Crescimento de Solow.

    Parâmetros
    ----------
    s     : taxa de poupança
    delta : taxa de depreciação
    n     : taxa de crescimento populacional
    alpha : elasticidade do capital na produção
    k0    : capital inicial por trabalhador
    """
    def __init__(self, s=0.2, delta=0.1, n=0, alpha=0.3, k0=1):
        self.s, self.delta, self.n = s, delta, n
        self.alpha                 = alpha
        self.k                     = k0
        self.historia              = [k0]

    def producao(self, k):
        """Função de produção Cobb-Douglas: y = k^alpha."""
        return k**self.alpha

    def atualizar(self):
        """Atualiza o capital para o próximo período."""
        y      = self.producao(self.k)
        self.k = self.s * y + (1 - self.delta - self.n) * self.k
        self.historia.append(self.k)

    def simular(self, T=100):
        """Simula T períodos."""
        for _ in range(T):
            self.atualizar()

    def estado_estacionario(self):
        """Calcula o estado estacionário teórico."""
        return (self.s / (self.n + self.delta))**(1 / (1 - self.alpha))

    def __str__(self):
        return (f"ModeloSolow(s={self.s}, delta={self.delta}, n={self.n}, "
                f"alpha={self.alpha})")


economia1 = ModeloSolow(s=0.25, delta=0.1, alpha=0.3)
economia2 = ModeloSolow(s=0.35, delta=0.1, alpha=0.3)  # taxa de poupança maior

economia1.simular(T=100)
economia2.simular(T=100)

print(economia1)
print(f"Capital inicial:        {economia1.historia[0]:.4f}")
print(f"Capital final:          {economia1.k:.4f}")
print(f"Estado estacionário k*: {economia1.estado_estacionario():.4f}")
print()
print(economia2)
print(f"Capital inicial:        {economia2.historia[0]:.4f}")
print(f"Capital final:          {economia2.k:.4f}")
print(f"Estado estacionário k*: {economia2.estado_estacionario():.4f}")

fig, ax = plt.subplots(figsize=(8, 4))
ax.plot(economia1.historia, label="Economia 1: $k_t$")
ax.plot(economia2.historia, label="Economia 2: $k_t$")
ax.axhline(economia1.estado_estacionario(), color="C0", linestyle="--", alpha=0.6)
ax.axhline(economia2.estado_estacionario(), color="C1", linestyle="--", alpha=0.6,
           label="Estado estacionário $k^*$")
ax.set_title("Convergência do capital no Modelo de Solow")
ax.set_xlabel("Período")
ax.set_ylabel("Capital por trabalhador ($k$)")
ax.legend()
plt.tight_layout()
plt.show()
ModeloSolow(s=0.25, delta=0.1, n=0, alpha=0.3)
Capital inicial:        1.0000
Capital final:          3.7001
Estado estacionário k*: 3.7024

ModeloSolow(s=0.35, delta=0.1, n=0, alpha=0.3)
Capital inicial:        1.0000
Capital final:          5.9830
Estado estacionário k*: 5.9874

8.10 Exercícios

  1. Crie uma classe Pais com os atributos nome, pib_per_capita (em US$) e inflacao (em %). Adicione:

    • Um método __str__ que exiba as informações formatadas.
    • Um método taxa_real(taxa_nominal) que calcule a taxa de juros real pela Equação de Fisher.
    • Validação no construtor: pib_per_capita deve ser positivo e inflacao deve ser um número.
    • Um @classmethod do_dicionario(cls, dados) que crie um Pais a partir de um dicionário.

    Instancie pelo menos 3 países e exiba os resultados.

  2. Crie uma classe SerieTemporalEconomica para armazenar uma série de dados (ex: IPCA mensal). A classe deve ter:

    • Atributos: nome (str), unidade (str, ex: "%"), valores (lista de floats).
    • Propriedades calculadas: media, minimo, maximo, amplitude.
    • Método acumulado() que calcule o valor acumulado usando a fórmula de produto: \(\prod(1 + v_i/100) - 1\).
    • Método __len__ que retorne o número de observações.
    • Método __add__ que concatene duas séries (retorne uma nova SerieTemporalEconomica com os valores combinados).
  3. Crie uma hierarquia de classes para instrumentos de renda fixa:

    • Classe base RendaFixa com atributos nome, valor_inicial e taxa_anual.
    • Classe filha CDB que herda de RendaFixa e adiciona banco e liquidez_diaria (bool).
    • Classe filha TesouroDireto que herda de RendaFixa e adiciona tipo (LTN, LFT etc.) e vencimento.
    • Ambas devem ter um método valor_futuro(anos) que calcule o montante com juros compostos.
    • Implemente __str__ em cada classe.

    Crie instâncias de ambas e compare seus valores futuros em 3 anos.

  4. Estenda a classe ModeloSolow com os seguintes recursos:

    • Um método __lt__(self, other) que compare dois modelos pelo estado estacionário \(k^*\) (o modelo com menor \(k^*\) é “menor”).
    • Um método __repr__ que retorne uma string que permita recriar o objeto.

    Crie três modelos com parâmetros diferentes e ordene-os com sorted().

  5. Crie uma classe EconomiaMundo que receba uma lista de objetos ModeloSolow e implemente:

    • Método resumo() que imprima uma tabela com os parâmetros e o \(k^*\) de cada modelo.
    • Método filtrar_poupanca(s_min) que retorne apenas os modelos com taxa de poupança acima de s_min.
    • Método convergencia_mais_rapida() que retorne o modelo que atinge 95% do seu \(k^*\) em menos períodos.