Como uma classe protege seus dados, garante as próprias regras e decide o que o resto do programa pode ver e tocar.
"Moldes e Instâncias" terminou com um defeito exposto: a classe ContaBancaria recusava saques maiores que o saldo, mas qualquer linha do programa podia escrever bruno.saldo = -5000 e ignorar a regra. Uma classe que tem regras, mas não consegue fazê-las valer, é uma sugestão, não uma garantia. Esta aula apresenta o encapsulamento, a ideia de esconder os dados de um objeto e expor só as operações que mantêm esses dados válidos. Você também vai conhecer os modificadores de acesso do Java, os campos final e static, e o motivo pelo qual o String nunca muda.
Invariantes: as regras que nunca podem ser quebradas
Toda classe bem projetada tem invariantes, condições que devem ser verdadeiras para todo objeto, durante toda a vida dele. Para a conta bancária:
- o saldo nunca é negativo;
- toda conta tem um titular, e o titular não é um texto vazio;
- o número da conta nunca muda.
O papel do encapsulamento é tornar essas regras impossíveis de violar de fora da classe. Se só os métodos da própria classe podem alterar o saldo, e todos eles respeitam a regra, então nenhum trecho do programa, escrito por quem quer que seja, consegue produzir uma conta com saldo negativo. Isso muda o tipo de raciocínio necessário. Para saber se a regra do saldo é respeitada, você não precisa ler o programa inteiro, só a classe.
private: escondendo os campos
O modificador private torna um membro visível apenas dentro da própria classe:
public class ContaBancaria {
private String titular;
private double saldo;
// ...
}
Agora a linha que quebrava a regra não compila mais:
Hack.java:4: error: saldo has private access in ContaBancaria
c.saldo = -5000;
^
Mas se ninguém de fora pode ler o saldo, como mostrá-lo? Com um método público que devolve o valor, chamado por convenção de getter:
public double getSaldo() {
return saldo;
}
O nome segue a convenção getCampo, e para campos boolean a convenção é isCampo (por exemplo, isAtiva()). Muitas bibliotecas do ecossistema, como as que convertem objetos para JSON, reconhecem esse padrão de nomes.
O ponto central: não há um setSaldo. O saldo só muda por depositar e sacar, que são operações com significado no domínio de um banco e que verificam as regras. Um iniciante costuma gerar automaticamente um getter e um setter para cada campo, e isso desfaz o encapsulamento: um setSaldo(double) público é a mesma coisa que um campo público, com mais linhas. Pergunte sempre quais operações fazem sentido para o objeto, e não quais campos ele tem.
Os quatro níveis de acesso
O Java tem quatro níveis de visibilidade, do mais aberto ao mais fechado:
| Modificador | Visível para |
|---|---|
public |
qualquer classe |
protected |
o mesmo pacote e as subclasses |
| (nenhum) | só as classes do mesmo pacote |
private |
só a própria classe |
O nível sem modificador é chamado de acesso de pacote (package-private). Era ele que os campos de ContaBancaria tinham na aula anterior. Todas as classes do curso até aqui estão numa mesma pasta, sem pacote declarado, e por isso enxergavam os campos umas das outras. Pacotes são assunto de "Do Arquivo Solto ao Projeto", e o protected aparece na próxima aula, com a herança.
A regra prática é começar pelo mais restrito que funcione e só abrir quando houver motivo. Campos são private quase sempre. Métodos que fazem parte do que a classe oferece ao mundo são public. Métodos auxiliares, que só existem para organizar o código interno, são private. Tudo o que é public vira um compromisso: outras partes do programa vão depender daquilo, e mudar depois custa caro. O que é private pode ser reescrito à vontade, porque ninguém de fora sabe que existe.
Validar na entrada: o construtor como porteiro
Esconder os campos não basta se o objeto pode nascer inválido. O construtor é o porteiro: ele deve recusar dados que violem as invariantes. Mas um construtor não tem valor de retorno para dizer "não deu". A forma de recusar é lançar uma exceção:
public ContaBancaria(String titular, double saldoInicial) {
if (titular == null || titular.isBlank()) {
throw new IllegalArgumentException("Titular é obrigatório");
}
if (saldoInicial < 0) {
throw new IllegalArgumentException("Saldo inicial não pode ser negativo: " + saldoInicial);
}
this.titular = titular;
this.saldo = saldoInicial;
}
A instrução throw interrompe o construtor imediatamente, e o objeto não é criado. Sem tratamento, o programa para com a mensagem:
Exception in thread "main" java.lang.IllegalArgumentException: Titular é obrigatório
at ContaBancaria.<init>(ContaBancaria.java:15)
(<init> é o nome que os construtores recebem no bytecode.) Você já viu exceções serem lançadas pela biblioteca, como a ArrayIndexOutOfBoundsException e a NumberFormatException. Agora é o seu código que lança. Capturar e tratar exceções é assunto de "Quando o Contrato se Quebra". O que importa agora é a garantia: se um objeto ContaBancaria existe, ele é válido. Nenhum método precisa verificar de novo se o titular está preenchido.
Repare na ordem do teste titular == null || titular.isBlank(). O curto-circuito de "Operadores, Texto e Conversa com o Usuário" impede que isBlank() seja chamado sobre null, o que causaria uma NullPointerException. A guarda vem antes do que ela protege.
A mesma lógica vale para os métodos. Depositar um valor negativo seria uma forma disfarçada de saque sem verificação de saldo, então também é recusado. Como a verificação se repete, ela vira um método auxiliar privado:
public void depositar(double valor) {
exigirPositivo(valor);
saldo += valor;
}
private void exigirPositivo(double valor) {
if (valor <= 0) {
throw new IllegalArgumentException("O valor precisa ser positivo: " + valor);
}
}
final: campos que não mudam
O número da conta e o titular não devem mudar depois da criação. Um campo final só pode receber valor uma vez, no construtor ou na própria declaração:
private final int numero;
private final String titular;
O compilador garante duas coisas: que nenhum método altere esses campos depois, e que todo construtor atribua um valor a eles. Esquecer dá erro:
Final.java:3: error: variable x might not have been initialized
public Final() { }
^
Declarar final tudo o que não precisa mudar é um hábito que compensa. O leitor sabe de imediato que aquele valor é fixo, e o compilador impede que alguém o altere por engano.
static: o que pertence à classe
Queremos que cada conta receba um número sequencial automático: 1, 2, 3... O contador não pode ser um campo comum, porque cada objeto teria o próprio contador, sempre começando do zero. Ele precisa pertencer à classe, compartilhado por todas as instâncias. É o que o static faz com campos, como já fazia com métodos:
private static int proximoNumero = 1;
private final int numero;
public ContaBancaria(String titular, double saldoInicial) {
// ... validações
this.numero = proximoNumero++;
this.titular = titular;
this.saldo = saldoInicial;
}
public static int getContasCriadas() {
return proximoNumero - 1;
}
Existe um único proximoNumero, que vive enquanto o programa roda. Cada construtor copia o valor atual para o numero da conta nova e incrementa o contador. O método getContasCriadas é static porque fala da classe como um todo, e é chamado pelo nome da classe: ContaBancaria.getContasCriadas().
A combinação static final cria constantes da classe, com um valor só, fixo e compartilhado: public static final double CM_POR_POLEGADA = 2.54;. É assim que Math.PI e Integer.MAX_VALUE são declarados. Uma classe que só tem membros estáticos, como Math, costuma ter um construtor private para impedir que alguém crie objetos dela, o que não faria sentido:
public final class Conversoes {
public static final double CM_POR_POLEGADA = 2.54;
private Conversoes() { }
public static double polegadasParaCm(double polegadas) {
return polegadas * CM_POR_POLEGADA;
}
}
Use campos static mutáveis com moderação. Um contador global é aceitável num exemplo, mas estado compartilhado por todo o programa é difícil de testar e se torna um problema sério quando várias threads o alteram ao mesmo tempo, como você vai ver na Fase 7.
Não deixe o interior escapar
Vamos dar à conta um extrato, guardando os valores de cada movimentação num array interno. Há uma armadilha na hora de expor esse extrato:
private double[] movimentos = new double[4];
private int quantidadeDeMovimentos = 0;
public double[] getMovimentos() {
return movimentos; // ERRADO
}
Pela lição de "Muitos Valores sob um Nome", este getter devolve uma referência ao array interno. Quem o chama recebe acesso direto aos dados privados e pode alterá-los: conta.getMovimentos()[0] = 1_000_000;. O private virou enfeite. A correção é devolver uma cópia defensiva:
public double[] getMovimentos() {
return Arrays.copyOf(movimentos, quantidadeDeMovimentos);
}
A cópia tem exatamente o número de movimentos registrados, e quem a alterar altera só a própria cópia. Pela mesma razão, um construtor que recebe um array deve copiá-lo antes de guardar, para que quem passou o array não possa alterar o objeto depois. Encapsular não é só marcar campos como private. É garantir que nenhuma referência a um dado interno mutável escape da classe.
A classe completa
import java.util.Arrays;
import java.util.Locale;
public class ContaBancaria {
private static int proximoNumero = 1;
private final int numero;
private final String titular;
private double saldo;
private double[] movimentos = new double[4];
private int quantidadeDeMovimentos = 0;
public ContaBancaria(String titular, double saldoInicial) {
if (titular == null || titular.isBlank()) {
throw new IllegalArgumentException("Titular é obrigatório");
}
if (saldoInicial < 0) {
throw new IllegalArgumentException("Saldo inicial não pode ser negativo: " + saldoInicial);
}
this.numero = proximoNumero++;
this.titular = titular;
this.saldo = saldoInicial;
}
public ContaBancaria(String titular) {
this(titular, 0.0);
}
public int getNumero() {
return numero;
}
public String getTitular() {
return titular;
}
public double getSaldo() {
return saldo;
}
public void depositar(double valor) {
exigirPositivo(valor);
saldo += valor;
registrar(valor);
}
public boolean sacar(double valor) {
exigirPositivo(valor);
if (valor > saldo) {
return false;
}
saldo -= valor;
registrar(-valor);
return true;
}
public double[] getMovimentos() {
return Arrays.copyOf(movimentos, quantidadeDeMovimentos);
}
public String resumo() {
return String.format(Locale.of("pt", "BR"), "#%d %s: R$ %,.2f", numero, titular, saldo);
}
public static int getContasCriadas() {
return proximoNumero - 1;
}
private void exigirPositivo(double valor) {
if (valor <= 0) {
throw new IllegalArgumentException("O valor precisa ser positivo: " + valor);
}
}
private void registrar(double valor) {
if (quantidadeDeMovimentos == movimentos.length) {
movimentos = Arrays.copyOf(movimentos, movimentos.length * 2);
}
movimentos[quantidadeDeMovimentos++] = valor;
}
}
O método registrar resolve o problema do tamanho fixo dos arrays: quando o array interno enche, ele é trocado por uma cópia com o dobro do tamanho. Dobrar, em vez de aumentar uma posição por vez, faz com que as cópias sejam raras, e é exatamente o que o ArrayList faz por dentro, como você vai ver em "Listas, Conjuntos e Mapas". Do lado de fora, ninguém sabe que há um array, nem que ele cresce. Se amanhã o extrato passar a ser guardado de outra forma, só essa classe muda. Essa liberdade de trocar a implementação sem afetar quem usa a classe é o maior benefício do encapsulamento, maior até que a proteção contra erros.
Um teste:
ContaBancaria ana = new ContaBancaria("Ana", 1000.0);
ContaBancaria bruno = new ContaBancaria("Bruno");
ana.depositar(250.0);
ana.sacar(100.0);
ana.sacar(5000.0); // recusado: devolve false e não registra nada
IO.println(ana.resumo());
IO.println(bruno.resumo());
IO.println("Contas criadas: " + ContaBancaria.getContasCriadas());
IO.println(Arrays.toString(ana.getMovimentos()));
#1 Ana: R$ 1.150,00
#2 Bruno: R$ 0,00
Contas criadas: 2
[250.0, -100.0]
Imutabilidade: a forma mais forte de encapsular
A conta precisa mudar, porque o saldo muda. Mas muitos objetos representam valores que não têm motivo para mudar: um ponto no plano, uma data, uma quantia de dinheiro, um texto. Para esses, a forma mais segura de encapsular é tornar o objeto imutável: todos os campos private final, nenhum método que os altere, e operações que devolvem objetos novos em vez de modificar o atual.
public final class Ponto {
private final double x;
private final double y;
public Ponto(double x, double y) {
this.x = x;
this.y = y;
}
public double getX() { return x; }
public double getY() { return y; }
public Ponto deslocar(double dx, double dy) {
return new Ponto(x + dx, y + dy);
}
public String descrever() {
return "(" + x + ", " + y + ")";
}
}
Ponto origem = new Ponto(0, 0);
Ponto outro = origem.deslocar(3, 4);
IO.println(origem.descrever() + " " + outro.descrever()); // (0.0, 0.0) (3.0, 4.0)
deslocar não move o ponto. Cria outro, e a origem continua na origem. O final na declaração da classe impede que alguém crie uma subclasse mutável dela, uma brecha que só vai fazer sentido na próxima aula.
É exatamente assim que o String funciona, e agora dá para entender por que isso é uma virtude. Em "Muitos Valores sob um Nome", o compartilhamento de referências causou surpresas: alterar o array por uma variável mudava o que a outra via. Com objetos imutáveis, esse problema não existe. Duas variáveis podem apontar para o mesmo String sem risco, porque ninguém consegue alterá-lo. Não é preciso fazer cópias defensivas de um String, nem se preocupar com quem mais tem uma referência a ele. E, como você vai ver na Fase 7, objetos imutáveis podem ser compartilhados entre threads sem nenhuma sincronização. Classes pequenas que representam valores, como Ponto, são tão comuns que o Java ganhou um recurso só para elas, os records, tema de "Dados sem Cerimônia".
A regra de projeto é deixar imutável tudo o que puder ser imutável, e só tornar mutável o que precisa mudar.
Com os dados protegidos dentro das classes, a próxima pergunta é como aproveitar uma classe para criar outras. Um banco não tem só um tipo de conta: há contas correntes com tarifa e poupanças com rendimento, que compartilham quase tudo com a conta comum e diferem num detalhe ou dois. Copiar a classe inteira para mudar um método seria a pior solução possível. A alternativa do Java é a herança.
Fontes e leituras recomendadas
- Joshua Bloch, Effective Java, 3ª edição, itens 15 a 17: "Minimize the accessibility of classes and members", "In public classes, use accessor methods, not public fields" e "Minimize mutability". São os três itens que fundamentam esta aula, escritos por quem projetou boa parte da biblioteca do Java.
- Oracle, The Java Tutorials, "Controlling Access to Members of a Class": https://docs.oracle.com/javase/tutorial/java/javaOO/accesscontrol.html. A tabela de modificadores de acesso com exemplos.
- The Java Language Specification, Java SE 25, seção 6.6, "Access Control": https://docs.oracle.com/javase/specs/jls/se25/html/jls-6.html#jls-6.6. As regras formais de visibilidade, inclusive os casos sutis do
protected. - Martin Fowler, "TellDontAsk": https://martinfowler.com/bliki/TellDontAsk.html. Um texto curto sobre por que é melhor pedir ao objeto que faça algo do que extrair os dados dele e decidir de fora. É a razão de não haver
setSaldo. - David Parnas, "On the Criteria To Be Used in Decomposing Systems into Modules" (1972): https://dl.acm.org/doi/10.1145/361598.361623. O artigo que introduziu a ideia de ocultação de informação, décadas antes do Java. Continua atual.
Exercícios
Exercício 1
Um colega implementou getMovimentos() devolvendo o array interno diretamente, com return movimentos;. Escreva um trecho que use esse defeito para alterar o extrato de uma conta sem passar por depositar ou sacar, e explique por que a cópia defensiva o impede.
Ver resposta
✓ Resposta:
ContaBancaria conta = new ContaBancaria("Ana", 100);
conta.depositar(50);
double[] extrato = conta.getMovimentos();
extrato[0] = 1_000_000; // com o getter defeituoso, altera o array interno da conta
Com o getter defeituoso, extrato e o campo movimentos apontam para o mesmo array, e a atribuição altera o histórico interno da conta. O saldo não mudou, então o extrato passa a contradizer o saldo, e a invariante implícita de que o extrato explica o saldo foi quebrada sem que a classe soubesse. Com Arrays.copyOf, cada chamada devolve um array novo. Alterar a cópia não afeta a conta, e uma nova chamada a getMovimentos() devolve os valores originais. A regra geral é nunca devolver, e nunca guardar sem copiar, uma referência a um objeto mutável que faz parte do estado interno.
Exercício 2
Para cada membro abaixo de uma classe Pedido, escolha o modificador de acesso adequado e justifique: o campo itens, o método adicionarItem, o método calcularDescontoProgressivo (usado só dentro de calcularTotal), o método calcularTotal, e a constante LIMITE_DE_ITENS, que outras classes precisam consultar.
Ver resposta
✓ Resposta: itens: private, porque é estado interno e só deve mudar pelos métodos que respeitam as regras do pedido. adicionarItem: public, porque é uma operação que o resto do programa precisa usar, e é nela que se valida o limite de itens. calcularDescontoProgressivo: private, porque é um detalhe de implementação: se amanhã a regra de desconto mudar ou for dividida em três métodos, nenhum código externo quebra. calcularTotal: public, porque é uma informação que o pedido oferece ao mundo. LIMITE_DE_ITENS: public static final. É pública porque outras classes precisam dela, e pode ser pública sem risco porque é uma constante: ninguém consegue alterá-la. Esse é o único caso em que um campo público é aceitável sem ressalvas.
Exercício 3
Reescreva a classe Retangulo do exercício 3 de "Moldes e Instâncias" como uma classe imutável: campos privados e finais, validação de que largura e altura são positivas, getters, e um método redimensionar(double fator) que devolva um novo retângulo.
Ver resposta
✓ Resposta:
public final class Retangulo {
private final double largura;
private final double altura;
public Retangulo(double largura, double altura) {
if (largura <= 0 || altura <= 0) {
throw new IllegalArgumentException(
"Dimensões precisam ser positivas: " + largura + " x " + altura);
}
this.largura = largura;
this.altura = altura;
}
public double getLargura() { return largura; }
public double getAltura() { return altura; }
public double area() { return largura * altura; }
public double perimetro() { return 2 * (largura + altura); }
public boolean ehQuadrado() { return largura == altura; }
public Retangulo redimensionar(double fator) {
return new Retangulo(largura * fator, altura * fator);
}
}
Como o construtor valida e nada muda depois, todo Retangulo que existe tem dimensões positivas, para sempre. redimensionar passa pelo construtor, então um fator negativo ou zero também é recusado, sem nenhuma verificação extra. É uma vantagem da imutabilidade: toda criação passa pelo porteiro, e a validação fica num único lugar.
Exercício 4
Explique o que aconteceria se o campo proximoNumero fosse declarado sem static, e por que o campo numero não pode ser static.
Ver resposta
✓ Resposta: Sem static, cada objeto teria o próprio proximoNumero, inicializado com 1 na criação. O construtor copiaria esse 1 para o numero e incrementaria um contador que só aquela conta enxerga, e todas as contas acabariam com o número 1. Um contador compartilhado precisa pertencer à classe, e não às instâncias. Já o numero identifica uma conta específica. Se fosse static, existiria um único numero para todas as contas, e cada conta criada sobrescreveria o número de todas as outras. A pergunta para decidir é: "este dado descreve um objeto ou a classe inteira?". O número de uma conta descreve a conta. A quantidade de contas criadas descreve a classe.
Exercício 5
Alguém propõe acrescentar à conta um método public void setSaldo(double novoSaldo) com a validação if (novoSaldo < 0) throw ..., argumentando que ele respeita a invariante. Por que ainda é uma má ideia?
Ver resposta
✓ Resposta: Ele preserva a invariante do saldo não negativo, mas desfaz tudo o mais que a classe garante. Com setSaldo, qualquer código pode mudar o saldo sem que o movimento apareça no extrato, e o histórico deixa de explicar o saldo. Qualquer código pode "depositar" sem validar se o valor é positivo, e ninguém mais sabe se um saldo mudou por um depósito, um saque, um rendimento ou um erro. Além disso, as regras de negócio, como "saque exige saldo suficiente", passam a morar em quem chama setSaldo, espalhadas pelo programa, em vez de dentro da classe. Uma conta bancária real não tem a operação "definir saldo". Tem depósitos, saques, transferências e estornos. Os métodos públicos devem refletir as operações do domínio, e não os campos da classe. Se um ajuste administrativo for necessário, ele merece um método próprio, como estornar ou ajustar(valor, motivo), que também registre o que aconteceu.