Pensando nas Exceptions do Java ou... "por que elas são assim?"
Bora entender por que as Exceptions da linguagem Java são implementadas tal como são pra podermos fazer críticas melhor embasadas?
Existem muitas discussões sobre se o conceito de exceptions no Java é bom ou ruim: há quem diga que esta mudança de fluxo é péssima, outros que é boa. Na prática é uma discussão inútil quando o assunto é a linguagem Java, pois independente do que você pense, elas continuarão ali.
Boa parte desta discussão no entanto tem como origem uma má compreensão do recurso. Então neste post vou tentar expor por que o Java é assim e como podemos tirar proveito disto pra melhorar o nosso código.
Uma visão histórica
Antes de se criticar uma linguagem é interessante entender o que motivou o aspecto que vamos criticar. Um pouco de história cai bem, sendo assim recomendo que você leia o capítulo sobre exceções tal como redigido na especificação do Java 1.0, acessível neste link que contém a menção mais antiga que encontrei (yeap: no site da Microsoft, acredita?).
A leitura desta especificação é muito interessante pois mostra de forma explícita os objetivos dos designers na criação da linguagem naquele momento: prover portabilidade e robustez. Ao falarmos de exceções o que realmente nos interessa é o segundo princípio.
Com a web este diferencial perdeu muito o valor pois o navegador tomou o lugar do desktop e este sim nos forneceu a portabilidade que o Java prometeu (e que até conseguiu entregar apesar das limitações).
Antes um pouco de semântica: uma exception, como o próprio nome já nos diz, denota uma condição anormal que ocorre durante o fluxo de execução dos nossos programas.
A vida sem exceptions
Muitas linguagens de programação simplesmente finalizam a execução do software (pense em C) quando algo assim ocorre e não há uma forma de tratamento do erro padronizada. Um padrão que se formou foi retornar um código de erro que pode facilmente ser ignorado pelo programador (pense na função read do C retornando o valor -1, por exemplo).
Ao retornar múltiplos valores, dentre os quais um é um erro, você está na prática adotando uma estratégia similar à que temos em C: a diferença está no fato de que você tem uma tipagem mais simples de ser entendida do que um retorno que pode significar ao mesmo tempo um resultado ou um erro.
Vamos a um exemplo rápido usando “pseudo C”. A linguagem possuí uma função chamada read (mencionada acima) que lê bytes em uma fonte de dados e a armazena em um buffer. Ela retorna o valor -1 caso algo de errado ocorra, 0 se chegamos ao final do arquivo e um valor positivo nos informando quantos bytes foram lidos. Veja o código abaixo:
char buffer[128];
read(arquivo, buffer, 128);
printf("Opa, vou ser impresso?")
// um monte de coisas importantes no código serão executadas na sequência se tudo deu certo
Este é um código muito comum: o programador espera que o arquivo sempre exista, sendo assim, possuí a “certeza” de que a saída “Serei impresso?” sempre irá ser exposta em seu terminal. Mas nem sempre é assim: e se o arquivo imaginário sumir? Ainda veremos a saída na tela?
Talvez nosso programador pudesse escrever o código acima de uma forma diferente tal como no exemplo a seguir:
char buffer[128]
int resultadoLeitura = read(arquivo, buffer, 128);
if (resultadoLeitura < 0) {
// trato o problema aqui usando
// o código de erro (que torço para estar bem documentado)
}
É uma alternativa, o código se tornou mais robusto, mas sua leitura não torna claro o que de fato ocorreu para termos um erro. Sem acesso à documentação que me diz o que aquele código de erro significa a leitura fica mais difícil. O arquivo foi apagado? Seria um problema de permissão? Ainda pior: o código que motivou a escrita do programa, o fluxo principal (em condições ideais de temperatura e pressão) agora se encontra mesclado ao código de tratamento de erros.
Após tantos anos de experiência a conclusão que cheguei foi a de que se trata de uma questão meramente estética. O que realmente importa é ter código que consiga ser legível, fácil de manter e com desempenho aceitável.
Java veio com uma solução interessante. Visto que nossas classes são uma interface, por que não alertar seus clientes a respeito do que pode dar errado e, ainda melhor: força-los a tratar estas situações (o problema está neste “força-los”)?
Pensando como Gosling, Joy e Steele
Clareza na escrita
Por mais incrível que possa parecer a diversos críticos atuais da linguagem, naquela época um dos objetivos era ter código menos verboso. O ideal é que o programador pudesse ver o fluxo principal do seu programa de uma forma simples, e o tratamento dos erros isoladamente, tal como no exemplo a seguir:
String conteudoArquivo(File arquivo) {
try {
// fluxo principal entra aqui
} catch (FileNotFoundException ex) {
// o que faço se o arquivo não existir
} catch (IOException ex) {
// se for outro erro de I/O que não previ no meu código
}
}
É interessante como agora você sabe o quê pode ter dado errado e consegue diferenciar de forma clara como tratar cada uma daquelas situações. Ainda melhor: o que realmente importa, o fluxo principal, está explicitamente isolado.
Uma exception na prática é um desvio de fluxo com metadados e tipificado. Talvez você lide com erros do tipo FileNotFound e IOException da mesma forma. Neste caso, como a primeira exception é uma subclasse da segunda, basta colocar um único bloco catch para esta.
A grande vantagem em relação a alternativas como o C é que como a exceção é uma classe, esta pode ter atributos customizados, nos fornecendo mais informações sobre por quê da situação anômala ter ocorrido, e não apenas um código.
Exceptions como contrato
Mais do que isto, acredito que muitos programadores simplesmente não saibam interpretar o código que encontram: imagine uma declaração de método como a abaixo:
void processeArquivos(File[] arquivos) throws FileNotFoundException
O método me diz:
“Recebo uma lista de arquivos em uma matriz como parâmetro. Conseguirei executar meu trabalho quando todos os arquivos forem acessíveis a mim. Se me passar algum deles que não seja, repasso a você, que me chamou, a responsabilidade de lidar com este problema para mim.”
O método, é um contrato, e a exception, uma validação de que o mesmo será cumprido. Se não for o caso, o fluxo deverá ser alterado para que o seja ou a responsabilidade para se resolver o problema, repassada a outro objeto (talvez o cliente do cliente).
Mais do que isto: um contrato válido é aquele bem definido. Fica fácil perceber quando quem escreveu o código não tem muita ideia a respeito do que está fazendo. Observe a declaração de método abaixo:
void processarArquivos(File[] arquivos) throws Throwable
O que este método me diz?
“Recebo uma lista de arquivos para serem processados, alguma coisa pode dar errado, mas não sei o que.”
Temos um meio contrato aqui: apenas sabemos que devemos enviar arquivos para este método. Não sei se todos devem realmente estar acessíveis, apenas os envio.
Exceptions checadas e não checadas. Pra quê?
Por que há as tais “checked exceptions” e “unchecked exceptions”? O que diferencia uma de outra? Uma interpretação rápida seria:
“Checked exception é aquela que é uma subclasse de java.lang.Exception e que, se eu disparar no corpo do meu método, tenho de incluir uma clausula throws. A outra não, eu apenas a disparo lá dentro e não aviso ninguém a respeito pois é uma subclasse de RuntimeException ou Error.”
O que não responde quase nada além de expor uma hierarquia de classes incompleta. A resposta é mais simples: há erros que são tratáveis e outros nem tanto. Erros tratáveis são aqueles que definem um contrato e os clientes conseguem ao menos tentar resolve-los quando ocorrem.
Por exemplo: um arquivo inacessível é um erro tratável. Se topei com um erro do tipo FileNotFound, talvez seja possível criar um novo arquivo para em seguida chamar aquela função ou procedimento novamente.
Por outro lado, se houver um crash do meu sistema operacional ou meu sistema de arquivos desaparecer, não há muito o que eu possa fazer. É um erro de tempo de execução (runtime). E a quantidade de problemas deste tipo que podem ocorrer é praticamente infinita: seu HD pode pegar fogo, ou seu HD pode ser removido, ou seu sistema operacional pode desaparecer, ou sua rede pode se tornar inacessível, ou alguém pode desligar o servidor, ou….
Por que as exceções do tipo Runtime não são “checked”? Vou pedir para Gosling, Joy e Steele uma força. Veja o que é dito na seção 11.2.2 da especificação:
“A informação disponível para o compilador Java, e o nível de análise que este executa, raramente são suficientes para se descobrir que erros de tempo de execução poderão ocorrer, mesmo sendo óbvio para o programador. Obrigar o programador a declarar todas estas exceções seria apenas uma tarefa irritante para o desenvolvedor.” (tradução minha)
É interessante também ver o que os autores dizem na especificação ao nos dizerem por que a outra categoria de erros (java.lang.Error) não são checados (11.2.1):
“São problemas que podem ocorrer em inúmeros pontos de um programa e cuja solução é difícil ou impossível. Um programa escrito em Java que precisasse lidar com todos estes erros seria uma zona e sem sentido algum” (tradução minha)
Sendo assim, ao invés de obrigar o desenvolvedor a tratar cada um destes problemas, por que não força-lo a lidar apenas com o que pode ser tratado? Este é um dos principais motivadores: te forçar a escrever menos código e, quem sabe, escrever código de melhor qualidade.
Isto não quer dizer que você deva escrever código como o a seguir:
try {
// meu lindo fluxo principal
} catch (Throwable t) {
// aqui lido com um erro genérico que representa todos os problemas do mundo!
}
Quando escrevemos algo como “catch (Throwable)” estamos com uma das seguintes ideias na cabeça:
- Vou ignorar qualquer tipo de erro que venha a ocorrer.
(me faz lembrar do “on error resume next” do VB) - Todos os erros são iguais, sendo assim os tratarei todos da mesma forma.
Se um dos princípios norteadores da criação do Java foi a robustez, e estamos usando Java (ignore sua linguagem favorita por um momento), escrever código deste tipo é corromper a linguagem e se induzir ao erro.
Mais do que isto: checked exceptions permitem ao compilador verificar se você está lidando com as situações anômalas definidas no contrato das suas interfaces.
Como uso bem as exceções?
O principal motivador para a escrita deste post são as críticas que ouço a respeito do modo como a linguagem Java lida com exceções. Especialmente você, que é iniciante, ao ler estas críticas pode ser levado a seguir más práticas de codificação.
É interessante como muitas pessoas se esquecem que o recurso foi incluído na linguagem para facilitar a vida do programador, não pra complicá-la.
Curiosamente, a esmagadora maioria das críticas que vejo são motivadas pelo mal uso ou compreensão do recurso. Sendo assim, seguem algumas dicas:
- Pense na declaração de exceções como a definição de um contrato bem definido: elas definem premissas, ou seja, aquilo que não deve ocorrer para que o código possa ser executado com sucesso.
- Tire proveito da precisão: um “catch (Throwable)” não te possibilita lidar com as diferentes situações ou quebras de contrato que podem ocorrer durante a execução do sistema, você estará apenas criando um bloco catch que, no futuro, pode se tornar um verdadeiro monstrinho.
- Entenda a diferença entre checked e unchecked exceptions.
- Uma declaração de método que contém uma instrução throws seguida de 293847 tipos de exceção e algo como um “throws Throwable” são a mesma coisa.
- Se checked exceptions são um problema para você, considere linguagens como Groovy que torna o tratar de exceções uma tarefa opcional