O Objeto como Máquina de Estados

Aula 2 — Programação Orientada a Objetos

Autor

Marcos M. Raimundo — Instituto de Computação, UNICAMP

Data de Publicação

26 de agosto de 2026

Slides Lista de aulas

1 Proposta da Aula

Na Aula 1, o construtor de Produto chamou setPreco() para validar o preço inicial. Deixamos uma pergunta em aberto: e se o preço vier negativo — o construtor deve “consertar” o valor silenciosamente, ou recusar a existência do objeto? Esta aula responde a essa pergunta formalizando uma ideia que já rondava a Aula 1: todo objeto bem projetado é a implementação física de uma máquina de estados finita, protegida por um construtor que age como guardião e por métodos que agem como transições validadas.

O roteiro, em quatro perguntas:

  1. O que muda quando paramos de ver uma classe como um “saco de variáveis com getters e setters” e passamos a vê-la como uma máquina de estados?
  2. O que é uma invariante, e por que ela é o verdadeiro motivo de existir do encapsulamento?
  3. Por que o construtor é matematicamente diferente dos outros métodos — e o que acontece quando ele falha em proteger essa diferença?
  4. Quando um objeto deve recusar uma operação lançando uma exceção, e como o Java distingue “argumento errado” de “momento errado”?

2 Do Saco de Variáveis ao Agente Ativo

Em C, uma struct agrupa dados, mas é inteiramente passiva — as funções que operam sobre ela vivem em outro lugar, e a responsabilidade de manter a consistência recai sobre quem escreve essas funções externas:

struct Pedido {
    int status;
};

// A funcao externa dita a regra
void pagar(struct Pedido* p) {
    p->status = 1; // Mutacao direta!
}

Em Java, a classe agrupa os dados e as funções que têm o direito de alterá-los. O objeto deixa de ser memória passiva e se torna um agente ativo: você não altera o estado diretamente, você faz um pedido, e o objeto decide se a transição é legal.

public class Pedido {
    private Estado status;

    // O objeto controla a propria mutacao
    public void pagar() {
        if (this.status.podePagar()) {
            this.status = Estado.PAGO;
        }
    }
}

Essa união indissociável entre dados e as regras que os governam é o que chamamos de encapsulamento forte.

O anti-padrão do Modelo Anêmico. Um dos maiores desserviços do ensino básico de Java é reduzir o encapsulamento a “deixar os atributos private” sem se importar com o resto. Se todo atributo private ganha automaticamente um getter e um setter cegos, a inteligência vazou de volta para fora da classe — é apenas uma struct glorificada:

public class PedidoAnemico {
    private String status;

    // Setter cego: permite qualquer absurdo!
    public void setStatus(String s) { this.status = s; }
    public String getStatus() { return this.status; }
}

// O erro ocorre externamente:
pedido.setStatus("ENTREGUE"); // E o pagamento?

A regra de ouro: se você precisa inspecionar os dados de um objeto (get) para decidir por fora e depois alterar seu estado (set), o design falhou. O objeto deve tomar as próprias decisões.

3 A Máquina de Estados Finita e o Objeto

Um Autômato Finito Determinístico (DFA) é um modelo matemático clássico da Ciência da Computação: um sistema com um número limitado de estados, que muda de um estado para outro através de transições disparadas por eventos, de forma determinística — para cada estado e cada evento, existe exatamente um próximo estado.

O mapeamento para Java é direto: os atributos privados (em conjunto) definem em qual estado o objeto se encontra; os métodos públicos são as únicas transições autorizadas. O encapsulamento, sob esta luz, ganha um propósito mais profundo do que “esconder”: ele impede que o código externo “teletransporte” o objeto para um estado ilegal, pulando as transições validadas.

Considere um Produto com três estados lógicos, definidos pelo seu estoque:

A invariante que a máquina inteira existe para proteger é simples e inegociável: \(\text{estoque} \ge 0\). O objeto nasce em CADASTRADO (via new Produto()), transita para DISPONÍVEL ao repor estoque, e só aceita vender() nesse estado. A defesa é feita em código, dentro do próprio método de transição:

public class Produto {
    private int estoque; // O Estado Oculto

    // A Transicao (Metodo)
    public void vender(int quantidade) {
        // 1. Verificacao da legalidade da transicao (Guarda da Invariante)
        if (quantidade <= 0) {
            throw new IllegalArgumentException("Quantidade deve ser positiva.");
        }
        if (this.estoque < quantidade) {
            throw new IllegalStateException("Transicao invalida: Estoque insuficiente.");
        }
        // 2. Execucao da mudanca de estado
        this.estoque -= quantidade;
    }
}

Se o estoque tem 2 unidades e uma falha no carrinho tenta vender(5), a máquina não processa a requisição e assume um estado impossível de \(-3\): o método interroga o estado atual antes de mudar qualquer byte, e aborta a operação — o padrão Fail-Fast, que voltará com mais rigor ainda no Bloco do construtor.

DicaSe os atributos de um objeto fossem todos public, a máquina de estados continuaria funcionando — só ficaria “mais rápida”? Por quê?

Escreva sua resposta e compare com um colega antes de avançar (2 min).

4 Invariantes: do Laço à Classe

O conceito de invariante não nasceu na Orientação a Objetos — vem da análise de algoritmos. Uma invariante de laço é uma propriedade que se mantém verdadeira antes da primeira iteração (inicialização), permanece verdadeira a cada volta (manutenção), e no fim do laço prova que o algoritmo alcançou seu objetivo (término). No Insertion Sort, por exemplo, a invariante é: “no início de cada iteração \(i\), o subvetor de índices \(0\) a \(i-1\) contém os elementos originais, já ordenados”. Essa garantia é o que permite provar a corretude do algoritmo sem precisar “torcer” para que dê certo no final.

A invariante de classe eleva essa mesma ideia da execução de um laço para toda a vida de um objeto na Heap: é uma regra que deve continuar verdadeira do nascimento (construtor) até qualquer ponto depois de qualquer método. Exemplos concretos:

  • ContaBancaria: “o saldo mais o limite de crédito aprovado deve ser sempre \(\ge 0\)”.
  • Triangulo: “a soma de dois lados deve ser sempre maior que o terceiro” (\(a+b>c\), e as outras duas permutações).
  • Carrinho: “o total deve ser sempre igual à soma dos preços dos itens” — uma propriedade derivada, que não deveria ter um setTotal(valor) — o total é resultado, não entrada.
public class Carrinho {
    private double total = 0.0; // Estado Oculto

    // A transicao oficial garante a Invariante automaticamente
    public void adicionarItem(Item i) {
        if (i != null && i.getPreco() > 0) {
            this.total += i.getPreco();
        }
    }
}

Se você expõe saldo como public numa ContaBancaria usada por 50 módulos diferentes, cada um dos 50 precisaria lembrar de checar o limite antes de alterar o saldo — e basta um esquecimento para quebrar a invariante e criar um risco financeiro real. Encapsulando o atributo, a regra existe em um único lugar: dentro do método que a protege.

5 O Construtor como Base da Indução

Para quem gosta de rigor matemático: a corretude de uma classe pode ser vista como uma prova por indução finita. Seja \(S\) o conjunto de todos os estados possíveis de um objeto, e \(V \subset S\) o subconjunto que respeita todas as invariantes.

  • Base (\(n=0\)): o construtor tem o dever de garantir que o objeto recém-criado \(o\) satisfaça \(o \in V\) desde o primeiro milissegundo de vida na Heap. Se os argumentos levariam a \(o \notin V\), o construtor deve falhar ativamente.
  • Passo indutivo (\(n \Rightarrow n+1\)): dado um objeto em estado válido \(s_n \in V\), qualquer método público invocado sobre ele deve obrigatoriamente resultar em \(s_{n+1} \in V\).

Se o construtor falha em estabelecer a base, todo o resto do sistema desmorona — os métodos assumem, sem verificar de novo, que o objeto já nasceu válido. Um construtor permissivo cria o que se chama de Objeto Zumbi: uma instância que ocupa espaço na Heap, mas cujos dados não fazem sentido, produzindo erros distantes e difíceis de rastrear.

public class Produto {
    private String nome;
    private double preco;

    /** Constructor: A Base da Inducao */
    public Produto(String nome, double preco) {
        // FAIL-FAST: Validacao agressiva das Invariantes Iniciais
        if (nome == null || nome.trim().isEmpty()) {
            throw new IllegalArgumentException("Nome obrigatorio.");
        }
        if (preco <= 0) {
            throw new IllegalArgumentException("Preco deve ser positivo.");
        }
        // Se sobreviveu as validacoes, o estado V nasce.
        this.nome = nome;
        this.preco = preco;
    }
}

Um erro comum de quem está aprendendo: “consertar” o dado ruim silenciosamente (if (preco < 0) this.preco = 0.0;). Isso é um erro de design grave — mascarar dados inválidos cria uma falsa sensação de segurança e esconde uma falha de validação que deveria ter sido pega antes, na interface ou na camada de serviço. O construtor deve ser um guardião honesto: se a entrada está errada, a responsabilidade é de quem tentou criar o objeto, e o sistema deve recusar imediatamente.

6 Engenharia de Métodos: CQS e Design by Contract

Depois que o construtor garante um nascimento íntegro, a responsabilidade de manter a máquina segura passa para os métodos. O princípio CQS (Command-Query Separation) exige que todo método público pertença a exatamente um destes dois papéis:

  • Comando (Command): executa uma transição e altera o estado observável. Deve retornar void. Ex.: pedido.cancelar().
  • Consulta (Query): devolve um dado ao chamador. É proibido ter efeito colateral — uma pergunta não pode alterar a resposta. Ex.: produto.getPrecoLiquido().

O perigo de misturar os dois: imagine verificarSaldo() que, a cada chamada, secretamente cobra R$ 0,50 de taxa. Um desenvolvedor que só queria mostrar o saldo na tela acaba drenando a conta do usuário sem querer. Separar Comandos de Consultas garante que perguntar é sempre uma operação segura e idempotente.

O Design by Contract formaliza essa mesma disciplina em termos de pré e pós-condições:

public void processarSaque(double valor) {
    // PRE-CONDICAO: Protegendo a transicao contra chamadas invalidas
    if (valor <= 0) throw new IllegalArgumentException("Valor negativo.");
    if (this.saldo < valor) throw new IllegalStateException("Saldo insuficiente.");

    this.saldo -= valor; // EXECUCAO DA TRANSICAO (Comando)

    // POS-CONDICAO e INVARIANTE (Garantida pelo design do codigo acima)
    assert this.saldo >= 0 : "Invariante violada apos o saque!";
}

Pré-condições são barreiras de entrada: se falham, a culpa é de quem chamou o método fora de hora ou com dado inválido. Pós-condições atestam que, cumpridas as pré-condições, o método devolve o objeto num estado onde todas as invariantes seguem válidas — se isso falhar, o bug é do autor da classe, não de quem a chamou.

DicaUm método getSaldoLiquido() calcula um imposto e, de quebra, atualiza um contador interno de “quantas vezes o saldo foi consultado”. Isso viola algum princípio desta aula?

Escreva sua resposta e compare com um colega antes de avançar (2 min).

7 Fail-Fast: Exceções como Defesa

Em C, uma função de saque devolveria 1 (sucesso) ou -1 (falha). O perigo estrutural: códigos de erro podem ser ignorados silenciosamente — se o chamador esquecer o if (resultado == -1), o programa segue rodando como se nada tivesse acontecido, e os dados começam a se corromper sem aviso.

Fail-Fast propõe o oposto: ao detectar uma pré-condição violada, o método lança uma exceção, interrompendo o fluxo imediatamente, na mesma linha da infração — impossível de ignorar.

Para blindar uma máquina de estados, você não precisa (ainda) criar suas próprias exceções — o kit padrão do Java já cobre os casos comuns:

  • IllegalArgumentException — a culpa é da carga: um argumento passado não faz sentido no domínio (ex.: idade negativa).
  • IllegalStateException — a culpa é do momento: o dado está correto, mas o objeto não está no estado certo para a operação (ex.: carrinho.finalizarCompra() num carrinho vazio).
  • NullPointerException — usada proativamente via Objects.requireNonNull(cliente, "..."), para recusar um null já na porta de entrada, em vez de deixar o erro estourar linhas abaixo.
public class Elevador {
    private boolean portaAberta = false;
    private boolean emMovimento = false;

    public void subir() {
        // 1. Protegendo contra transicao em Estado Invalido
        if (this.portaAberta) {
            throw new IllegalStateException("Nao pode subir com porta aberta.");
        }
        if (this.emMovimento) {
            throw new IllegalStateException("O elevador ja esta em movimento.");
        }
        // 2. Mutacao Segura (Comando)
        this.emMovimento = true;
    }
}

Essas três exceções são subclasses de RuntimeException (não-checadas) — não exigem throws na assinatura nem try-catch do lado de quem chama. A filosofia é: um erro de contrato (argumento ruim, chamada fora de hora) é bug de quem chamou, e bugs devem falhar de forma visível para serem corrigidos antes de produção, não silenciados com try-catch.

8 Identidade Física vs. Identidade Lógica

Se criarmos duas instâncias idênticas de Produto na Heap, elas são o mesmo objeto? Para a JVM, não — == compara endereços físicos. Para o negócio, muitas vezes sim: dois objetos com o mesmo SKU deveriam ser tratados como “o mesmo produto”.

Produto p1 = new Produto(1, "Teclado");
Produto p2 = new Produto(1, "Teclado");

System.out.println(p1 == p2);      // false (enderecos fisicos diferentes)
System.out.println(p1.equals(p2)); // false! (Java nao sabe que o ID 1 importa)

O equals() herdado de Object usa == por padrão — ele não conhece o seu domínio de negócio. Se seu sistema tenta usar um HashSet<Produto> para evitar duplicatas no carrinho, e dois Produto com o mesmo SKU foram instanciados em requisições diferentes, o Set vai aceitar os dois como distintos — e o cliente é cobrado duas vezes pelo mesmo item.

A correção: sobrescrever equals() para comparar o atributo que de fato define identidade de negócio (o SKU), e hashCode() em conjunto — nunca um sem o outro:

@Override
public boolean equals(Object o) {
    if (this == o) return true;                                  // 1. mesma identidade fisica?
    if (o == null || getClass() != o.getClass()) return false;    // 2. protecao de tipo
    Produto produto = (Produto) o;
    return this.sku == produto.sku;                               // 3. regra de negocio: SKU define identidade
}

@Override
public int hashCode() {
    return Objects.hash(sku); // o hash deve olhar APENAS para os campos do equals
}

Analogia do armazém: hashCode() é o número do corredor (busca rápida); equals() é a checagem da etiqueta quando você chega lá. Se equals() compara o SKU mas hashCode() usa outra coisa (a cor da embalagem, digamos), caixas iguais acabam em corredores diferentes — o sistema procura no corredor errado e afirma, silenciosamente, que o item não existe. É por isso que os dois métodos formam um contrato indissociável.

DicaPor que reescrever equals() sem reescrever hashCode() junto é mais perigoso do que não reescrever nenhum dos dois?

Escreva sua resposta e compare com um colega antes de avançar (2 min).

9 Conclusão

Voltando às quatro perguntas da abertura:

  1. Saco de variáveis vs. máquina de estados: uma classe deixa de ser um struct glorificado quando os métodos, não o mundo externo, decidem se uma mudança de estado é legal.
  2. Invariante e o motivo do encapsulamento: a invariante é a regra que nunca pode ser violada; o encapsulamento existe precisamente para que só o próprio objeto possa proteger essa regra.
  3. O construtor é diferente: ele é a base de uma indução — se falha em recusar dados ruins, todo o resto do sistema herda a corrupção sob a forma de “objetos zumbis”.
  4. Quando lançar exceção: IllegalArgumentException quando o dado está errado, IllegalStateException quando o momento está errado — e ambas devem interromper o fluxo imediatamente, não ser mascaradas.

Ponte para a Aula 3

Falamos da máquina de estados por dentro — invariantes, construtor, transições. A Aula 3 olha para as bordas dessa máquina: o contrato entre interface e implementação visto com mais rigor filosófico, a diferença entre o escopo de uma variável e o ciclo de vida de um objeto (o que já apareceu na Aula 1 com a Heap e o GC), e a identidade revisitada sob a ótica da imutabilidade.

10 Exercícios

10.1 Questões discursivas

  1. Numa auditoria de um sistema de pagamentos, encontrou-se a classe Transferencia com getters e setters cegos para todas as propriedades, sem lógica interna alguma. Usando a teoria de máquinas de estado e proteção de invariantes, explique por que o Modelo Anêmico é um risco inaceitável em software financeiro.

  2. Um estagiário validou salários no construtor de Funcionario assim: if (salario < 0) this.salario = 0;. Usando o conceito de Fail-Fast e a analogia da base da indução, critique esse mecanismo e reescreva o construtor de forma robusta.

  3. Dois usuários preenchem os mesmos dados num formulário e o sistema cria dois objetos Passaporte distintos, com dados idênticos. Explique por que p1.equals(p2) retorna false por padrão, e como corrigir esse comportamento para refletir a identidade lógica correta do domínio.

10.2 Questões de Verdadeiro/Falso

Cada bloco de 4 itens trata do mesmo tema. A questão só é considerada correta se todos os 4 itens forem julgados corretamente (deixar em branco tem penalidade de 20% da nota da questão).

DicaEstado, Comportamento e Identidade (revisitados)
  1. ( ) O Estado de um objeto é definido pelo conjunto de valores de seus atributos num dado instante.
  2. ( ) O Comportamento é a única via autorizada a alterar o Estado de um objeto bem projetado.
  3. ( ) Dois objetos com Estado idêntico são sempre a mesma Identidade física na Heap.
  4. ( ) A Identidade é garantida pela JVM no momento em que o objeto é instanciado via new.
DicaStruct Passivo vs. Agente Ativo
  1. ( ) Em C, uma struct agrupa dados e as funções que operam sobre eles na mesma unidade.
  2. ( ) Numa struct, a responsabilidade de manter a consistência do dado recai sobre a função externa que o manipula.
  3. ( ) Em Java, o objeto pode recusar uma mutação que viole suas próprias regras internas.
  4. ( ) A união entre dados e as regras que os protegem é o que caracteriza o encapsulamento forte.
DicaO Modelo de Domínio Anêmico
  1. ( ) Ocorre quando todos os atributos privados recebem getters e setters cegos, sem validação.
  2. ( ) É considerado um design rico, pois maximiza a flexibilidade de quem consome a classe.
  3. ( ) Esvazia o objeto de regra de negócio, reduzindo-o a uma estrutura de dados passiva.
  4. ( ) O design correto prefere métodos verbais de intenção (pagar(), cancelar()) a setters genéricos.
DicaA Máquina de Estados Finita (DFA)
  1. ( ) Um DFA tem um número limitado e conhecido de estados possíveis.
  2. ( ) Para cada par (estado, evento), existe exatamente um próximo estado, por definição de determinismo.
  3. ( ) Um DFA bem definido permite comportamentos “mágicos” e imprevisíveis em casos de borda.
  4. ( ) O determinismo de um DFA facilita a prova formal da corretude de um sistema.
DicaA Máquina de Estados Encapsulada em Java
  1. ( ) Atributos privados materializam, em conjunto, o “nó” atual da máquina de estados do objeto.
  2. ( ) Métodos públicos atuam como as transições — os únicos eventos autorizados a mudar o estado.
  3. ( ) Tornar os atributos públicos tornaria as transições mais eficientes, sem custo arquitetural.
  4. ( ) O encapsulamento impede que código externo “pule” transições e alcance um estado ilegal.
DicaInvariantes de Classe
  1. ( ) Uma invariante de classe deve permanecer verdadeira após a execução de qualquer método público.
  2. ( ) Uma propriedade derivada, como o total de um carrinho, dispensa proteção por invariante.
  3. ( ) Expor o estado publicamente distribui a responsabilidade de manter a invariante por todo o sistema.
  4. ( ) Centralizar a regra dentro do próprio objeto reduz o risco de um módulo externo esquecer de aplicá-la.
DicaO Construtor como Base da Indução
  1. ( ) O construtor corresponde à etapa “base” de uma prova por indução sobre a validade do objeto.
  2. ( ) Um construtor permissivo, que aceita qualquer dado, é uma boa prática para evitar exceções.
  3. ( ) Lançar uma exceção no construtor impede que um “Objeto Zumbi” passe a existir na Heap.
  4. ( ) Se a base da indução falha, os métodos subsequentes não podem mais confiar na validade do objeto.
DicaCQS: Comandos vs. Consultas
  1. ( ) Comandos são os únicos métodos autorizados a provocar mutação de estado.
  2. ( ) O CQS recomenda que Comandos retornem void, sinalizando ausência de dado de retorno.
  3. ( ) Uma Consulta pode alterar o estado interno, desde que devolva o dado correto ao chamador.
  4. ( ) Consultas idempotentes evitam efeitos colaterais acidentais ao simplesmente inspecionar um objeto.
DicaDesign by Contract
  1. ( ) Pré-condições avaliam se o pedido faz sentido antes da execução do método.
  2. ( ) Se uma pré-condição falha, a responsabilidade recai sobre quem escreveu a classe, não sobre o chamador.
  3. ( ) Pós-condições atestam que as invariantes permanecem válidas depois da transição.
  4. ( ) Uma pós-condição violada, após pré-condições satisfeitas, indica um bug no próprio método.
DicaFail-Fast e Exceções Padrão
  1. ( ) Códigos de erro como -1 podem ser silenciosamente ignorados por quem chama a função.
  2. ( ) IllegalArgumentException deve ser usada quando o objeto está no estado errado para a operação.
  3. ( ) IllegalStateException sinaliza que o dado é válido, mas o momento da chamada não é.
  4. ( ) Objects.requireNonNull(...) permite disparar NullPointerException proativamente, na entrada do método.
DicaIdentidade Física vs. Lógica
  1. ( ) O operador == compara sempre o endereço físico na Heap, nunca o conteúdo do objeto.
  2. ( ) O equals() herdado de Object, sem sobrescrita, já reflete a identidade lógica de negócio.
  3. ( ) Dois objetos podem ser fisicamente distintos (== falso) e ainda assim logicamente iguais para o negócio.
  4. ( ) Um HashSet pode aceitar duplicatas lógicas se equals() não foi corretamente sobrescrito.
DicaO Contrato equals()/hashCode()
  1. ( ) Se dois objetos são iguais por equals(), seus hashCode() devem obrigatoriamente coincidir.
  2. ( ) O hashCode() pode usar atributos completamente diferentes dos usados pelo equals(), sem risco.
  3. ( ) Violar o contrato equals()/hashCode() pode causar o desaparecimento silencioso de itens num HashMap.
  4. ( ) IDEs modernas geram os dois métodos juntos justamente para preservar essa coerência.