Roadmap Java - Parte 10: Classes, Objetos, Métodos e Construtores
- #Java
Este é o décimo artigo da série Roadmap Java (antes chamada "Aprendendo Java do Zero"). Recapitulando rapidamente: nas Partes 1 e 2, demos os primeiros passos e montamos o ambiente de desenvolvimento; nas Partes 3, 4 e 5, conhecemos variáveis, tipos, operadores e String; na Parte 6, vimos entrada e saída de dados com Scanner; na Parte 7, aprendemos a tomar decisões com estruturas condicionais e switch-case; e nas Partes 8 e 9, conhecemos as principais estruturas de dados do dia a dia: Arrays, loops, ArrayList e HashMap.
Até aqui, porém, todo programa desta série girou em torno de um único arquivo, com um único método main fazendo tudo. Isso funciona para exemplos pequenos, mas não se sustenta em programas maiores, onde é preciso organizar o código em torno de conceitos do mundo real (uma pessoa, um produto, uma conta bancária) para manter tudo administrável. É exatamente esse o papel de classes e objetos, a base da Programação Orientada a Objetos, um dos pilares que tornaram o Java tão popular em sistemas de grande porte. Neste artigo, nós vamos:
- entender a diferença entre classe e objeto, e como criar seus próprios objetos a partir de uma classe;
- entender o que é um método, e como ele difere de um simples bloco de código solto;
- conhecer a sobrecarga de métodos (method overloading) e as regras que a tornam possível;
- entender o que são construtores, incluindo o construtor padrão, a sobrecarga de construtores e o encadeamento entre eles.
Classes e Objetos em Java
Você já usa classes desde o primeiro artigo desta série, mas até agora sempre da mesma forma: uma única classe (Main), com um único método (main), executando tudo em sequência. O que muda a partir de agora é que você vai passar a criar suas próprias classes, para representar conceitos do seu programa.
Pense em uma classe como uma planta baixa, e em um objeto como a casa construída a partir dela. A planta descreve o que toda casa daquele modelo vai ter (quantos quartos, onde fica a cozinha), mas não é, ela mesma, uma casa habitável. Um objeto é a aplicação concreta dessa planta: você pode construir várias casas (objetos) diferentes a partir da mesma planta (classe), cada uma com seus próprios valores, mobiliada e pintada do seu próprio jeito.
class Pessoa {
String nome;
int idade;
}
public class Main {
public static void main(String[] args) {
Pessoa pessoa1 = new Pessoa();
pessoa1.nome = "José";
pessoa1.idade = 32;
Pessoa pessoa2 = new Pessoa();
pessoa2.nome = "Maria";
pessoa2.idade = 30;
System.out.println(pessoa1.nome + " tem " + pessoa1.idade + " anos.");
System.out.println(pessoa2.nome + " tem " + pessoa2.idade + " anos.");
}
}
A classe Pessoa declara dois atributos (também chamados de campos, ou fields): nome e idade. Repare que a sintaxe é a mesma de declarar qualquer variável, só que dentro da classe, e fora de qualquer método. Todo objeto criado a partir de Pessoa com o operador new vai ter sua própria cópia independente desses dois atributos: alterar pessoa1.idade não tem nenhum efeito sobre pessoa2.idade.
Um detalhe que já vimos, sem perceber sua importância, lá na Parte 7: atributos declarados no nível da classe recebem um valor padrão automaticamente, mesmo sem você inicializá-los explicitamente (0 para int, null para String, e assim por diante). Por isso, tecnicamente, o programa acima já compilaria mesmo que você removesse as duas linhas de atribuição: pessoa1.nome simplesmente valeria null, e pessoa1.idade valeria 0. Isso muda quando conhecermos construtores, mais adiante neste mesmo artigo: eles são a forma correta de garantir que um objeto já nasça com os valores certos, em vez de depender de atribuições soltas como as do exemplo acima.
Também vale registrar uma regra que já apareceu de relance na Parte 1, sobre a classe pública de nível superior de um arquivo: em Pessoa.java, você poderia ter tanto a classe pública Main quanto a classe Pessoa no mesmo arquivo (como no exemplo acima), desde que apenas uma delas seja public. Em projetos maiores, porém, o mais comum é que cada classe importante viva em seu próprio arquivo, com o nome do arquivo batendo com o nome da classe.
O que é um método?
Uma classe raramente serve só para guardar dados soltos; ela também costuma descrever comportamentos daquele tipo de objeto. Isso é feito através de métodos, que nada mais são do que funções declaradas dentro de uma classe.
class Pessoa {
String nome;
int idade;
void apresentar() {
System.out.println("Olá, meu nome é " + nome + " e tenho " + idade + " anos.");
}
}
public class Main {
public static void main(String[] args) {
Pessoa pessoa1 = new Pessoa();
pessoa1.nome = "José";
pessoa1.idade = 32;
pessoa1.apresentar(); // Olá, meu nome é José e tenho 32 anos.
}
}
Repare que, dentro do método apresentar(), usamos nome e idade diretamente, sem precisar escrever pessoa1.nome ou pessoa1.idade. Isso é possível porque um método de instância (isto é, um método sem static, como este) já roda automaticamente "dentro" de um objeto específico: quando você chama pessoa1.apresentar(), o método enxerga os atributos daquele objeto em particular. É esse "objeto específico ao qual o método pertence no momento da chamada" que, mais adiante nesta série, vamos nos referir formalmente pela palavra-chave this.
Assim como o main que você já conhece bem desde a Parte 1, métodos também podem receber parâmetros e devolver um valor:
class Calculadora {
int somar(int a, int b) {
return a + b;
}
}
public class Main {
public static void main(String[] args) {
Calculadora calc = new Calculadora();
int resultado = calc.somar(3, 4);
System.out.println(resultado); // 7
}
}
A palavra return encerra a execução do método imediatamente e devolve o valor indicado para quem o chamou. O tipo declarado antes do nome do método (int, no exemplo de somar) precisa bater com o tipo do valor retornado; e se o método não devolve nada, esse tipo é void, como já vínhamos usando desde public static void main e como no apresentar() do exemplo anterior.
O que é sobrecarga de métodos (Method Overloading)?
Java permite que uma mesma classe tenha vários métodos com o mesmo nome, desde que a lista de parâmetros seja diferente entre eles, seja na quantidade de parâmetros, seja no tipo. Isso é chamado de sobrecarga (overloading).
class Calculadora {
int somar(int a, int b) {
return a + b;
}
int somar(int a, int b, int c) {
return a + b + c;
}
double somar(double a, double b) {
return a + b;
}
}
public class Main {
public static void main(String[] args) {
Calculadora calc = new Calculadora();
System.out.println(calc.somar(3, 4)); // 7, usa a versão com 2 int
System.out.println(calc.somar(3, 4, 5)); // 12, usa a versão com 3 int
System.out.println(calc.somar(3.5, 4.2)); // 7.7, usa a versão com double
}
}
O compilador decide, em tempo de compilação, qual versão do método deve ser chamada, com base na quantidade e no tipo dos argumentos passados na chamada. É por isso que as três chamadas acima, embora usem o mesmo nome somar, disparam três métodos diferentes.
Duas regras importantes sobre sobrecarga:
- O tipo de retorno, sozinho, não é suficiente para diferenciar dois métodos. Não é possível ter
int somar(int a, int b)edouble somar(int a, int b)na mesma classe: para o compilador, a lista de parâmetros (int a, int b) é idêntica nos dois casos, então ele não teria como escolher entre eles ao ver uma chamadasomar(3, 4). - A ordem dos tipos dos parâmetros importa.
somar(int a, double b)esomar(double a, int b)são consideradas assinaturas diferentes e podem, sim, coexistir na mesma classe, mesmo usando os mesmos dois tipos.
Vale abrir um parêntese sobre um detalhe que pode gerar confusão mais adiante: quando existe mais de uma versão de um método que, em teoria, poderia atender à mesma chamada (por exemplo, se uma classe tivesse somar(int, int) e somar(long, long), e você chamasse somar(3, 4)), o Java aplica um critério de prioridade, preferindo sempre a conversão implícita mais "estreita" possível (do tipo mais parecido para o mais largo, lembra da conversão implícita que vimos na Parte 3?) antes de recorrer a autoboxing ou a parâmetros do tipo varargs (int...). Na prática, isso raramente chega a ser um problema no dia a dia, mas é útil saber que essa ordem de prioridade existe, caso você algum dia se depare com um comportamento de sobrecarga que pareça, à primeira vista, ambíguo.
Por fim, vale reforçar uma distinção que vai ficar mais clara quando falarmos de herança, mais adiante nesta série: sobrecarga (overloading) acontece dentro da mesma classe, com métodos de mesmo nome e assinaturas diferentes, resolvidos em tempo de compilação. Isso é uma coisa bem diferente de sobrescrita de método (override), que envolve uma subclasse substituindo o comportamento de um método já existente em sua superclasse, e que só é resolvida em tempo de execução. Os nomes parecidos ("overloading" e "override") costumam confundir quem está começando, mas os dois mecanismos resolvem problemas completamente diferentes.
O que são construtores em Java?
Lá no início deste artigo, criamos objetos Pessoa de um jeito um tanto manual: primeiro chamando new Pessoa(), depois atribuindo nome e idade em linhas separadas. Isso funciona, mas tem um problema: nada impede que alguém crie um objeto Pessoa e esqueça de preencher idade, deixando-a silenciosamente com o valor padrão 0. Um construtor resolve esse problema, garantindo que um objeto já nasça com os valores corretos, no exato momento em que é criado.
class Pessoa {
String nome;
int idade;
// construtor
Pessoa(String nome, int idade) {
this.nome = nome;
this.idade = idade;
}
void apresentar() {
System.out.println("Olá, meu nome é " + nome + " e tenho " + idade + " anos.");
}
}
public class Main {
public static void main(String[] args) {
Pessoa pessoa1 = new Pessoa("José", 32);
pessoa1.apresentar(); // Olá, meu nome é José e tenho 32 anos.
}
}
Um construtor é um método especial, chamado automaticamente sempre que você cria um novo objeto com new. Ele tem três características que o diferenciam de um método comum: tem exatamente o mesmo nome da classe, não declara nenhum tipo de retorno (nem mesmo void), e não pode ser chamado diretamente como objeto.Pessoa(); ele só roda através do new.
Repare no uso de this.nome = nome;. Como o parâmetro do construtor tem o mesmo nome do atributo da classe (nome), seria ambíguo escrever apenas nome = nome;: o Java entenderia que ambos os lados se referem ao parâmetro, e o atributo da classe nunca seria de fato preenchido. A palavra-chave this resolve essa ambiguidade, referindo-se explicitamente "ao atributo deste objeto", em contraste com a variável de mesmo nome que está mais próxima (o parâmetro). É perfeitamente possível dar nomes diferentes ao parâmetro e ao atributo para evitar esse conflito, mas usar o mesmo nome, junto com this, é uma convenção comum e bem aceita em código Java.
O construtor padrão
Se você não declarar nenhum construtor próprio, o Java automaticamente disponibiliza um construtor padrão (default constructor), sem parâmetros e sem nenhum corpo relevante, que foi exatamente o que aconteceu no primeiro exemplo deste artigo, com new Pessoa().
Assim que você declara qualquer construtor próprio, porém, esse construtor padrão deixa de ser fornecido automaticamente. Ou seja, depois de adicionar o construtor Pessoa(String nome, int idade) do exemplo acima, a chamada new Pessoa() (sem argumentos) deixaria de compilar; se você ainda quiser permitir a criação de uma Pessoa sem argumentos, precisa declarar esse construtor explicitamente, como qualquer outro.
Sobrecarga de construtores
Assim como métodos comuns, construtores também podem ser sobrecarregados, seguindo exatamente as mesmas regras que já vimos na seção anterior: várias versões do construtor, cada uma com uma lista de parâmetros diferente.
class Pessoa {
String nome;
int idade;
Pessoa(String nome, int idade) {
this.nome = nome;
this.idade = idade;
}
Pessoa(String nome) {
this.nome = nome;
this.idade = 0; // valor padrão para quem não informa idade
}
}
public class Main {
public static void main(String[] args) {
Pessoa pessoa1 = new Pessoa("José", 32);
Pessoa pessoa2 = new Pessoa("Maria"); // usa o construtor de um parâmetro
System.out.println(pessoa1.idade); // 32
System.out.println(pessoa2.idade); // 0
}
}
Encadeando construtores com this(...)
No exemplo acima, o segundo construtor duplica a linha this.nome = nome; do primeiro. Em uma classe pequena como essa, a duplicação é inofensiva, mas em uma classe com vários atributos e várias regras de inicialização, repetir a mesma lógica em cada construtor sobrecarregado vira uma fonte constante de bugs (bastaria mudar a regra em um construtor e esquecer de replicar no outro). Java resolve isso permitindo que um construtor chame outro construtor da mesma classe, usando this(...):
class Pessoa {
String nome;
int idade;
Pessoa(String nome, int idade) {
this.nome = nome;
this.idade = idade;
}
Pessoa(String nome) {
this(nome, 0); // delega para o construtor de dois parâmetros
}
}
Aqui, Pessoa(String nome) não repete nenhuma lógica de inicialização: ele simplesmente delega para Pessoa(String nome, int idade), passando 0 como idade padrão. Se algum dia a regra de inicialização mudar (por exemplo, validando que idade não pode ser negativa), basta alterar um único lugar. Duas restrições valem a pena guardar: a chamada a this(...) só é válida dentro de um construtor, e precisa ser, obrigatoriamente, a primeira instrução desse construtor.
Nota sobre versões mais recentes do Java: para classes cujo único papel é agrupar alguns dados relacionados, sem muito comportamento além disso, como seria o caso de umaPessoabem simples, o Java (a partir da versão 16) introduziu os records, uma forma bem mais enxuta de declarar esse tipo de classe:record Pessoa(String nome, int idade) {}já gera automaticamente um construtor, os métodos de leitura dos atributos e implementações deequals(),hashCode()etoString(), tudo isso numa única linha. Records não substituem classes tradicionais em todo cenário (eles têm limitações propositais, como atributos sempre finais), mas vale ter esse recurso no radar; vamos voltar a ele com mais calma depois de conhecermos encapsulamento e os métodos getter/setter, na próxima parte desta série.
O que vem a seguir
Neste décimo artigo, demos o primeiro passo na Programação Orientada a Objetos: entendemos a diferença entre classe e objeto, criamos nossos próprios métodos (incluindo a sobrecarga entre eles) e conhecemos os construtores, responsáveis por garantir que um objeto já nasça com seus atributos devidamente preenchidos, incluindo a sobrecarga e o encadeamento entre construtores diferentes.
Ainda ficou uma pergunta em aberto: no exemplo de ContaBancaria que citamos de relance no bônus sobre records, faria sentido permitir que qualquer trecho do programa alterasse o saldo de uma conta diretamente, sem qualquer validação? Na Parte 11 desta série, vamos responder isso com os tópicos "Modificadores de Acesso" e "Métodos Getter e Setter", entendendo como Java controla o que pode (ou não) ser acessado de fora de uma classe, e como isso sustenta o princípio de encapsulamento.
Até lá!
Este artigo é parte do Roadmap Java, uma série de introdução a Java escrita do zero, com progressão didática própria pensada para quem nunca programou na linguagem antes.
Artigos dessa série na DIO:
- Roadmap Java - Parte 1, Pré-requisitos e seu primeiro programa
- Roadmap Java - Parte 2, Instalando o Java, o IntelliJ IDEA e criando seu primeiro projeto local
- Roadmap Java - Parte 3, Variáveis e tipos primitivos
- Roadmap Java - Parte 4, Operadores
- Roadmap Java - Parte 5, Strings
- Roadmap Java - Parte 6: Entrada e Saída de Dados
- Roadmap Java - Parte 7: Estruturas condicionais, switch-case, escopo e valores padrão
- Roadmap Java - Parte 8: Arrays e Loops
- Roadmap Java - Parte 9: ArrayList e HashMap
- Roadmap Java - Parte 10: Classes, Objetos, Métodos e Construtores



