O Objeto como Máquina de Estados
Aula 2 — Programação Orientada a Objetos
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:
- 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?
- O que é uma invariante, e por que ela é o verdadeiro motivo de existir do encapsulamento?
- Por que o construtor é matematicamente diferente dos outros métodos — e o que acontece quando ele falha em proteger essa diferença?
- 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.
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). Julgue também V ou F os quatro itens abaixo — a questão só conta se acertar os 4:
- □ Os atributos privados materializam os “nós” do grafo de estados do objeto.
- □ Os métodos públicos são os únicos gatilhos autorizados a processar uma mudança de estado.
- □ Tornar o atributo
estoquepúblico tornaria as transições mais rápidas, sem custo arquitetural. - □ Um DFA bem definido garante que, para cada estado e evento, exista exatamente uma transição válida.
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 umsetTotal(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.
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). Julgue também V ou F os quatro itens abaixo — a questão só conta se acertar os 4:
- □ Um Comando deve, em geral, retornar
void, sinalizando que sua função é mutar o estado, não devolver dado. - □ Uma Consulta pode alterar o estado interno do objeto, desde que devolva o valor correto ao chamador.
- □ Se uma pré-condição falha, a responsabilidade recai sobre quem chamou o método fora de hora ou com dado inválido.
- □ Se uma pós-condição falha após pré-condições válidas, o bug é do autor da classe, não de quem a chamou.
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 viaObjects.requireNonNull(cliente, "..."), para recusar umnulljá 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.
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). Julgue também V ou F os quatro itens abaixo — a questão só conta se acertar os 4:
- □ O operador
==em Java compara sempre a identidade física — o endereço na Heap. - □ O
equals()herdado deObject, sem sobrescrita, já entende qual atributo define a igualdade de negócio. - □ Se dois objetos são iguais por
equals(), seushashCode()devem obrigatoriamente coincidir. - □ Um
HashSetpode aceitar “duplicatas” de negócio seequals()não foi sobrescrito para refletir a identidade lógica correta.
9 Conclusão
Voltando às quatro perguntas da abertura:
- Saco de variáveis vs. máquina de estados: uma classe deixa de ser um
structglorificado quando os métodos, não o mundo externo, decidem se uma mudança de estado é legal. - 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.
- 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”.
- Quando lançar exceção:
IllegalArgumentExceptionquando o dado está errado,IllegalStateExceptionquando 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
Numa auditoria de um sistema de pagamentos, encontrou-se a classe
Transferenciacom 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.Um estagiário validou salários no construtor de
Funcionarioassim: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.Dois usuários preenchem os mesmos dados num formulário e o sistema cria dois objetos
Passaportedistintos, com dados idênticos. Explique por quep1.equals(p2)retornafalsepor 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).