Como dar nome a um pedaço de código, passar informações para ele, receber um resultado de volta, e até fazê-lo chamar a si mesmo.
O menu que fechou "O Programa que Escolhe e Repete" funcionava, mas o main tinha crescido demais: mostrava o menu, lia valores, validava, calculava e formatava, tudo no mesmo lugar, com a leitura de valores repetida em dois ramos do switch. Programas reais têm milhares de linhas, e ninguém consegue raciocinar sobre milhares de linhas de uma vez. A ferramenta básica para enfrentar isso é o método: um trecho de código com nome, que recebe dados, faz uma tarefa e pode devolver um resultado. Nesta aula você vai aprender a escrever métodos, entender exatamente o que acontece com os valores passados para eles e conhecer a recursão, um método que resolve um problema chamando a si mesmo.
Anatomia de um método
Você já usa métodos desde a primeira aula: IO.println, Math.sqrt, texto.length(). Agora vamos escrever os nossos.
void main() {
double area = areaDoCirculo(2.0);
IO.println("Área: " + area);
saudar("Marina");
IO.println(maior(7, 12));
}
double areaDoCirculo(double raio) {
return Math.PI * raio * raio;
}
void saudar(String nome) {
IO.println("Olá, " + nome + "!");
}
int maior(int a, int b) {
if (a > b) {
return a;
}
return b;
}
Área: 12.566370614359172
Olá, Marina!
12
Cada declaração de método tem quatro partes. Olhando para double areaDoCirculo(double raio):
- O tipo de retorno (
double) diz que tipo de valor o método devolve. Quando o método não devolve nada, comosaudar, o tipo de retorno évoid, que significa "vazio". - O nome (
areaDoCirculo) segue a mesma convenção das variáveis, em camelCase. Como um método faz algo, a convenção é usar um verbo ou uma expressão que descreva o resultado:calcularFrete,ehPar,areaDoCirculo. - Os parâmetros (
double raio), entre parênteses, são as variáveis que recebem os valores passados na chamada. Um método pode ter nenhum, um ou vários, separados por vírgula. - O corpo, entre chaves, é o código executado.
A instrução return encerra o método e entrega o valor. Na chamada areaDoCirculo(2.0), o valor 2.0 é o argumento: ele é copiado para o parâmetro raio, o corpo executa e o resultado substitui a chamada na expressão, como se lá estivesse escrito 12.566....
Num arquivo compacto, os métodos são escritos ao lado do main, em qualquer ordem. O Java não exige que um método seja declarado antes de ser usado, ao contrário do C, que exige protótipos. Numa classe clássica há um detalhe a mais, a palavra static, que ganha explicação completa em "Moldes e Instâncias", duas aulas à frente.
return: sair e entregar
O método maior mostra que um método pode ter vários return. Assim que um deles é executado, o método termina, e o resto do corpo é ignorado. Esse "retorno antecipado" costuma deixar o código mais simples que uma estrutura com else.
O compilador exige que todo caminho possível de um método com tipo de retorno termine num return:
int sinal(int n) {
if (n > 0) {
return 1;
} else if (n < 0) {
return -1;
}
}
E1.java:8: error: missing return statement
}
^
Para você, é óbvio que falta o caso n == 0. O compilador não sabe se você esqueceu ou não, e exige uma decisão. Um return 0; no fim resolve. É mais um erro que em C passaria silenciosamente, com o método devolvendo lixo, e que o Java acusa antes da execução.
Um método void pode usar return; sem valor, apenas para sair mais cedo.
Passagem por valor
Esta é a regra mais importante da aula: em Java, os argumentos são sempre passados por valor. O método recebe uma cópia do valor, e não a variável original.
void main() {
int numero = 10;
dobrar(numero);
IO.println("Depois de dobrar: " + numero);
numero = dobrado(numero);
IO.println("Usando o retorno: " + numero);
}
void dobrar(int x) {
x = x * 2;
IO.println("Dentro do método: " + x);
}
int dobrado(int x) {
return x * 2;
}
Dentro do método: 20
Depois de dobrar: 10
Usando o retorno: 20
O método dobrar alterou x, que é a cópia dele. A variável numero do main não foi tocada. Se a intenção é produzir um valor novo, o caminho é devolvê-lo com return e deixar que quem chamou decida o que fazer com ele, como em dobrado.
Quem vem do C talvez espere passar um ponteiro para alterar a variável original. Em Java não existem ponteiros explícitos, nem uma forma de um método alterar uma variável local de outro método. Isso é uma garantia valiosa: ao ler dobrar(numero), você sabe que numero continua valendo 10 depois da chamada, sem precisar abrir o método para conferir.
A regra tem uma sutileza com objetos, porque uma variável de objeto guarda uma referência, como você viu com o String. A cópia é da referência, e as consequências disso aparecem com clareza na próxima aula, com os arrays.
Escopo: cada variável no seu lugar
As variáveis declaradas dentro de um método, incluindo os parâmetros, são locais: existem só durante a execução daquele método e só são visíveis dentro dele. O x de dobrar e o x de dobrado são variáveis completamente independentes, que por acaso têm o mesmo nome. Cada chamada cria as suas, e elas desaparecem quando o método termina.
Isso permite que cada método seja entendido isoladamente. Para saber o que areaDoCirculo faz, basta ler as três linhas dele. Nenhuma variável de outro lugar pode interferir. Quanto menos um trecho de código depende do resto, mais fácil é entendê-lo, testá-lo e alterá-lo sem quebrar o que está em volta.
Sobrecarga: mesmo nome, parâmetros diferentes
Dois métodos podem ter o mesmo nome se tiverem listas de parâmetros diferentes, em quantidade ou em tipos. É a sobrecarga:
void main() {
IO.println(area(2.0)); // círculo
IO.println(area(3.0, 4.0)); // retângulo
IO.println(area(5)); // int convertido para double: círculo
}
double area(double raio) {
return Math.PI * raio * raio;
}
double area(double largura, double altura) {
return largura * altura;
}
12.566370614359172
12.0
78.53981633974483
O compilador escolhe qual método chamar pelos argumentos. Na terceira chamada, o 5 é int, e o alargamento automático de "Cada Valor em sua Caixa" o transforma em double para caber na versão de um parâmetro. Você já usa sobrecarga desde a primeira aula: o System.out.println do programa clássico aceita textos, inteiros, reais e booleanos porque há uma versão dele para cada tipo.
O tipo de retorno sozinho não diferencia métodos, porque o compilador não teria como escolher numa chamada que ignora o resultado:
E2.java:3: error: method calcular(int) is already defined in class E2
Use a sobrecarga quando os métodos fazem a mesma coisa com entradas diferentes. Se fazem coisas diferentes, merecem nomes diferentes.
Varargs: quantos argumentos quiser
Às vezes um método deve aceitar qualquer quantidade de argumentos do mesmo tipo. Os três pontos depois do tipo permitem isso:
void main() {
IO.println(somar()); // 0
IO.println(somar(4)); // 4
IO.println(somar(1, 2, 3)); // 6
}
int somar(int... numeros) {
int total = 0;
for (int n : numeros) {
total += n;
}
return total;
}
Por dentro, numeros é um array, e o for com dois-pontos percorre cada valor dele. Os dois são o assunto da próxima aula, e varargs vão fazer mais sentido depois dela. Por ora, basta saber que o String.format e o formatted da aula anterior aceitam quantos valores você quiser exatamente por usarem esse recurso.
Recursão: um método que chama a si mesmo
Um método pode chamar qualquer método, inclusive ele mesmo. Isso é recursão, e é uma forma natural de resolver problemas que contêm versões menores de si mesmos. O exemplo clássico é o fatorial: 5! = 5 × 4 × 3 × 2 × 1, que é o mesmo que 5 × 4!.
long fatorial(int n) {
if (n <= 1) {
return 1; // caso base: resolve sem recursão
}
return n * fatorial(n - 1); // caso recursivo: problema menor
}
Toda recursão correta tem duas partes. O caso base é resolvido diretamente e interrompe as chamadas. O caso recursivo reduz o problema e chama o método de novo, com a garantia de que em algum momento o caso base será alcançado. fatorial(5) chama fatorial(4), que chama fatorial(3), e assim por diante até fatorial(1), que devolve 1. Então as multiplicações acontecem na volta: 2 × 1, 3 × 2, 4 × 6 e 5 × 24, que dá 120.
A recursão não escapa dos limites dos tipos. fatorial(20) dá 2432902008176640000, ainda dentro do long. fatorial(21) dá −4249290049419214848, o overflow de novo, silencioso.
O custo das chamadas
Cada chamada de método ocupa um espaço na pilha de execução (call stack), com seus parâmetros e variáveis locais, que é liberado quando o método termina. Numa recursão, as chamadas se acumulam até o caso base. Se forem muitas, a pilha acaba:
long somaAte(int n) {
if (n == 0) {
return 0;
}
return n + somaAte(n - 1);
}
somaAte(10_000) devolve 50005000 sem problemas. somaAte(1_000_000) derruba o programa:
Exception in thread "main" java.lang.StackOverflowError
at Estouro.somaAte(Estouro.java:6)
at Estouro.somaAte(Estouro.java:9)
at Estouro.somaAte(Estouro.java:9)
...
Um milhão de chamadas empilhadas excedem o espaço reservado para a pilha. Algumas linguagens eliminam esse problema em certos formatos de recursão, uma otimização chamada tail call. A JVM não faz essa otimização. Em Java, recursões profundas devem ser reescritas com laços. Onde fica essa pilha e o que mais vive nela é assunto de "A Máquina por Trás da Máquina".
O segundo custo é mais traiçoeiro. A sequência de Fibonacci (0, 1, 1, 2, 3, 5, 8...) tem uma definição recursiva elegante, em que cada termo é a soma dos dois anteriores:
long fib(int n) {
if (n < 2) {
return n;
}
return fib(n - 1) + fib(n - 2);
}
fib(40) dá o resultado certo, 102334155, mas levou cerca de meio segundo na máquina em que este artigo foi testado. Uma versão com laço calcula o mesmo em alguns microssegundos:
long fibIterativo(int n) {
long anterior = 0;
long atual = 1;
for (int i = 0; i < n; i++) {
long proximo = anterior + atual;
anterior = atual;
atual = proximo;
}
return anterior;
}
A diferença é que a versão recursiva recalcula os mesmos valores muitas vezes. fib(40) chama fib(39) e fib(38), mas fib(39) também chama fib(38), e assim por diante, numa árvore de chamadas que dobra a cada nível. Para calcular fib(40), são feitas 331.160.281 chamadas. A versão com laço faz 40 voltas.
A lição não é "evite recursão". Muitos problemas, como percorrer uma árvore de pastas ou uma estrutura que contém a si mesma, ficam muito mais claros com ela, e você vai encontrá-los ao longo do curso. A lição é que a elegância de uma definição não garante a eficiência do cálculo. Pergunte sempre quantas chamadas a recursão vai fazer e quantas delas repetem trabalho já feito.
Juntando tudo: o menu, reorganizado
O menu da aula anterior, dividido em métodos:
void main() {
double saldo = 0;
String opcao;
do {
mostrarMenu();
opcao = IO.readln("Opção: ").strip();
switch (opcao) {
case "1" -> saldo += lerValor();
case "2" -> saldo = sacar(saldo, lerValor());
case "3" -> IO.println("Saldo: " + formatarReais(saldo));
case "0" -> IO.println("Até logo!");
default -> IO.println("Opção inválida.");
}
} while (!opcao.equals("0"));
}
void mostrarMenu() {
IO.println("""
1 - Depositar
2 - Sacar
3 - Ver saldo
0 - Sair""");
}
double lerValor() {
double valor;
do {
valor = Double.parseDouble(IO.readln("Valor (use ponto): "));
if (valor <= 0) {
IO.println("O valor precisa ser positivo.");
}
} while (valor <= 0);
return valor;
}
double sacar(double saldo, double valor) {
if (valor > saldo) {
IO.println("Saldo insuficiente.");
return saldo;
}
return saldo - valor;
}
String formatarReais(double valor) {
return String.format(Locale.of("pt", "BR"), "R$ %,.2f", valor);
}
Depositando 1500 (depois de uma tentativa com −5), sacando 300.25 e tentando sacar 5000:
Opção: 1
Valor (use ponto): -5
O valor precisa ser positivo.
Valor (use ponto): 1500
...
Opção: 3
Saldo: R$ 1.199,75
O main agora se lê quase como uma descrição do programa. Cada método tem uma responsabilidade e um nome que a anuncia. A validação de valores positivos, que não existia antes, foi escrita num lugar só e vale para depósitos e saques. Se a regra mudar, muda num lugar só.
Repare que sacar não altera o saldo, porque não conseguiria: pela passagem por valor, ele recebe uma cópia. Ele devolve o saldo novo e o main o guarda. Funciona, mas o saldo continua sendo uma variável solta no main, que qualquer trecho pode alterar. Guarde esse incômodo. Juntar o saldo com as operações que o protegem é exatamente o que uma classe faz, e é por isso que este exemplo reaparece em "Moldes e Instâncias".
Fontes e leituras recomendadas
- The Java Language Specification, Java SE 25, seção 8.4, "Method Declarations": https://docs.oracle.com/javase/specs/jls/se25/html/jls-8.html#jls-8.4. A definição formal de métodos, parâmetros, sobrecarga e varargs.
- Oracle, The Java Tutorials, "Defining Methods" e "Passing Information to a Method or a Constructor": https://docs.oracle.com/javase/tutorial/java/javaOO/methods.html. Tutorial com mais exemplos de declaração e da passagem por valor.
- Robert C. Martin, Código Limpo (Alta Books), capítulo 3, "Funções": o capítulo mais citado sobre como escrever métodos pequenos, com uma só responsabilidade e nomes que revelam a intenção.
- Harold Abelson e Gerald Jay Sussman, Structure and Interpretation of Computer Programs, seção 1.2, "Procedures and the Processes They Generate": https://mitp-content-server.mit.edu/books/content/sectbyfn/books_pres_0/6515/sicp.zip/full-text/book/book-Z-H-11.html. A análise clássica da diferença entre processos recursivos e iterativos, com o próprio exemplo de Fibonacci.
- Oracle, documentação de
StackOverflowError: https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/lang/StackOverflowError.html. Uma página curta, útil para saber o que o erro significa e como ele se relaciona com a pilha.
Exercícios
Exercício 1
O método sinal da aula não compila. Corrija-o de duas formas diferentes: uma com return no fim e outra com o operador ternário.
Ver resposta
✓ Resposta: Com um return final:
int sinal(int n) {
if (n > 0) {
return 1;
} else if (n < 0) {
return -1;
}
return 0;
}
Com operadores ternários encadeados:
int sinal(int n) {
return n > 0 ? 1 : (n < 0 ? -1 : 0);
}
As duas cobrem todos os caminhos. A primeira é mais fácil de ler e de estender. A segunda é compacta, mas ternários encadeados ficam ilegíveis rapidamente, e mais de dois níveis é um bom sinal para voltar ao if. Vale saber que a biblioteca já tem esse método: Integer.signum(n). Antes de escrever um método utilitário, vale procurar se ele já existe.
Exercício 2
Sem rodar, diga o que o programa abaixo imprime e explique.
void main() {
int a = 1;
int b = 2;
trocar(a, b);
IO.println(a + " " + b);
}
void trocar(int x, int y) {
int temp = x;
x = y;
y = temp;
}
Ver resposta
✓ Resposta: Imprime 1 2. O método trocar recebe cópias de a e b em x e y e troca as cópias entre si, mas as variáveis do main não são afetadas, porque em Java os argumentos são sempre passados por valor. Não existe em Java um jeito de escrever um método que troque duas variáveis locais de quem o chamou, e isso é proposital: a leitura de trocar(a, b) garante que a e b não mudam. Se a troca de valores for necessária, ela precisa ser feita no próprio main.
Exercício 3
Escreva um método recursivo mdc(int a, int b) que calcule o máximo divisor comum pelo algoritmo de Euclides: o mdc de a e b é a se b for zero, e senão é o mdc de b e a % b. Mostre o passo a passo de mdc(48, 18).
Ver resposta
✓ Resposta:
int mdc(int a, int b) {
if (b == 0) {
return a;
}
return mdc(b, a % b);
}
Passo a passo: mdc(48, 18) chama mdc(18, 12), porque 48 % 18 = 12. Esta chama mdc(12, 6), porque 18 % 12 = 6. Esta chama mdc(6, 0), porque 12 % 6 = 0, e aí o caso base devolve 6. O resultado volta sem alteração por todas as chamadas. O algoritmo de Euclides tem mais de dois mil anos e é um exemplo em que a recursão é ao mesmo tempo elegante e eficiente: o segundo argumento diminui rapidamente, e mesmo números enormes precisam de poucas dezenas de chamadas.
Exercício 4
Escreva um método recursivo inverter(String texto) que devolva o texto de trás para frente, e use-o num método ehPalindromo(String frase) que ignore espaços e maiúsculas. Teste com "Socorram me subi no onibus em Marrocos".
Ver resposta
✓ Resposta:
String inverter(String texto) {
if (texto.length() <= 1) {
return texto;
}
return inverter(texto.substring(1)) + texto.charAt(0);
}
boolean ehPalindromo(String frase) {
String limpa = frase.replace(" ", "").toLowerCase();
return limpa.equals(inverter(limpa));
}
O caso base é um texto vazio ou de uma letra, que é igual ao próprio inverso. O caso recursivo inverte o texto sem a primeira letra e coloca essa letra no fim: inverter("Java") é inverter("ava") + 'J', que acaba em "avaJ". ehPalindromo devolve true para a frase do enunciado e false para "Java". Repare no equals, e não ==, pela lição de "Operadores, Texto e Conversa com o Usuário". A solução é didática, mas cria um texto novo a cada chamada, e para textos muito longos esbarraria no limite da pilha. Uma versão com laço, comparando as letras das pontas para o meio, seria mais eficiente.
Exercício 5
A versão recursiva de fib(40) faz 331.160.281 chamadas. Explique de onde vem esse número tão grande e estime o que aconteceria com fib(50).
Ver resposta
✓ Resposta: Cada chamada de fib(n) com n ≥ 2 gera duas outras, fib(n - 1) e fib(n - 2), que recalculam os mesmos valores repetidamente. fib(38), por exemplo, é calculado uma vez dentro de fib(39) e de novo diretamente por fib(40), e cada um desses cálculos repete toda a sua própria árvore. O número de chamadas cresce na mesma proporção dos próprios números de Fibonacci, multiplicando-se por cerca de 1,618 (a razão áurea) a cada incremento de n. De 40 para 50, são dez incrementos, ou seja, 1,618¹⁰ ≈ 123 vezes mais chamadas: 40.730.022.147, mais de 40 bilhões, e perto de um minuto de execução em vez de meio segundo. A versão com laço passaria de 40 para 50 voltas. É a diferença entre crescimento exponencial e crescimento linear, e nenhuma otimização do JIT compensa um algoritmo exponencial.