Programação Java
Fundamentos de orientação a objetos em Java: classes, objetos, campos e métodos
Iniciante10 min de leituraAtualizado
Esta página foi traduzida do inglês com a ajuda de IA e conferida com o original. Ler a versão em inglês
Este guia explica o modelo de objetos sobre o qual todo o resto do Java é construído: o que uma classe descreve, o que um objeto realmente é na memória, como funcionam construtores e métodos, por que o encapsulamento importa e quando um record substitui uma classe escrita à mão. Ao final, você será capaz de projetar uma classe pequena e correta e de explicar o que acontece em cada linha de um programa que a utiliza.
O que você vai aprender
- Diferenciar uma classe de um objeto, e uma referência do objeto para o qual ela aponta
- Escrever construtores, getters e métodos que mantêm válido o estado de um objeto
- Escolher de forma consciente entre membros de instância e membros static
- Implementar equals, hashCode e toString corretamente, ou deixar que um record faça isso
- Explicar como os argumentos são passados para métodos em Java e por que isso não é passagem por referência
Java é uma linguagem orientada a objetos num sentido bem específico: quase toda linha de código que você escreve fica dentro de uma classe, e quase todo valor que você manipula, fora os oito tipos primitivos, é um objeto acessado por meio de uma referência. Entender bem esse modelo cedo compensa por anos: a maioria dos bugs confusos que iniciantes encontram — uma NullPointerException, um método que "não altera" a variável passada para ele, dois objetos "iguais" que um HashSet trata como diferentes — vem de uma imagem nebulosa do que são uma classe, um objeto e uma referência. Este guia constrói essa imagem com código que compila no Java 21.
Classes descrevem, objetos existem
Uma classe é uma planta: ela declara qual estado (campos) e qual comportamento (métodos) um tipo de coisa tem. Um objeto é uma instância concreta criada a partir dessa planta em tempo de execução, que vive no heap com sua própria cópia de cada campo de instância. Você cria objetos com new, e o resultado de new é uma referência — um valor que aponta para o objeto, não o objeto em si.
public class BankAccount {
private final String iban; // set once, never changes
private long balanceCents; // mutable state, kept private
public BankAccount(String iban, long openingBalanceCents) {
if (openingBalanceCents < 0) {
throw new IllegalArgumentException("Opening balance cannot be negative");
}
this.iban = iban;
this.balanceCents = openingBalanceCents;
}
public void deposit(long cents) {
if (cents <= 0) throw new IllegalArgumentException("Deposit must be positive");
balanceCents += cents;
}
public long balanceCents() {
return balanceCents;
}
}
BankAccount a = new BankAccount("FR7630006000011234567890189", 10_000);
BankAccount b = a; // b now refers to the SAME object as a
b.deposit(500);
System.out.println(a.balanceCents()); // 10500 — there is only one account
É no segundo trecho que o modelo de referências faz diferença. b = a não copia a conta; copia a seta. As duas variáveis apontam para um único objeto, então uma alteração feita por b fica visível por a. Se você quer uma segunda conta, independente, precisa criá-la com new.
Dentro de um método, a palavra this significa "o objeto no qual este método foi chamado". Na maior parte das vezes você vai precisar dela para diferenciar um campo de um parâmetro com o mesmo nome, como no construtor acima.
Tipos primitivos versus referências
Java tem oito tipos primitivos (int, long, double, boolean, char, byte, short, float) cujas variáveis guardam o valor diretamente. Todos os outros tipos — String, arrays, suas próprias classes — são tipos de referência. Uma variável de referência também pode conter null, que significa "não aponta para nada", e desreferenciar null (chamar um método ou ler um campo através dela) lança NullPointerException. Desde o Java 15, a mensagem informa por padrão qual expressão era nula (because "order.customer" is null), o que torna esses erros muito mais rápidos de diagnosticar do que antigamente.
As classes wrapper Integer, Long, Double e companhia são tipos de referência que empacotam um primitivo para que ele possa viver em uma coleção. O auto-boxing esconde a conversão, mas não as consequências: valores Integer podem ser null, e comparar dois objetos Integer com == compara referências, não números. Use equals ou faça o unboxing antes.
Construtores e inicialização de objetos
Um construtor é executado exatamente uma vez por objeto, quando o new é avaliado, e sua função é deixar o objeto em um estado válido. Três regras definem como os construtores se comportam:
- Se você não declarar nenhum construtor, o compilador fornece um público, sem argumentos. Assim que você declara qualquer construtor, esse construtor padrão deixa de existir.
- Um construtor pode chamar outro construtor da mesma classe com
this(...)como primeira instrução, o que evita duplicar a lógica de validação. - Campos que você não atribui recebem valores padrão:
0,falseounull. Para um campofinal, o compilador obriga você a atribuí-lo em todos os construtores.
public class Temperature {
private final double celsius;
public Temperature(double celsius) {
this.celsius = celsius;
}
public Temperature() {
this(20.0); // delegate, do not repeat
}
public static Temperature ofFahrenheit(double f) { // static factory
return new Temperature((f - 32) * 5 / 9);
}
public double celsius() { return celsius; }
}
O método ofFahrenheit mostra o padrão da fábrica estática (static factory): um método static que retorna uma instância. Fábricas podem ter nomes descritivos, retornar uma instância em cache em vez de uma nova e retornar um subtipo — nada disso um construtor consegue fazer. Muitos tipos do JDK usam esse recurso: List.of, Integer.valueOf, LocalDate.of.
Prefira validar no construtor e lançar IllegalArgumentException a aceitar qualquer coisa e verificar depois. Um objeto que só pode existir em um estado válido é muito mais fácil de entender do que um que precisa ser verificado antes de cada uso.
Encapsulamento: campos privados, intenção pública
Encapsulamento significa que um objeto controla o próprio estado. Na prática: declare os campos como private, exponha o comportamento por meio de métodos e deixe a superfície pública tão pequena quanto possível. A questão não é cerimônia; é fazer com que cada regra sobre o estado fique em um único lugar. Se balanceCents fosse público, a regra "nunca negativo" teria de ser verificada de novo por cada trecho de código que mexe nele.
Getters e setters são a forma mecânica de expor estado, e as IDEs os geram com um atalho — justamente por isso são usados em excesso. Uma classe cuja API é getX/setX para cada campo não passa de uma struct com mais cerimônia. Em vez disso, pergunte quais operações o objeto oferece — deposit, withdraw, close — e exponha essas. Quando um acessor simples é o certo, o estilo moderno de Java costuma dispensar o prefixo get, seguindo o que os records geram.
Os modificadores de acesso, do mais ao menos restritivo:
| Modificador | Visível a partir de |
|---|---|
private | Somente a classe que o declara (incluindo suas classes aninhadas) |
| (nenhum, "package-private") | Qualquer classe do mesmo pacote |
protected | O mesmo pacote, mais subclasses em qualquer pacote |
public | Qualquer lugar |
O package-private é subutilizado. Uma classe auxiliar de que só o pacote precisa não deveria ter modificador nenhum; assim a API pública de uma biblioteca expõe só o que realmente deve expor.
Imutabilidade
Um objeto imutável nunca muda depois de construído: todos os campos são final, não há setters e qualquer componente mutável (uma List, um Date, um array) é copiado defensivamente na entrada e na saída. Objetos imutáveis podem ser compartilhados com segurança entre threads, usados como chaves de map e guardados em cache. String, LocalDate, BigDecimal e todas as classes wrapper são imutáveis, e as coleções retornadas por List.of e Map.of não são modificáveis. Adote a imutabilidade por padrão e acrescente mutabilidade só onde o domínio exigir.
Métodos, sobrecarga e como os argumentos trafegam
Um método tem um nome, um tipo de retorno (ou void), parâmetros e um corpo. Dois métodos de uma mesma classe podem ter o mesmo nome se suas listas de parâmetros forem diferentes; isso é sobrecarga (overloading), e o compilador escolhe a versão pelos tipos estáticos dos argumentos no ponto de chamada. Sobrecarregar com tipos que se convertem entre si por auto-boxing ou alargamento (int versus long versus Integer) leva a escolhas surpreendentes, então mantenha as sobrecargas claramente distintas.
Java passa todo argumento por valor. Para um primitivo, o valor é o número. Para um objeto, o valor é a referência. O método recebe sua própria cópia da referência, apontando para o mesmo objeto:
static void rename(Person p) {
p.setName("Grace"); // mutates the shared object — caller sees this
p = new Person("Ada"); // rebinds the local copy — caller does NOT see this
}
Depois que rename(person) é executado, person.getName() é "Grace". A reatribuição na segunda linha só mudou a variável do próprio método. Esse único fato explica a maioria das discussões do tipo "Java passa por referência — não, não passa".
Parâmetros de aridade variável (String... parts) permitem que um método aceite zero ou mais argumentos de um tipo; dentro do método, eles são um array. Coloque o parâmetro varargs por último e evite sobrecarregar um método varargs, porque a escolha do compilador fica difícil de prever.
Membros static: o namespace da classe
Um campo ou método static pertence à classe, e não a uma instância. Existe uma única cópia, inicializada quando a classe é carregada pela primeira vez, e você a chama como Math.max(a, b) ou Temperature.ofFahrenheit(98.6), sem objeto. Use static para:
- Constantes:
public static final int MAX_RETRIES = 3; - Funções utilitárias puras que operam apenas sobre seus argumentos (
Math,Collections,Objects). - Métodos de fábrica, como mostrado antes.
Não use static para estado que logicamente pertence a um objeto, e desconfie de estado static mutável em geral: ele é global, compartilhado por todas as threads e invisível para os testes. Um método static não pode usar this nem ser sobrescrito, então o uso intenso de static também bloqueia o polimorfismo, tema do próximo guia.
A palavra-chave static em uma classe aninhada significa que ela não captura uma referência a uma instância externa. Quase toda classe aninhada deveria ser static; uma inner class (não static) mantém silenciosamente viva a instância externa, uma fonte clássica de vazamento de memória.
equals, hashCode e toString
Toda classe herda de java.lang.Object três métodos que você vai sobrescrever com frequência.
toString() retorna uma descrição legível. A padrão (BankAccount@1b6d3586) é inútil em logs; sobrescreva-a logo no início.
equals(Object) define quando dois objetos representam o mesmo valor. A implementação padrão compara referências, então dois objetos Money distintos de €10 são diferentes até que você diga o contrário. Uma implementação correta é reflexiva, simétrica, transitiva, consistente e retorna false para null:
public final class Money {
private final long cents;
private final String currency;
public Money(long cents, String currency) {
this.cents = cents;
this.currency = Objects.requireNonNull(currency);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Money other)) return false; // pattern matching, Java 16+
return cents == other.cents && currency.equals(other.currency);
}
@Override
public int hashCode() {
return Objects.hash(cents, currency);
}
@Override
public String toString() {
return cents / 100 + "." + String.format("%02d", cents % 100) + " " + currency;
}
}
hashCode() precisa ser coerente com equals: objetos iguais devem produzir o mesmo hash. HashMap e HashSet usam o hash para escolher um bucket e só depois chamam equals, então um par incoerente faz as buscas falharem de formas que parecem aleatórias. Nunca sobrescreva um sem o outro, e nunca baseie nenhum dos dois em um campo mutável de um objeto que você pretende colocar em uma coleção baseada em hash: alterar o campo depois da inserção torna o elemento inalcançável.
Records: o compilador escreve para você
Desde o Java 16, um record declara em uma linha um portador de dados imutável e gera o construtor, os acessores, equals, hashCode e toString:
public record Money(long cents, String currency) {
public Money { // compact constructor: validation only
Objects.requireNonNull(currency);
if (cents < 0) throw new IllegalArgumentException("negative amount");
}
public Money plus(Money other) {
if (!currency.equals(other.currency)) throw new IllegalArgumentException("currency mismatch");
return new Money(cents + other.cents, currency);
}
}
O construtor compacto não tem lista de parâmetros e é executado antes de os campos serem atribuídos, o que faz dele o lugar certo para validação e normalização. Records podem ter fábricas estáticas e métodos de instância, e podem implementar interfaces, mas não podem declarar campos de instância extras nem estender uma classe. Quando sua classe é "só dados", um record é mais curto, correto por construção e deixa clara a intenção para quem lê.
Um exemplo completo
Juntando as peças — um pequeno modelo de pedidos com uma classe de entidade e dois records:
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
record Product(String sku, String name, long priceCents) {}
record OrderLine(Product product, int quantity) {
OrderLine {
if (quantity <= 0) throw new IllegalArgumentException("quantity must be positive");
}
long totalCents() { return product.priceCents() * quantity; }
}
public class Order {
private static long nextId = 1; // shared counter — fine for a single thread
private final long id;
private final List<OrderLine> lines = new ArrayList<>();
public Order() { this.id = nextId++; }
public void add(Product product, int quantity) {
lines.add(new OrderLine(product, quantity));
}
public long totalCents() {
long sum = 0;
for (OrderLine line : lines) sum += line.totalCents();
return sum;
}
public List<OrderLine> lines() {
return Collections.unmodifiableList(lines); // callers can read, not mutate
}
public long id() { return id; }
}
Order tem identidade (seu id) e muda ao longo do tempo, então é uma classe. Product e OrderLine são valores, então são records. O acessor lines() retorna uma visão não modificável em vez da lista interna — sem isso, quem chama poderia fazer order.lines().clear() e contornar todas as regras da classe. O contador static funciona aqui, mas não é thread-safe; o guia de concorrência mostra AtomicLong para fazer isso de verdade.
Erros comuns
Comparar objetos com ==. Em referências, == pergunta "é o mesmo objeto?", e não "é o mesmo valor?". Use equals e, com strings em particular, nunca confie em ==, mesmo que literais internados às vezes deem a impressão de que funciona.
Expor coleções internas. Retornar o próprio campo em um getter é entregar um acesso direto ao seu estado privado. Retorne uma visão não modificável ou uma cópia.
Sobrescrever equals sem hashCode. Explicado acima; o sintoma é um HashSet que contém "duplicatas" ou um HashMap que não encontra uma chave.
Estado static mutável. Um static List<Order> allOrders é uma variável global com sotaque de Java. Ele vaza memória, quebra testes que rodam na mesma JVM e não é seguro sob concorrência.
Construtores que fazem trabalho de verdade. Abrir arquivos, conectar a bancos de dados ou iniciar threads em um construtor torna a classe impossível de testar isoladamente e deixa escapar um objeto construído pela metade se esse trabalho lançar uma exceção. Construa de forma barata; faça o trabalho em um método explícito.
Classes aninhadas não static por acidente. Uma inner class guarda uma referência oculta à instância externa. Declare as classes auxiliares aninhadas como static, a menos que você realmente precise dessa referência.
Esquecer que campos têm valores padrão. Um int não atribuído vale 0 e uma referência não atribuída vale null, sem nenhum aviso. Atribua no construtor e declare os campos como final sempre que puder, para que o compilador pegue as omissões.
Boas práticas
- Torne todo campo
privatee toda classefinal, a menos que ela tenha sido projetada para extensão. - Prefira tipos de valor imutáveis; desde o Java 16, records são a escolha padrão para eles.
- Valide nos construtores e nos construtores compactos, para que objetos inválidos não possam existir.
- Implemente
toStringem tudo que for aparecer em uma linha de log. - Baseie
equals/hashCodeem campos imutáveis e gere-os (record, IDE ouObjects.hash) em vez de escrevê-los à mão. - Use static apenas para constantes, utilitários puros e fábricas.
- Dê a classes nomes de substantivos e a métodos nomes de verbos, e deixe o guia de padrões de código resolver o resto.
Principais pontos
- Uma classe é uma planta; um objeto é uma instância no heap; uma variável guarda uma referência a ele. A atribuição copia a referência, não o objeto.
- Construtores estabelecem um estado válido; fábricas estáticas são uma alternativa mais flexível, com nomes significativos.
- O encapsulamento concentra em um só lugar todas as regras sobre uma parte do estado: campos privados, métodos públicos escolhidos com intenção, visões não modificáveis das coleções internas.
- Java sempre passa por valor; o valor de um argumento do tipo objeto é uma referência, então os métodos podem alterar o objeto de quem chamou, mas não reatribuir a variável dele.
equalsehashCodeprecisam ser sobrescritos juntos e baseados em campos estáveis; records geram os dois corretamente.- Membros static pertencem à classe; use-os para constantes e utilitários, não para o estado dos objetos.
Fontes e leituras complementares
Perguntas frequentes
- Java passa argumentos por referência ou por valor?
- Estritamente por valor. Em um argumento do tipo objeto, o valor copiado é a referência, então o método pode alterar os campos do objeto, mas não pode fazer a variável de quem chamou apontar para outro objeto. Reatribuir o parâmetro dentro do método não tem nenhum efeito fora dele.
- Quando devo usar um record em vez de uma classe?
- Use um record quando o tipo for um portador de dados transparente e imutável: um ponto, um valor monetário, um DTO, uma chave de um map. Use uma classe quando o objeto tiver estado mutável, precisar esconder sua representação ou tiver um ciclo de vida com comportamento que vai além de um conjunto de acessores.
- Por que preciso sobrescrever hashCode quando sobrescrevo equals?
- HashMap e HashSet localizam um elemento primeiro pelo hashCode e só depois chamam equals. Se dois objetos iguais retornarem hash codes diferentes, um set vai aceitar os dois sem reclamar e um map não vai encontrar uma chave que você acabou de inserir. O contrato de java.lang.Object exige que objetos iguais tenham o mesmo hash code.
- Qual a diferença entre um campo static e um campo de instância?
- Um campo de instância tem um valor por objeto; um campo static tem um valor por classe, compartilhado por todas as instâncias e acessível sem nenhuma instância. Static serve para constantes, contadores compartilhados por todos os objetos e métodos utilitários, não para estado que pertence a um objeto específico.