Pular para o conteúdo

Programação Orientada a Objetos

Ciência da Computação Universitária · Tópico 4

Ver Slides Treinar

Material de apoio

O que este curso é e como usá-lo

Este é o quarto curso de Ciência da Computação Universitária: programação orientada a objetos — como expandir um script pequeno em um sistema que outras pessoas possam alterar com segurança: objetos que detêm seu estado, interfaces que ocultam sua lógica, e testes que mantêm a integridade enquanto você refatora. você refatora.

Limites de escopo, declarados uma vez: curso de estudo, não grau; Python 3 é o veículo, e cada fragmento de programa é original, autocontido e foi executado para gerar as saídas mostradas.

  • Unidades 4.1–4.2 constroem o objeto (classes, referências, barreiras); 4.3–4.4 conectam-nas; 4.5–4.6 mantêm o sistema ativo.

O hábito de projeto: cada classe é um contrato com seus chamadores — cada unidade nomeia o contrato, e cada exemplo é um programa que você pode executar.

Objetos e classes

Por que importa. Programas falham quando o estado está espalhado: uma classe agrupa estado e comportamento, garantindo exatamente um local para consulta. A partir de agora, cada sistema que você construir será classes conversando.

A ideia, com uma imagem. Uma classe é um projeto; uma instância (um objeto) é uma unidade construída a partir dele. __init__ executa na construção e inicializa os atributos da instância; os métodos recebem self, a própria instância. Atributos de classe residem na classe e são compartilhados por todas as instâncias. A atribuição nunca copia: uma variável é uma referência a um objeto, portanto dois nomes podem ser ** aliases** de um único objeto.

class Book:
    next_id = 1                # class attribute, shared

    def __init__(self, title, copies):
        self.id = Book.next_id; Book.next_id += 1
        self.title = title
        self.copies = copies   # instance attribute

    def take(self):
        self.copies -= 1; return self.copies
Grafico de objetos e referências
Dois nomes em um objeto: a e c são alias do mesmo Book, b é um segundo, igual mas distinto

Ordem de busca (uma derivação). obj.x resolve percorrendo para fora: primeiro o dicionário da instância (obj.__dict__), depois o dicionário da classe, então as classes base. Essa única regra explica a armadilha clássica: atribuir obj.next_id = 9 não toca a classe — ele a sombra apenas para aquela uma instância. id acima vai para o dicionário da instância; next_id permanece no dicionário da classe, assim os livros recebem ids 1, 2, 3, … e Book.next_id sempre armazena o próximo número disponível.

Exemplo resolvido 1 — uma classe, rastreada. Com Book acima:

a = Book('Signals', 3)
b = Book('Signals', 3)
c = a
a.take(); a.take()

Estado após: a.copies é $1$, b.copies é $3$ (intocado), e como c é a, c.copies também é $1$ — um objeto, dois nomes. a == b é False (igualdade padrão é identidade) enquanto a is c é True: igualdade compara valores (uma vez definidos como), identidade (is) compara objetos.

Exemplo resolvido 2 — o contador compartilhado. Após a e b acima, Book.next_id, a.next_id e b.next_id leem todos $3$ — o contador pertence à classe, então todo chamador concorda. Escreva a.next_id = 9 e apenas a lê $9$: a classe ainda diz $3$, e o próximo livro ainda recebe id $3$.

Erros comuns. Esquecer self no cabeçalho de um método. Mutar um atributo de classe através de uma instância e se perguntar por que outros discordam. Esperar que d = c faça uma cópia — ele cria um alias.

Resumo. Classe = projeto, instância = objeto; dicionário de instância sobrepuja dicionário de classe; atribuição cria alias, nunca copia; is é identidade, == é igualdade (definida).

Encapsulamento

Por que importa. Toda classe possui um invariante — uma afirmação verdadeira entre chamadas (uma cópia está na estante ou emprestada a um leitor, nada mais. Encapsulamento é a disciplina de manter essa afirmação verdadeira: o estado é privado, e as alterações passam por métodos que o validam.

A ideia, com uma ilustração. Por convenção, um sublinhado inicial marca o estado interno (_copies); os chamadores usam os métodos públicos — a fronteira da interface. Valide na entrada: o construtor e cada método mutável restabelece o invariante ou recusa.

class Copy:
    def __init__(self, title):
        if title == '': raise ValueError('empty title')
        self._title = title; self._out = False

    def issue(self):
        if self._out: raise ValueError('already out')
        self._out = True

    def give_back(self):
        if not self._out: raise ValueError('not out')
        self._out = False

    def is_out(self):        # public reader, no wall breach
        return self._out
Fronteira da interface
Os métodos públicos formam uma barreira: toda alteração de estado passa pelas verificações; os campos atrás dela são privados

A dedução (por que uma única entrada basta). Se todo caminho que toca _out passa por issue/give_back, o invariante (_out é exatamente True ou False) pode ser provado a partir de dois pequenos métodos. Adicione um único gravador sem verificação e a prova se perde — o número de locais a verificar cresce com cada chamador, assim como a taxa de erros.

Exemplo resolvido 1 — a barreira se mantém. c.issue() tem sucesso; c.issue() novamente eleva ValueError — o chamador não consegue atingir um estado impossível, e após a chamada falha o objeto permanece inalterado (verifique primeiro, logo não há necessidade de reversão). c.give_back(); c._out é False, e c.issue() funciona novamente: exatamente os estados True / False, nada mais, para sempre.

Exemplo resolvido 2 — o custo de um vazamento. Um colega apressado escreve c._out = 'y'. Nada os impede — sublinhado é convenção — e agora is_out() responde 'y': truthy, mas não True. Um chamado (if c.is_out():) armazena a cópia como ausente; outro (if c.is_out() is True:) armazena como disponível — dois leitores discordam sobre um objeto, estado que nenhum método poderia gerar. Custo de reparo escala com cada leitor de _out; o custo de validação foi dois statements de if. Essa assimetria é todo o argumento.

Erros comuns. Retornar a lista interna (return self._loans) — chamadores mutam seu estado privado; retorne uma cópia. Validando no construtor, mas não no setter. Escrevendo pares get/set para tudo: expor comportamento (available()), não armazenamento.

Resumo. Sublinhado marca intenção; métodos validam na porta; um caminho verificado por mudança mantém invariantes prováveis; exponha comportamento, não campos.

Composição e herança

Por que importa. Objetos se conectam de duas formas: herança (é-um: um Member é um Person) e composição (tem-um: um Library tem um Catalog). Escolher errado acopla classes tão fortemente que uma correção em classe base quebra seus filhos — o problema da classe base frágil problema.

A ideia, com uma imagem. Cadeias de herança e inicialização: super().__init__(...) percorre a cadeia, começando pela base. Composição delega: o objeto inteiro possui partes e encaminha chamadas.

class Person:
    def __init__(self, name):
        self._name = name

class Member(Person):
    def __init__(self, name, card_no):
        super().__init__(name)      # base first
        self._card = card_no; self._loans = 0

    def borrow(self):
        self._loans += 1; return self._loans
Diagrama de relação de classes
Dois tipos de conexão: herança indica is-a (triângulo vazio para a base); composição indica has-a (losango preenchido para a parte)

A derivação (ordem de inicialização). Member('Ana', 7) executa Member.__init__, cuja primeira ação é super().__init__('Ana') — logo Person.__init__ conclui antes que qualquer linha de Membro seja executada. A ordem é sempre base-primeiro descendo pela cadeia; pule a chamada super() e self._name não existirá quando Member precisar dela, falhando com um erro claro AttributeError em vez de corromper silenciosamente.

Exemplo resolvido 1 — delegação por composição. Um Library possui um Catalog (uma lista de Copy):

class Library:
    def __init__(self):
        self._shelves = []          # composition

    def add(self, copy): self._shelves.append(copy)

    def available(self):
        return sum(1 for c in self._shelves if not c.is_out())

Após adicionar 5 cópias e emitir 2, available() é $3$ — a biblioteca não possui lógica de empréstimo; ela consulta sua parte. Substitua a lista por um banco de dados similar a lista e Library permanece inalterado: as partes mudam, o todo mantém seu interface.

Exemplo resolvido 2 — a base frágil. A base Receipt.total() soma os itens; uma subclasse sobreescreve copiando o código de soma e subtraindo $10$. A base então ganha uma linha de fidelidade — cada recibo de desconto perde silenciosamente: a cópia é um fork. Correção: return super().total() - 10 — substitua delegando. Herança para is-a, composição para tudo o mais.

Erros comuns. Herdar para reutilizar dois métodos (composição ou uma função de módulo em vez disso). Esquecer super().__init__. Duplicar lógica base em uma substituição — o fork que fica desatualizado.

Resumo. is-a herda (e chama super()); has-a compõe (e delega); nunca copie lógica base em uma substituição.

Interfaces e polimorfismo

Por que importa. Sistemas grandes sobrevivem à mudança quando chamadores dependem de o que uma coisa faz, não de sua classe. Uma interface nomeia o comportamento; polimorfismo é um ponto de chamada executando muitas implementações.

A ideia, com uma imagem. Python é tipado dinamicamente: qualquer objeto com os métodos corretos serve — tipagem pato. Para tornar o contrato explícito, escreva um Protocol (ou abc.ABC) nomeando os métodos.

class Notifier(Protocol):
    def send(self, text: str) -> str: ...

class EmailNotify:
    def send(self, text): return f'email: {text}'

class SmsNotify:
    def send(self, text): return f'sms: {text}'
Sequência de dispatch dinâmico
Uma chamada, três etapas: a variável nomeia a interface, o objeto carrega o tipo de tempo de execução, o dispatch escolhe o send daquele tipo

A derivação (dispatch dinâmico). Quando n.send('due') executa, a busca de atributo de 4.1 resolve no tipo de tempo de execução — o próprio objeto, não a anotação da variável. Assim, um corpo de laço dispara para uma send diferente por elemento. Isso é dinâmico dispatch: o local de chamada é escrito uma vez, a seleção ocorre por objeto no momento da chamada.

Exemplo resolvido 1 — um laço, três emissores. Com um terceiro emissor QueueNotify da mesma forma e team = [EmailNotify(), SmsNotify(), QueueNotify()]:

for n in team:
    print(n.send('loan due'))

Três linhas para fora — email: loan due, sms: loan due, queue: loan vencido — e adicionar um quarto emissor não toca nenhuma linha existente: o chamador nunca foi informado das classes concretas. Este é o aberto/fechado formato: aberto a novos tipos, fechado para edição.

Exemplo resolvido 2 — substituição e sua armadilha. Um substituto que honra os slots do contrato invisivelmente. Um que restringe falha silenciosamente: uma SilentNotify.send que levanta erro em string vazia quebra um chamador cujo contrato dizia enviar nunca levanta erro. A regra de substituição (Liskov): pré-condições só podem alargar, garantias apenas crescer.

Erros comuns. Cadeias de isinstance — substitua por um método que cada classe implementa. Uma sobrecarga que adiciona uma pré-condição ou remove uma garantia: o chamador não pode saber, até quebrar.

Resumo. Nomeie o comportamento com um Protocolo; dependa do nome; o dispatch segue o tipo em tempo de execução; os substitutos devem respeitar o todo contrato.

Exceções e tratamento de erros

Por que importa. Os erros viajam. Uma função três níveis abaixo não pode corrigir uma falha, mas não deve ignorá-la. Exceções transportam um erro para a frame que pode decidir, executando a limpeza de cada frame no caminho.

A ideia, com uma imagem. raise lança; try/except captura por tipo; finally executa em todo caminho. Uma hierarquia personalizada permite que os chamadores capturem precisamente: base ampla, filhos específicos.

class LoanError(Exception): ...
class RenewalError(LoanError): ...
class LimitError(RenewalError): ...

class Loan:
    def __init__(self, days):
        self.days = days

def renew(loan, extra):
    if loan.days + extra > 30:
        raise LimitError(f'{loan.days}+{extra} exceeds 30')
    loan.days += extra
    return loan.days
Fluxo de exceção
Lançar em renov desfaz issue e main até um handler correspondente; finally executa na saída, em todo caminho

A derivação (valores de status vs exceções). Um retorno de $-1$ é um valor normal: o compilador e o runtime não anexam nada a ele, então um chamador que esquece de verificar continua calculando com nonsensens — a falha se espalha silenciosamente. Uma exceção, uma vez lançada, não pode ser ignorada: ela desfaz frame por frame (executando cada finally) até que algum except corresponda; se nenhum corresponder, o programa para com ruído. Erros ignoráveis são ignorados; erros não ignoráveis são tratados. Escolha valores de status para resultados esperados, próximos ao chamador (uma falha em busca), exceções para contratos quebrados e falhas, o chamador imediato não pode corrigir.

Exemplo resolvido 1 — captura precisa. renew(Loan(20), 15) levanta LimitError com mensagem 20+15 exceeds 30; o chamador captura RenewalError (um pai) e o empréstimo ainda lê $20$: verifique antes de executar significa que chamadas falhadas deixam o estado intacto. A hierarquia é um filtro — o chamador escolhe a granularidade.

Exemplo resolvido 2 — limpeza em ambos os caminhos. Um guardiã de lock:

locked = True
loan = Loan(28)
try:
    total = renew(loan, 10)          # raises
except RenewalError:
    kept = 'handled'
finally:
    locked = False

Rastreio: renew levanta; except executa (kept = 'handled'); finally executa; depois locked está False e o empréstimo ainda lê $28$ — liberado no caminho de falha. No caminho de sucesso finally executa apenas o mesmo. Limpeza que deve ocorrer vai em finally (ou um bloco with), nunca após a última instrução.

Erros comuns. except: nu — captura KeyboardInterrupt e oculta bugs reais. except ... pass — a falha desaparece e o estado fica metade atualizado. Capturar Exception quando se pretendia uma criança específica.

Resumo. Levante sobre contratos quebrados, capture no quadro que possa decidir, finally para limpeza; uma pequena hierarquia compra tratamento preciso; chamadas falhadas devem deixar o estado inalterado.

Testes e qualidade de design

Por que importa. Refatoração — alterar estrutura sem alterar comportamento — é seguro apenas quando o comportamento é fixado. Testes unitários são essa fixação, e os bons testam comportamentos, não métodos.

A ideia, com uma imagem. Um teste = um comportamento: arranjar os objetos, agir uma chamada, assertar o resultado observável do comportamento. Comportamentos, não métodos: renew past the limit raises é um comportamento; test_days é um método.

def test_renew_ok():
    loan = Loan(20)                  # arrange
    out = renew(loan, 7)             # act
    assert out == 27                 # assert

def test_renew_boundary():
    assert renew(Loan(20), 10) == 30     # exactly 30 is allowed

def test_renew_limit():
    loan = Loan(20)
    try:
        renew(loan, 11); assert False
    except LimitError:
        assert loan.days == 20       # unchanged
Mapa de cobertura de testes
Comportamentos para baixo, testes transversais: cada célula é um comportamento que um teste fixa; a célula vazia é a próxima a escrever

A derivação (por que primeiro comportamento). Um teste nomeado por método falha em toda renomeação e sobrevive a toda mudança de comportamento — ruído sem segurança; um teste nomeado por comportamento faz o oposto. A cobertura segue a lista de comportamentos: cada ramo do contrato é uma linha, e a grade mostra o próximo espaço vazio.

Exemplo resolvido 1 — três comportamentos, três testes. Para renew: dentro do limite ($20 + 7 = 27$), exatamente no limite ($20 + 10 = 30$ é permitido, porque a verificação é $> 30$), e após ele ($20 + 11$ lança exceção, estado inalterado). O teste de fronteira captura o erro clássico de escrever >= em vez de >. Executá-los imprime 3 passed.

Exemplo resolvido 2 — refatorar sob verde. A multa do empréstimo cálculo move-se para fora de Loan para uma classe FineCalculator (único princípio de responsabilidade: uma das razões para alterar). O três testes permanecem inalterados, ainda 3 passed — comportamento preservado enquanto a estrutura se move: acoplamento diminuiu, coesão aumentou. para cima.

Erros comuns. Assertar implementações privadas — refactoring quebra tais testes. Estado mutável compartilhado nos testes (o teste B passa apenas após o teste A ser executado). Busca por cobertura de linhas em getters enquanto a ramificação limite não tem testar.

Resumo. Arrange-act-assert por comportamento; os testes fixam o comportamento para que refactoring seja seguro; SRP reduz o custo de alteração; baixo acoplamento e alta coesão são a direção de toda mudança.

Vocabulário
English Português
object-oriented programming/ˈɒbdʒekt ˈɔːrɪəntɪd ˈprəʊɡræmɪŋ/ programação orientada a objetos
class/klæs/ classe
object/ˈɒbdʒekt/ objeto
instance/ˈɪnstəns/ instância
instance attributes atributos de instância
Class attributes atributos de classe
reference/ˈrefrəns/ referência
aliases alias
equality/iːˈkwɒlɪti/ igualdade
identity/aɪˈdentɪti/ identidade
invariant/ɪnˈveərɪənt/ invariante
encapsulation/ɪnˌkæpsjʊˈleɪʃn/ encapsulamento
inheritance/ɪnˈherɪtəns/ herança
composition/ˌkɒmpəˈzɪʃn/ composição
fragile base class classe base frágil
interface/ˈɪntəfeɪs/ interface
polymorphism/ˈpɒlɪmɔːfɪzəm/ polimorfismo
duck typing tipagem de pato
dynamic dispatch despacho dinâmico
substitution/ˌsʌbstɪˈtjuːʃn/ substituição
exceptions exceções
unit tests testes unitários
coupling acoplamento
cohesion/kəʊˈhiːʒn/ coesão
single responsibility principle princípio da responsabilidade única
runtime type tipo em tempo de execução
open/closed aberto/fechado
arrange organizar
act/ækt/ agir
assert assertar

Aulas interativas sobre este tópico

Passe por ele passo a passo, com exercícios de verificação instantânea.

Mais tópicos em Ciência da Computação Universitária

Entrar ou criar conta

IGCSE, A-Level & AP