O que Fica Dentro, o que Fica Fora — Encapsulamento e Modificadores de Acesso

O que Fica Dentro, o que Fica Fora — Encapsulamento e Modificadores de Acesso

Encapsulamento em Java: invariantes, campos private e getters sem setters automáticos, os quatro níveis de acesso, validação no construtor com exceções, campos final e static, cópias defensivas de arrays internos e objetos imutáveis, o motivo de o String nunca mudar.
Java

• • 17 min de leitura

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.

Comentários

Mais em Java

Extensão — Dados que Sobrevivem: SQL Essencial e JDBC com SQLite
Extensão — Dados que Sobrevivem: SQL Essencial e JDBC com SQLite

As trilhas deixam de viver só na memória: tabelas com regras que o banco…

Hierarquias Fechadas — Sealed Classes e Pattern Matching
Hierarquias Fechadas — Sealed Classes e Pattern Matching

Sealed classes e pattern matching em Java: permits, final, sealed e…

Ordem e Critério — Comparable, Comparator e Coleções Ordenadas
Ordem e Critério — Comparable, Comparator e Coleções Ordenadas

Ordenação em Java: Comparable e a ordem natural, o perigo de comparar…