O que acontece quando algo dá errado, como capturar e tratar esses erros, a diferença entre as exceções que o compilador obriga a tratar e as que não, e como projetar as suas.
Desde "Operadores, Texto e Conversa com o Usuário", uma frase se repetiu: "o programa para com um erro". Um número mal digitado, um índice fora do array, um saldo insuficiente recusado pelo construtor de "O que Fica Dentro, o que Fica Fora". Até aqui, toda exceção encerrava o programa, e a promessa era que um dia o assunto seria tratado. Esta aula cumpre a promessa. Você vai ler um stack trace, capturar exceções, garantir que um código de limpeza sempre execute, entender a divisão entre exceções verificadas e não verificadas, e criar as suas próprias, junto com as práticas que separam um tratamento de erros profissional de um que esconde problemas.
O que é uma exceção
Uma exceção é um objeto que representa uma situação anormal, lançado no ponto em que o problema é detectado. Lançar uma exceção interrompe o fluxo normal: o método para imediatamente, e a exceção sobe pela pilha de chamadas até encontrar um trecho que a trate. Se nenhum trecho tratar, ela chega ao topo e o programa termina, mostrando o stack trace:
void main() {
IO.println("início");
processarPedido("abc");
IO.println("fim");
}
void processarPedido(String codigo) {
int quantidade = lerQuantidade(codigo);
IO.println("quantidade: " + quantidade);
}
int lerQuantidade(String texto) {
return Integer.parseInt(texto);
}
início
Exception in thread "main" java.lang.NumberFormatException: For input string: "abc"
at java.base/java.lang.NumberFormatException.forInputString(NumberFormatException.java:67)
at java.base/java.lang.Integer.parseInt(Integer.java:565)
at java.base/java.lang.Integer.parseInt(Integer.java:662)
at Pilha.lerQuantidade(Pilha.java:13)
at Pilha.processarPedido(Pilha.java:8)
at Pilha.main(Pilha.java:3)
Saber ler isto é uma habilidade essencial. A primeira linha diz o tipo da exceção e a mensagem. As linhas com at são a pilha de chamadas no momento do lançamento, do mais recente para o mais antigo: a exceção nasceu dentro de Integer.parseInt, que foi chamado por lerQuantidade na linha 13, que foi chamado por processarPedido na linha 8, que foi chamado pelo main na linha 3. As linhas com java.base são da biblioteca. O primeiro trecho seu, de cima para baixo, costuma ser o lugar certo para começar a investigar. Repare também que "fim" nunca foi impresso: a exceção atravessou os três métodos, interrompendo cada um.
try e catch: capturando
Um bloco try delimita um trecho que pode lançar exceções. Um bloco catch logo depois diz o que fazer se uma exceção de determinado tipo acontecer ali. É a resposta à pendência de "Operadores, Texto e Conversa com o Usuário": ler um número sem encerrar o programa quando o usuário digita errado.
void main() {
int idade = lerInteiro("Sua idade: ");
int filhos = lerInteiro("Quantos filhos? ");
IO.println("Idade " + idade + ", filhos " + filhos);
}
int lerInteiro(String pergunta) {
while (true) {
String texto = IO.readln(pergunta).strip();
try {
return Integer.parseInt(texto);
} catch (NumberFormatException e) {
IO.println("\"" + texto + "\" não é um número inteiro. Tente de novo.");
}
}
}
Sua idade: trinta
"trinta" não é um número inteiro. Tente de novo.
Sua idade: 30
Quantos filhos? 2,5
"2,5" não é um número inteiro. Tente de novo.
Quantos filhos? 2
Idade 30, filhos 2
Se parseInt tem sucesso, o return encerra o método com o valor. Se falha, a exceção é lançada, o resto do try é pulado, e a execução continua no catch correspondente. O laço então pede de novo. O while (true) parece um laço infinito, mas termina pelo return do caso de sucesso. A variável e é o objeto da exceção, com métodos como getMessage().
Um try pode ter vários catch, um por tipo. Quando os tratamentos são iguais, um único catch pode listar vários tipos com |:
try {
int n = Integer.parseInt(entradas[i].strip());
IO.println(100 / (n - 5));
} catch (NumberFormatException | NullPointerException e) {
IO.println("entrada inválida: " + e.getClass().getSimpleName());
} catch (ArithmeticException e) {
IO.println("divisão por zero");
}
Os blocos catch são testados na ordem, e o primeiro compatível executa. Um catch de um tipo mais geral antes de um mais específico tornaria o segundo inalcançável, e o compilador recusa, como fazia com os casos dominados de "Hierarquias Fechadas":
Ordem.java:6: error: exception NumberFormatException has already been caught
finally: o que sempre executa
Um bloco finally executa sempre que o try termina, com sucesso, com exceção ou com um return no meio:
int dividir(int a, int b) {
try {
IO.println("dividindo " + a + " por " + b);
return a / b;
} finally {
IO.println("finally executado");
}
}
dividir(10, 2) imprime as duas linhas e devolve 5. dividir(10, 0) também imprime "finally executado", antes de a ArithmeticException subir para quem chamou. O finally é o lugar para liberar recursos: fechar um arquivo, encerrar uma conexão, devolver uma trava. Sem ele, uma exceção no meio do caminho deixaria o recurso aberto. Na próxima aula, "Recursos que se Fecham Sozinhos", você vai conhecer uma forma mais segura e mais curta de fazer isso, o try-with-resources.
Um cuidado: nunca use return dentro de um finally. Ele substitui o resultado do try e descarta silenciosamente qualquer exceção em andamento:
int estranho() {
try {
throw new IllegalStateException("perdida");
} finally {
return 42; // a exceção desaparece
}
}
O método devolve 42, e a exceção nunca é vista por ninguém. O javac aceita o código em silêncio; o aviso ("finally clause cannot complete normally") só aparece se você pedir, com javac -Xlint:finally.
A hierarquia das exceções
Toda exceção é um objeto de uma classe que descende de Throwable:
Throwable
├── Error problemas graves da JVM
│ ├── OutOfMemoryError
│ └── StackOverflowError
└── Exception
├── IOException ┐
├── InterruptedException ┘ verificadas
└── RuntimeException ┐
├── IllegalArgumentException
│ └── NumberFormatException
├── IllegalStateException não verificadas
├── NullPointerException
├── ArithmeticException
└── IndexOutOfBoundsException ┘
Os Error representam problemas da própria JVM, como a falta de memória ou o StackOverflowError da recursão de "Dividir para Resolver". Em geral não há o que um programa possa fazer por eles, e não devem ser capturados.
As Exception se dividem em dois grupos, e essa divisão é uma das características mais discutidas do Java.
Verificadas e não verificadas
As subclasses de RuntimeException são não verificadas (unchecked). Todas as exceções que você viu até aqui são desse tipo. Elas costumam indicar erros de programação: um argumento inválido, um null onde não deveria haver, um índice fora dos limites. O compilador não exige que sejam tratadas, e o certo, na maioria das vezes, é corrigir o código que as causou, e não capturá-las.
As demais subclasses de Exception são verificadas (checked). Elas representam situações que um programa correto pode encontrar por motivos alheios a ele, como um arquivo que não existe, uma rede que caiu ou um saldo insuficiente. Para essas, o compilador impõe uma regra: todo método que pode lançar uma exceção verificada precisa tratá-la com try/catch ou declará-la na assinatura com throws, repassando a decisão a quem o chama.
Vamos modelar o saque com uma exceção verificada própria:
public class SaldoInsuficienteException extends Exception {
private final double saldo;
private final double solicitado;
public SaldoInsuficienteException(double saldo, double solicitado) {
super("Saldo insuficiente: saldo %.2f, solicitado %.2f".formatted(saldo, solicitado));
this.saldo = saldo;
this.solicitado = solicitado;
}
public double getFaltando() {
return solicitado - saldo;
}
}
Uma exceção própria é uma classe como outra qualquer: estende Exception (verificada) ou RuntimeException (não verificada), passa a mensagem para o construtor da mãe, e pode carregar dados úteis para quem a trata. O nome termina em Exception por convenção.
public class Conta {
private final String titular;
private double saldo;
public Conta(String titular, double saldo) {
this.titular = titular;
this.saldo = saldo;
}
public void sacar(double valor) throws SaldoInsuficienteException {
if (valor <= 0) {
throw new IllegalArgumentException("Valor inválido: " + valor);
}
if (valor > saldo) {
throw new SaldoInsuficienteException(saldo, valor);
}
saldo -= valor;
}
public double getSaldo() {
return saldo;
}
}
A cláusula throws SaldoInsuficienteException faz parte do contrato do método: ela avisa que o saque pode falhar dessa forma. A IllegalArgumentException não precisa ser declarada, por ser não verificada: um valor negativo é um erro de quem chamou, e não uma situação normal. Agora, quem chama sacar sem tratar a exceção não compila:
SemTratar.java:4: error: unreported exception SaldoInsuficienteException; must be caught or declared to be thrown
c.sacar(50);
^
O tratamento correto:
Conta conta = new Conta("Ana", 100);
double[] pedidos = {30, 200, 50};
for (double valor : pedidos) {
try {
conta.sacar(valor);
IO.println("Saque de %.2f ok. Saldo: %.2f".formatted(valor, conta.getSaldo()));
} catch (SaldoInsuficienteException e) {
IO.println(e.getMessage() + " (faltam %.2f)".formatted(e.getFaltando()));
}
}
Saque de 30.00 ok. Saldo: 70.00
Saldo insuficiente: saldo 70.00, solicitado 200.00 (faltam 130.00)
Saque de 50.00 ok. Saldo: 20.00
A falha de um saque não impediu os seguintes, e quem trata a exceção tem acesso ao valor que faltou.
O debate
As exceções verificadas são uma particularidade do Java: nem C#, nem Kotlin, nem Scala, nem Python as adotaram. A intenção é boa, porque o compilador obriga a considerar falhas previsíveis. O custo aparece em sistemas grandes: exceções verificadas precisam ser declaradas em cada método do caminho, e mudar a assinatura de um método lá embaixo obriga a alterar dezenas de métodos acima. Muitos programadores reagem capturando e ignorando, o que é pior do que não ter verificação nenhuma. Além disso, elas combinam mal com as lambdas da Fase 4.
A prática atual converge para um meio-termo, alinhado com a recomendação de Joshua Bloch:
- Use exceções verificadas para condições que quem chama pode e deve tratar de forma razoável, e que fazem parte do contrato: um arquivo inexistente numa operação de leitura, por exemplo. A biblioteca de I/O faz isso, como você vai ver na próxima aula.
- Use exceções não verificadas para erros de programação (argumentos inválidos, estados impossíveis) e para falhas das quais quem chama não tem como se recuperar ali.
- Para resultados esperados de uma operação, considere a alternativa de "Hierarquias Fechadas": um tipo selado como
ResultadoSaque, que oswitchobriga a tratar, sem desviar o fluxo do programa. Um saldo insuficiente é um resultado normal de um saque, não uma catástrofe. As duas abordagens são defensáveis, e muitos projetos modernos preferem a segunda para regras de negócio.
Encadeando causas
Ao converter uma exceção em outra, por exemplo para traduzir um erro técnico num erro do seu domínio, preserve a original como causa:
static class ConfiguracaoInvalidaException extends RuntimeException {
ConfiguracaoInvalidaException(String mensagem, Throwable causa) {
super(mensagem, causa);
}
}
static int lerPorta(String valor) {
try {
return Integer.parseInt(valor);
} catch (NumberFormatException e) {
throw new ConfiguracaoInvalidaException("Porta inválida na configuração: " + valor, e);
}
}
lerPorta("oitenta") produz:
Exception in thread "main" Causa$ConfiguracaoInvalidaException: Porta inválida na configuração: oitenta
at Causa.lerPorta(Causa.java:12)
at Causa.main(Causa.java:18)
Caused by: java.lang.NumberFormatException: For input string: "oitenta"
at java.base/java.lang.NumberFormatException.forInputString(NumberFormatException.java:67)
at java.base/java.lang.Integer.parseInt(Integer.java:565)
...
A mensagem de cima diz o problema em termos de negócio. A seção "Caused by" preserva o detalhe técnico, com o stack trace original. Sem passar e como causa, essa informação se perderia, e quem investigasse o problema teria só metade da história. Esse padrão, de capturar uma exceção de baixo nível e lançar uma de nível mais alto com a original como causa, também é a forma de lidar com exceções verificadas que não fazem sentido para quem está acima: elas viram não verificadas, sem perder informação.
Validando argumentos
Para a situação mais comum, recusar um null, a biblioteca tem um atalho:
record Cliente(String nome) {
Cliente {
Objects.requireNonNull(nome, "nome não pode ser nulo");
}
}
new Cliente(null) lança NullPointerException: nome não pode ser nulo na construção, com uma mensagem clara, em vez de deixar o null entrar e causar um erro obscuro muito depois. Falhar cedo, perto da causa, é o princípio que atravessa toda esta aula e que "Contratos à Vista" vai aprofundar.
O que não fazer
Algumas práticas aparecem com frequência em código real, e cada uma delas transforma um erro visível num erro escondido.
Engolir exceções.
int total = 0;
for (String s : new String[] {"1", "2", "três", "4"}) {
try {
total += Integer.parseInt(s);
} catch (Exception e) {
}
}
IO.println("total: " + total); // total: 7
O programa diz que o total é 7, sem nenhum sinal de que um valor foi ignorado. Um catch vazio é quase sempre um defeito. Se ignorar for mesmo a decisão certa, escreva um comentário explicando por quê. Se não for, registre, avise ou relance.
Capturar genérico demais. catch (Exception e) captura também os erros de programação que você queria ver, como um NullPointerException causado por um defeito. Capture os tipos específicos que você sabe tratar. catch (Throwable t) é ainda pior, porque inclui os Error da JVM.
Usar exceções para controlar o fluxo normal. Lançar uma exceção é caro, porque a JVM captura a pilha inteira de chamadas no momento do lançamento. Um teste com um milhão de conversões inválidas levou cerca de 1 segundo capturando exceções, contra 20 milissegundos verificando os caracteres antes. Exceções são para situações excepcionais. Se a entrada inválida é comum e esperada, verifique antes.
Perder a causa. throw new MinhaException("falhou"), dentro de um catch, sem passar a exceção original, apaga a informação mais útil para quem vai investigar.
Imprimir e seguir em frente. e.printStackTrace() num catch, seguido da execução normal, é engolir com barulho: a mensagem aparece, mas o programa continua como se nada tivesse acontecido, frequentemente num estado inconsistente. Em sistemas reais, os erros vão para um log, com contexto, e aparecem na trilha de web e API do curso.
O finally desta aula resolve o problema de liberar recursos, mas com um código repetitivo e com armadilhas próprias: e se o fechamento também lançar uma exceção? Qual das duas se perde? Os recursos mais comuns de um programa são arquivos: abrir, ler, gravar e fechar, sempre fechar, mesmo quando algo dá errado. É ali que as exceções verificadas deixam de ser exemplo e passam a aparecer em quase toda linha, nas operações de entrada e saída.
Fontes e leituras recomendadas
- Oracle, The Java Tutorials, "Exceptions": https://docs.oracle.com/javase/tutorial/essential/exceptions/index.html. A trilha completa, incluindo a seção "Unchecked Exceptions — The Controversy", que resume o debate sobre as verificadas.
- Joshua Bloch, Effective Java, 3ª edição, capítulo 10, itens 69 a 77: o capítulo inteiro sobre exceções, incluindo "Use checked exceptions for recoverable conditions and runtime exceptions for programming errors", "Favor the use of standard exceptions" e "Don't ignore exceptions".
- The Java Language Specification, Java SE 25, capítulo 11, "Exceptions": https://docs.oracle.com/javase/specs/jls/se25/html/jls-11.html. As regras formais das exceções verificadas e da análise que o compilador faz.
- Documentação de
Throwable(Java SE 25): https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/lang/Throwable.html. Os construtores com causa,getCause,addSuppressede o formato do stack trace. - Brian Goetz, "Exceptional practices" (JavaWorld, 2001): https://www.infoworld.com/article/2075476/exceptional-practices--part-1.html. Uma série clássica sobre como projetar exceções, com a discussão sobre encadeamento de causas e conversão entre níveis.
Exercícios
Exercício 1
No stack trace de Pilha, identifique: o tipo da exceção, a mensagem, o método onde ela foi lançada, o primeiro método do seu próprio código na pilha, e a linha do main que iniciou o caminho. Por que "fim" nunca foi impresso?
Ver resposta
✓ Resposta: O tipo é java.lang.NumberFormatException, e a mensagem é For input string: "abc". Ela foi lançada dentro da biblioteca, em NumberFormatException.forInputString, chamado por Integer.parseInt. O primeiro método do próprio código, de cima para baixo, é Pilha.lerQuantidade, na linha 13, que é onde está a chamada problemática. O caminho começou no main, na linha 3, com processarPedido("abc"). "fim" nunca foi impresso porque nenhum método da pilha tratou a exceção. Ela interrompeu lerQuantidade, depois processarPedido (que não chegou a imprimir a quantidade) e depois o main, antes da última linha, e encerrou o programa. Uma exceção não tratada desfaz a pilha inteira.
Exercício 2
Sem rodar, diga o que o trecho imprime, e em que ordem:
static String testar(int x) {
try {
IO.println("A");
if (x == 0) {
throw new IllegalArgumentException("zero");
}
IO.println("B");
return "C";
} catch (IllegalArgumentException e) {
IO.println("D " + e.getMessage());
return "E";
} finally {
IO.println("F");
}
}
// no main:
IO.println(testar(1));
IO.println(testar(0));
Ver resposta
✓ Resposta: testar(1) imprime "A", "B", "F" e, depois que o método termina, o main imprime "C". testar(0) imprime "A", "D zero", "F", e o main imprime "E". Em ambos os casos o finally executa depois que o valor de retorno já foi decidido (no try ou no catch) e antes de o método de fato devolver o controle a quem chamou. Por isso "F" sempre aparece antes do valor devolvido. Se o finally tivesse um return, ele substituiria "C" ou "E", que é justamente o que não se deve fazer.
Exercício 3
Para cada situação, diga se usaria uma exceção verificada, uma não verificada, ou nenhuma exceção (um valor de retorno ou um tipo selado), e justifique: (a) um método recebe um percentual de desconto de 150%; (b) um método que lê as configurações de um arquivo não encontra o arquivo; (c) uma busca de cliente por CPF não encontra ninguém; (d) um método é chamado sobre um objeto já fechado.
Ver resposta
✓ Resposta: (a) IllegalArgumentException, não verificada. Um percentual acima de 100 é um erro de quem chamou, e a correção é no código chamador, e não um tratamento em tempo de execução. (b) Verificada, como a IOException que a biblioteca de arquivos já usa, porque é uma situação que um programa correto pode encontrar (o arquivo foi apagado) e que quem chama pode tratar razoavelmente, usando configurações padrão ou avisando o usuário. (c) Nenhuma exceção. Não encontrar é um resultado normal de uma busca, e não algo excepcional. O método pode devolver um valor que representa a ausência, como o Optional de "Um Valor que Pode Não Existir", ou um tipo selado se houver vários resultados possíveis. Usar exceção aqui seria usar exceção para o fluxo normal. (d) IllegalStateException, não verificada. O método é válido em geral, mas não no estado atual do objeto, e isso indica um erro na ordem das chamadas feitas pelo programador.
Exercício 4
Reescreva o laço que "engolia" exceções de modo que ele some os valores válidos, conte os inválidos e informe quais foram ignorados.
Ver resposta
✓ Resposta:
void main() {
int total = 0;
List<String> ignorados = new ArrayList<>();
for (String s : new String[] {"1", "2", "três", "4"}) {
try {
total += Integer.parseInt(s);
} catch (NumberFormatException e) {
ignorados.add(s);
}
}
IO.println("total: " + total);
if (!ignorados.isEmpty()) {
IO.println(ignorados.size() + " valor(es) ignorado(s): " + ignorados);
}
}
A saída é "total: 7" e "1 valor(es) ignorado(s): [três]". Três mudanças: o catch agora é específico, NumberFormatException, e não Exception, então um erro de programação diferente não seria engolido; os valores inválidos são registrados em vez de desaparecerem; e o resultado informa que o total é parcial. Se o programa precisasse da soma de todos os valores, o certo seria não capturar, ou capturar e relançar com contexto, porque um total parcial apresentado como completo é pior do que um erro.
Exercício 5
Compare as duas formas de tratar um saque sem saldo que você viu no curso: a SaldoInsuficienteException verificada desta aula e o tipo selado ResultadoSaque de "Hierarquias Fechadas". Cite uma vantagem de cada uma.
Ver resposta
✓ Resposta: A exceção verificada tem a vantagem de que o caso de sucesso fica simples: conta.sacar(100) não devolve nada que precise ser examinado, e uma sequência de operações pode ser escrita em linha reta, com todo o tratamento de falhas agrupado num catch no fim. Ela também se propaga sozinha por vários níveis de chamada, se cada um declarar throws. O tipo selado tem a vantagem de que o resultado é um valor comum: pode ser guardado, passado adiante, colocado numa lista, e o switch obriga a tratar cada caso com verificação de exaustividade, inclusive casos novos acrescentados depois. Além disso, não há o custo de capturar a pilha, e o fluxo do programa não "salta". Uma regra prática: se a falha é rara e interrompe o que se estava fazendo, a exceção é natural. Se é um resultado frequente, que faz parte do funcionamento normal e cada chamador trata de um jeito, o tipo selado deixa isso explícito.