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:

  1. 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.
  2. 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.
  3. Campos que você não atribui recebem valores padrão: 0, false ou null. Para um campo final, 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:

ModificadorVisível a partir de
privateSomente a classe que o declara (incluindo suas classes aninhadas)
(nenhum, "package-private")Qualquer classe do mesmo pacote
protectedO mesmo pacote, mais subclasses em qualquer pacote
publicQualquer 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 private e toda classe final, 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 toString em tudo que for aparecer em uma linha de log.
  • Baseie equals/hashCode em campos imutáveis e gere-os (record, IDE ou Objects.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.
  • equals e hashCode precisam 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.