Por que eu devo ler este artigo:Durante a execução de um programa podemos ser surpreendidos por eventos inesperados. Quando esse tipo de evento acontece, o fluxo normal do programa é interrompido e o programa/aplicação é finalizado. Diante disso, para escrever um bom programa é preciso incluir um bom gestor de erros e recuperação.

O mecanismo de exceções da linguagem Java fornece uma abordagem simples e organizada para isso. Assim, ao invés de deixarmos o programa terminar devido a situações inesperadas, podemos escrever código para lidar com essas exceções e continuar a execução do programa normalmente. Neste artigo, vamos conhecer as várias categorias de exceções da linguagem Java, aprender a utilizar o mecanismo de exceções que temos disponível para tratá-las, analisar a hierarquia de exceções e entender a importância das informações contidas no stack trace gerado por uma exceção.


Guia do artigo:

Qualquer condição que interrompa o fluxo normal de um programa em execução é um erro ou uma exceção. Para compreender melhor esses dois termos, é importante, desde já, saber diferenciá-los. Um erro significa um problema no código ou em um arquivo de configuração que deve ser corrigido para que seja solucionado. Enquanto não houver correção, o problema voltará a acontecer sempre nas mesmas linhas. Alguns exemplos de erros são:

  • Tentativa de conexão a uma base de dados inexistente;
  • Acessar variáveis não estáticas a partir de métodos estáticos.

Por sua vez, uma exceção (ou evento excepcional) não está necessariamente relacionada com o código do programa, pode estar relacionada com condições externas que impedem o funcionamento normal da aplicação. Nesse caso, a criação do tratamento de exceções não é necessária para o correto funcionamento do programa, basta que a condição (ou condições) externa(s) que provoca(m) a exceção deixe(m) de existir. Entretanto, não queremos permitir que o programa termine inesperadamente, por isso podemos escrever código para lidar com as exceções e continuar o seu fluxo normal de execução. Alguns exemplos de exceções são:

  • A base de dados com a qual o programa tenta se conectar está offline;
  • Manipulação de operandos fora dos limites dos seus intervalos.

Hierarquia de exceções

Todas as classes de exceções são subtipos da classe java.lang.Exception. Por sua vez, Exception é uma subclasse de Throwable, assim como a classe Error. A Figura 1 nos mostra a hierarquia das exceções em Java.

Hierarquia das exceções na linguagem Java
Figura 1. Hierarquia das exceções na linguagem Java.
Fonte:Java Programming for Java Lover: What is Exception handling in Java

A classe Error é um caso especial e distinto das exceções, já que os erros são condições anormais que ocorrem em caso de falhas graves do sistema. Voltaremos a falar sobre esse assunto mais à frente.

Os três tipos de exceções

Para ajudar na organização das exceções, elas foram divididas em três categorias. É essencial entender essa divisão para compreender como o tratamento de exceções funciona na linguagem Java.

Checked exceptions

O primeiro tipo de exceção é a checked exception. Uma checked exception é uma exceção que ocorre em tempo de compilação, por isso também é chamada de exceção de tempo de compilação. Essas exceções não podem ser ignoradas, o programador deve, obrigatoriamente, tratar todas as exceções desse tipo, caso contrário é gerado um erro de compilação. A Listagem 1 mostra um exemplo de checked exception.


 public static void save(String filename, Object object) {
   // Writing data...
   ObjectOutputStream oos = null;
       
   File file = new File(filename);
   System.out.println("Path: " + file.getAbsolutePath());
   oos = new ObjectOutputStream(new FileOutputStream(file));
   System.out.println("Nome do arquivo: " + filename);
   oos.writeObject(object);
 }

  -- Erros de compilação: 
  Unhandled exception type FileNotFoundException TestClassException.java /TestProject    
  line 7  Java Problem
  Unhandled exception type IOException   TestClassException.java /TestProject    
  line 7  Java Problem
  Unhandled exception type IOException   TestClassException.java /TestProject    
  line 9  Java Problem
Listagem 1.Exemplo de checked exception.

Como podemos observar, na linha 7 tivemos dois erros de compilação, provenientes de duas exceções não tratadas: FileNotFoundException e IOException. Da mesma forma, na linha 9 temos um erro de compilação devido à exceção IOException, que, como dito, não foi tratada.

Conseguimos determinar as exceções que um método lança ao navegar pela API do Java, já que na assinatura do método temos obrigatoriamente que dizer que tipo de exceção (checked exception) ele pode lançar, através da instrução throws. Na Listagem 2 podemos ver a assinatura do construtor ObjectOutputStream e a exceção que esse construtor pode lançar.


public ObjectOutputStream(OutputStream out) throws IOException {  ]  
(...)
Listagem 2. Assinatura do construtor ObjectOutputStream

Como podemos notar, a classe ObjectOutputStream lança a checked exception IOException, daí um dos erros gerados na linha 7 do código da Listagem 1.


public FileOutputStream(File file) throws FileNotFoundException {
(...)
Listagem 3. Assinatura do construtor FileOutputStream

Do mesmo modo, podemos verificar na Listagem 3 que o construtor FileOutputStream lança a checked exception FileNotFoundException, o que explica o outro erro gerado na compilação da linha 7. Finalmente, como você poderá identificar na API da classe ObjectOutputStream, o método writeObject(Object obj) tem a instrução throws IOException em sua assinatura, o que provoca a mensagem de erro da linha 9 (devido à falta do código de tratamento para exceções desse tipo).

Unchecked exceptions

Uma unchecked exception é uma exceção que ocorre em tempo de execução, por isso também é conhecida como exceção de runtime ou runtime exception. Esse tipo inclui bugs de programação, como erros lógicos, ou uso impróprio da API. Ao contrário das checked exceptions, uma unchecked exception não precisa (ou não deve) ser tratada e não gera erro de compilação. Por essa razão, um método que possa lançar um unchecked exception não precisa ter essa informação (throws) em sua assinatura. A Listagem 4 mostra um exemplo simples de código que gera uma exceção de runtime.


public class TestClassException {

     public static void testException() {
             String[] stringArray = { "Hello", "World", "Greetings" };

             for (int i = 0; i < 4; i++) {
                     System.out.println(stringArray[i]);
             }
     }

     public static void main(String[] args) {
             testException();
     }
 }
  
  -- Output: 
  Hello
  World
  Greetings
  Exception in thread "main" java.lang.ArrayIndexOutOfBoundsException: 3
          at TestClassException.testException(TestClassException.java:7)
          at TestClassException.main(TestClassException.java:12)
Listagem 4. Código Java que gera uma exceção de runtime e seu respectivo output

Como podemos ver no exemplo apresentado, o resultado da iteração sobre o array de strings gerou uma exceção do tipo ArrayIndexOutOfBoundsException, que, como o próprio nome diz, significa que tentamos acessar uma posição fora dos limites do array. O stack trace da exceção contém informações extremamente úteis e detalhadas sobre o erro. Olhando para o output da Listagem 4, constatamos que a thread principal do programa tentou acessar a posição três do array de strings (Exception in thread "main" java.lang.ArrayIndexOutOfBoundsException: 3) e que o código que gerou a exceção é o que está na linha 7 da classe TestClassException (at TestClassException.testException(TestClassException.java:7)), que por sua vez foi chamado na linha 12 da mesma classe (at TestClassException.main(TestClassException.java:12)). Olhando para o código, podemos ver que o array de strings contém três posições: a posição 0 (“Hello”), a posição 1 (“World”) e a posição 2 (“Greetings”). Quando chamamos o método println() para uma posição inexistente do array (posição 3), temos uma exceção.

Errors

Como podemos verificar na Figura 1, um “error”, na verdade, é um throwable, e não exatamente uma exceção. Os erros são condições anormais que ocorrem em caso de falhas graves do sistema. Eles geralmente não são e nem devem ser tratados pelos programas.

Os objetos de erros são criados para indicar erros gerados pelo ambiente em tempo de execução. Por exemplo, quando a Java Virtual Machine fica sem memória (quer seja Java heap space ou PermGen space), é criado e lançado o objeto de erro java.lang.OutOfMemoryError. Normalmente, os programas não podem se recuperar de erros.

Tratamento de exceções

No caso de uma exceção acontecer e não ser tratada, o programa irá terminar com uma mensagem de erro. O tratamento de exceções (exception handling) permite ao programador capturar exceções e tratá-las sem interromper o fluxo normal de execução do programa. Esses casos excepcionais, quando ocorrem, são tratados em blocos de código separados, associados ao código da execução normal do programa, o que produz um código mais limpo, legível e de mais fácil manutenção.

As instruções try e catch e finally

A linguagem de programação Java fornece um mecanismo que permite ao programa descobrir que tipo de exceção foi lançado e se recuperar da mesma. Para tratar exceções, podemos colocar as linhas de código que podem gerar uma exceção dentro de um bloco try e criar uma lista de blocos catch contíguos, um para cada exceção possível de ser lançada. As linhas de código de um bloco catch serão executadas se a exceção gerada for do mesmo tipo da listada nesse bloco de captura. Podem existir múltiplos blocos catch depois de um bloco try, cada um tratando uma exceção diferente.

A instrução finally define um bloco de código que é sempre executado, quer tenha sido lançada uma exceção, ou não. A Listagem 5 mostra o código da Listagem 1 novamente, mas dessa vez implementando o tratamento de exceções de forma que o código seja compilável.


 public static void save(String filename, Object object) throws IOException {
   // Writing data...
   ObjectOutputStream oos = null;
    try {
       File file = new File(filename);
       System.out.println("Path: " + file.getAbsolutePath());
       oos = new ObjectOutputStream(new FileOutputStream(file));
       System.out.println("Nome do arquivo: " + filename);
       oos.writeObject(object);
   }
   catch (FileNotFoundException e) {
      _logger.error("File not found: " + filename + "\nMessage: " + e.getMessage());
      throw e;
   }
   catch (IOException e) {
     _logger.error("Erro while trying to save an object: " + e.getMessage());
     throw e;
   }
   finally {
     if (oos != null) {
       try {
          oos.close();
       }
       catch (Exception e) {
          // Do nothing!
       }
     }
    }
   }
Listagem 5. Tratamento de exceções com as instruções try/catch

Como podemos notar, temos dois tratamentos distintos para dois tipos de exceções: um tratamento para resolver possíveis problemas com o arquivo não encontrado e outro para resolver possíveis problemas de input/output. No caso de o arquivo não ser encontrado (FileNotFoundException), vamos informar, no log, que o arquivo de nome “filename” não foi encontrado, e adicionar uma informação mais detalhada sobre o erro, usando o método e.getMessage(), disponível para objetos de exceção. Em seguida, lançamos o erro para quem chamou o método, já que ainda não sabemos o que o cliente (chamador) quer fazer no caso de não conseguir executar o método “save” com sucesso.

No caso de termos um erro de input/output (IOException), faremos basicamente a mesma coisa: inserimos informações no arquivo de log sobre a falha na escrita no filesystem e lançamos a informação para o método cliente para que ele tome alguma ação.

É importante entender que, para que possamos lançar a exceção de volta ao método cliente, temos que dizer explicitamente na assinatura do método quais exceções podemos lançar (apenas para checked exceptions), utilizando para isso a instrução throws, como podemos ver na linha 1 da Listagem 5; caso contrário teremos erros de compilação do tipo “unhandled exception”, exatamente como vimos nos erros encontrados na Listagem 1. Devemos notar que o código da Listagem 5 informa que o método save() pode lançar apenas exceções do tipo IOException (throws IOException) e não diz nada sobre exceções do tipo FileNotFoundException, mas que mesmo assim não temos erros de compilação. Isso acontece porque as classes de exceção, como todas as classes do Java, estão organizadas de forma hierárquica, e a exceção FileNotFoundException é um subtipo da IOException, como nos mostra a Figura 2.

Hierarquia das exceções na linguagem Java
Figura 2. Hierarquia das exceções na linguagem Java

Do mesmo modo, sendo a IOException um subtipo da Exception, podemos ter a instrução throws Exception e até mesmo throws Throwable, mas não podemos ter a instrução throws Object, pois apenas classes e subclasses Throwable podem ser lançadas. Mesmo sendo possível lançar exceções de tipos mais genéricos, devemos sempre informar o tipo mais específico possível; neste caso, IOException.

Seguindo o mesmo princípio e sabendo que podemos usar uma classe sempre que uma subclasse é esperada, podemos capturar grupos de exceções e tratá-las com um mesmo catch. Por exemplo, usando ainda o código da Listagem 5 e com a informação da Figura 2, podemos escrever apenas o código catch (IOException e), da linha 15, para capturar exceções do tipo IOException e FileNotFoundException (visto que a segunda é um subtipo da primeira). Na verdade, a captura da exceção FileNotFoundException é opcional e serve para agregar informação à exceção capturada.

Sabendo disso, temos mais uma regra importante: a ordem com que as exceções são capturadas é importante. Se eu posso capturar exceções mais genéricas na árvore hierárquica das classes de exceção, então tenho que começar a capturar da exceção mais específica para a mais genérica. No caso de alterarmos a ordem de captura das exceções no exemplo demonstrado na Listagem 5, capturando primeiro a exceção mais genérica, teríamos um erro de compilação nos dizendo que o código contido no bloco de captura da exceção mais específica nunca será executado devido à exceção já ser tratada no bloco de tratamento da exceção mais genérica, como nos mostra a Listagem 6.


catch (IOException e) {
  _logger.error("Erro while trying to save an object: " + e.getMessage());
  throw e;
}
catch (FileNotFoundException e) {
  _logger.error("File not found: " + filename + "\nMessage: " + e.getMessage());
  throw e;
}

  -- Erro de compilação: 
  Unreachable catch block for FileNotFoundException. 
  It is already handled by the catch block for IOException.
Listagem 6. Exemplo demonstrando que a ordem de captura das exceções é importante

Avançando ainda mais na hierarquia de classes, podemos usar a instrução catch (Exception e) para capturar todas as exceções que podem ocorrer num bloco de código. Dessa forma, o código seria o que nos mostra a Listagem 7.


 public static void save(String filename, Object object) throws Exception {
   // Writing data...
   ObjectOutputStream oos = null;
   try {
     File file = new File(filename);
     System.out.println("Path: " + file.getAbsolutePath());
     oos = new ObjectOutputStream(new FileOutputStream(file));
     System.out.println("Nome do arquivo: " + filename);
     oos.writeObject(object);
  }
  catch (Exception e) {
   _logger.error("Erro while trying to save an object: " + e.getMessage());
   throw e;
  }
   finally {
     (...)
   }
 }
Listagem 7. Tratamento de exceções com catch genérico

No entanto, por que devemos evitar esse tipo de aproximação? Porque se capturarmos exceções do tipo Exception, capturaremos, também, “unchecked exceptions”, como nos mostra a árvore hierárquica da Figura 1, o que não seria uma boa ideia já que capturaríamos possíveis exceções de runtime. Além do mais, perderíamos as informações de erros por arquivo não encontrado e o nome do arquivo, o que nos dificultaria na hora de procurar a causa do erro. Então, para reforçar o pensamento, quanto mais específica for a exceção capturada e quanto mais informação tivermos sobre o problema, melhor! Assim, vamos preferir o código da Listagem 5 ao código da Listagem 7 sempre que precisarmos de informações específicas das exceções. Caso contrário, se a informação contida no objeto de exceção não for importante, podemos agrupar o tratamento das exceções utilizando uma classe mais genérica.

Agrupamento de exceções sem generalização de classes

A partir da versão 7 do Java, foi introduzida uma nova característica à linguagem que nos permite agrupar o tratamento de exceções sem generalizar. Suponha que estamos lidando com arquivos e bases de dados e que, no nosso método, não interessa se o erro for de input/output ou de base de dados, qualquer um deles terá a mesma influência no resultado final. Além disso, não queremos capturar possíveis exceções de runtime, ou seja, não queremos generalizar e capturar exceções do tipo Exception. Para isso, podemos capturar mais de um tipo de exceção sem dependências hierárquicas num único bloco de tratamento de exceções. A Listagem 8 ilustra o exemplo citado.


catch (IOException|SQLException e) {
   _logger.error("Exception occurred:" + e.getMessage());
   throw e;
}
Listagem 8. Agrupamento de exceções sem generalização de classes

Como podemos reparar, a instrução catch na captura da exceção especifica os tipos das exceções que o bloco pode tratar, separando cada tipo de exceção com uma barra vertical “|”. Esse recurso da linguagem ajuda a reduzir a duplicação de código e a diminuir a tentação de se tratar uma exceção muito genérica e abrangente.

Confira também

O bloco finally

Agora, vamos olhar para o bloco da instrução finally da Listagem 5. Como vimos anteriormente, a instrução finally define um bloco de código que é sempre executado. Sendo assim, esse bloco foi criado para tentar garantir que o objeto oos (do tipo ObjectOutputStream) seja sempre fechado no fim da execução do método, quer tenha sido lançada uma exceção ou não. É importante notar que esse bloco contém o seu próprio tratamento de exceções. Isso acontece porque o método close(), que podemos ver na linha 22 da Listagem 5, da classe ObjectOutputStream, pode lançar uma exceção do tipo IOException. Isso aconteceria caso a chamada new FileOutputStream(file), da linha 7, lançasse uma exceção do tipo FileNotFoundException, o que acionaria o código do bloco catch (FileNotFoundException e) da linha 11 e, posteriormente, o bloco de código definido no finally da linha 19. Como a exceção foi lançada antes da instrução oos = new ObjectOutputStream ter sido executada, o bloco finally geraria uma IOException na tentativa de fechar o objeto com a instrução oos.close() visto que objeto oos seria nulo.

A fim de garantirmos que esse caso não aconteça, inserimos a instrução if (oos != null). Ora, de qualquer forma essa exceção para nós é irrelevante, já que apenas teria a informação que tentamos fechar um objeto que não existe, quando o que importa é a informação que diz que o arquivo “filename” não existe. Não queremos manter qualquer informação de alguma possível exceção que possa acontecer no bloco finally e muito menos passá-la para a stack de chamadas. Por isso, no nosso tratamento de exceções, tratamos qualquer exceção com a instrução catch (Exception e) e não fazemos nada, sem deixar de garantir a correta execução do programa. Vale citar que é muito importante para a manutenção e legibilidade do código que tenhamos uma nota dizendo explicitamente que o bloco catch não vai executar nenhuma operação para esse caso. Isso é demonstrado na linha 25 da Listagem 5 com a nota // Do nothing!

Como o bloco de código contido na instrução finally é sempre executado, é uma boa prática de programação termos todo o código de limpeza do método situado nesse bloco. O finally é uma ferramenta fundamental para a prevenção de vazamentos de recursos.

A instrução try-with-resources

Vamos estudar agora mais um “atalho” criado pela linguagem Java para facilitar nossa tarefa de manter um código limpo e livre de vazamento de recursos. A instrução try-with-resources é uma instrução try que declara um ou mais recursos, sendo um recurso um objeto que deve ser fechado e liberado após o programa ter terminado o seu uso. Ao contrário da instrução try, essa nova instrução garante que cada recurso seja fechado no final das instruções contidas em seu bloco sem que tenhamos que escrever código para isso.

Qualquer objeto que implementa java.lang.AutoCloseable, o que inclui todos os objetos que implementam java.io.Closeable, pode ser utilizado como um recurso. Para obter a lista de classes que implementam essas interfaces, consulte o Javadoc.

Vejamos um exemplo simples de como essa instrução funciona. Primeiro, utilizaremos o método tradicional try-catch-finally e em seguida produziremos o mesmo resultado com a instrução try-with-resources a fim de compararmos a facilidade de uso de uma em relação à outra. Vamos usar o objeto BufferedReader, que estende a classe Reader, que, por sua vez, implementa a interface Closeable. A Listagem 9 mostra o código com esse exemplo.


 public static String readFirstLineFromFile(String path) throws IOException {
   BufferedReader br = new BufferedReader(new FileReader(path));
    try {
       return br.readLine();
    }
    finally {
      if (br != null) {
       try {
         br.close();
      }
      catch (Exception e) {
        // Do nothing!
       }
     }
   }
 }
Listagem 9. Utilização da instrução try-catch-finally

Esse código lê a primeira linha de um arquivo utilizando um buffer de leitura (BufferedReader) construído sobre um leitor de arquivos (FileReader, linha 2 do código). Nesse caso, o objeto BufferedReader é um recurso que deve ser fechado assim que o método terminar de utilizá-lo, caso contrário poderemos ter vazamento de memória do sistema. Isso acontece porque o Garbage Collector não tem como liberar os recursos de objetos referenciados ou abertos, pois isso indica que eles ainda podem estar sendo utilizados pelo programa. O fechamento é feito no bloco finally, na chamada do método close(), que, por sua vez, tem o seu próprio tratamento de exceções, como explicado nos exemplos anteriores.

Vamos agora utilizar a instrução try-with-resources e ver como fica a implementação desse mesmo método. A Listagem 10 apresenta o código em sua nova implementação.

  
public static String readFirstLineFromFile(String path) throws IOException {
  try (BufferedReader br = new BufferedReader(new FileReader(path))) {
    return br.readLine();
   }
 }
Listagem 10. Utilização da instrução try-with-resources

A primeira diferença que podemos notar é que não temos mais de escrever o bloco finally. Isso porque, como vimos anteriormente, a instrução try-with-resources garante que todos os recursos declarados no bloco try sejam fechados no fim da sua utilização. Depois, em termos de linhas de código, diminuímos de 16 para cinco, o que nos permitiu criar um código aproximadamente três vezes menor do que o inicial. Podemos, ainda, declarar vários recursos de uma só vez, bastando separá-los por ponto e vírgula. A Listagem 11 demonstra um exemplo.

  
public static String readFirstLineFromFile(String zipFileName, String path) throws IOException {
  try (ZipFile zf = new ZipFile(zipFileName); 
     BufferedReader br = new BufferedReader(new FileReader(path))) {
     (...)
   }
  }
Listagem 11. Exemplo de instanciação de mais de um recurso na instrução try-with-resources

Nesse caso, os recursos ZipFile e BufferedReader serão fechados no fim do método, garantindo a limpeza de recursos do sistema sem termos de nos preocupar com isso. Dessa maneira, a instrução try-with-resources pode nos poupar muito trabalho de codificação, ajudando na criação de um código mais enxuto e de fácil manutenção.

Quando devemos tratar a exceção e quando devemos lançá-la?

Nos exemplos vistos até aqui, implementamos códigos de tratamento de exceções utilizando as informações contidas nas mesmas, mas continuamos enviando esses mesmos objetos de exceções para o cliente, ou seja, para o método que chamou o método que gerou a exceção. Diante disso, quando devemos parar a pesquisa na pilha de chamadas para tratar as exceções levantadas de fato?

Vamos tomar como exemplo o código da Listagem 5, onde temos o método save(String filename, Object object), que guarda o objeto object no arquivo indicado em filename. Esse tipo de método auxiliar do filesystem deve ser o mais genérico possível para que possa ser partilhado por todo código que queira guardar informações no filesystem. Sendo genérico, o método save() não tem como descobrir qual a relevância que o método que o chama dá à correta persistência do objeto enviado. Em outras palavras, persistir corretamente o objeto é algo que pode falhar algumas vezes, que é importante ou que é crítico para o método cliente? Para um método, pode ser crítico a ponto de não fazer sentido continuar a execução do programa se houver uma falha. Para outro, pode ser moderado, podendo ele tentar guardar novamente o objeto mais tarde. Para um terceiro método, ainda, esse problema pode ser ignorado, e a execução programa continuar normalmente. Sendo assim, o método save() informa ao usuário, através do log de erro, que houve um problema e passa o problema “para cima”, lançando-o para o cliente, que deve saber o que fazer.

Como exemplo de um possível cliente para o método save(), criamos um listener de uma aplicação em Java Swing que persiste arquivos de configuração de bases de dados de forma que essas configurações não sejam perdidas depois que a aplicação é fechada. A Listagem 12 mostra esse código.


 public class ActionListenerSaveDatabasesSettings implements ActionListener {
      (...)
      @Override
      public void actionPerformed(ActionEvent e) {
              _logger.info("Saving database settings...");
              (...)
              SettingsDatabasesDTO settings =
                             new SettingsDatabasesDTO(databaseX, databaseY, databaseZ);
              
              try {
                      FileSystemUtils.save
                        (Constants.SETTINGS_FILE_PATH_DATABASE, settings);
                      JOptionPane.showMessageDialog(_tabPanelSettings,
                                     Resources.INSTANCE.getString("settings_databases_saved"),
                                     Resources.INSTANCE.getString("InfoDialog.title"),
                                     JOptionPane.INFORMATION_MESSAGE);
                      
                      _logger.info("Environments saved!");
              }
              catch (IOException e1) {
                      JOptionPane.showMessageDialog(_tabPanelSettings,
                                     Resources.INSTANCE.getString("error_save_environment"),
                                     Resources.INSTANCE.getString("error"),
                                     JOptionPane.ERROR_MESSAGE);
                      
                      _logger.error("Error while trying to save the environments!");
              }
      }
  }
Listagem 12. Demonstração de código cliente do método save()

Como podemos verificar, na linha 11 do código é feita uma chamada ao método save(), dentro de um bloco try. Se a chamada for executada com sucesso, o método cria uma caixa de diálogos que avisa ao usuário que as informações foram guardadas com sucesso. Caso o método save() lance alguma exceção, o bloco catch trata a mesma e mostra uma caixa de diálogos com uma mensagem de erro informando ao usuário que não foi possível guardar a informação. O usuário, por sua vez, deverá tentar novamente mais tarde e pode olhar o log para conseguir mais detalhes sobre o erro. De qualquer forma, a exceção é tratada e resolvida, e o programa continua sua execução normal, sem interrupções.

Imaginemos agora um cliente onde a execução do método save() é essencial para a continuidade do programa. No caso de ser lançada uma exceção, o método poderia esperar algum tempo e tentar novamente até conseguir, ou poderia tentar certo número de vezes antes de desistir, informando ao usuário que a operação não executou com sucesso. O importante é que, mesmo que não seja possível completar a operação, o programa possa terminar normalmente.

A decisão de onde a exceção deve ser tratada deve ser sempre a que faça com que o código seja o mais reutilizável possível. Se a exceção fosse tratada e terminada no próprio método save(), os dois exemplos anteriores não poderiam chamar o mesmo método, visto que um, qualquer que seja o resultado, chama o método, informa o cliente do resultado e sai, e o outro sai apenas se o método completar com sucesso, caso contrário continua tentando durante um certo número de vezes antes de sair. E por que lançar a exceção e não apenas retornar um erro? Porque a exceção contém informações detalhadas sobre o problema, que podem ou não serem utilizadas pelo cliente, quer seja apenas para informar ao usuário, quer seja para resolver o problema.

Criando sua própria exceção

Como já vimos, uma exceção é uma classe Java como qualquer outra, com a particularidade de estender a interface Exception. Isso quer dizer que podemos escrever nossas próprias classes de exceção e instanciá-las da mesma maneira que criamos e instanciamos outros objetos. A primeira coisa que precisamos saber antes de criar a nossa própria exceção é se já existe alguma na biblioteca da linguagem que nos forneça o que precisamos (não queremos reinventar a roda). Depois, é importante saber quando criar uma exceção e quando apenas enviar um retorno com erro. Essa é uma grande discussão e que não tem uma única reposta.

A melhor dica é: represente uma exceção apenas se o acontecido for exatamente isso, uma exceção! O que pretendemos dizer com isso é que a linguagem Java é uma linguem orientada a objetos, onde os objetos fazem a representação de coisas e acontecimentos reais. E o que é uma exceção? Como vimos, uma exceção é um evento excepcional, um desvio da regra geral, algo inesperado. Vamos colocar, então, alguns problemas e tentar entender onde devemos e onde não devemos criar uma exceção.

Suponha que trabalhamos para uma empresa onde temos um programa que valida a idade dos seus empregados. Para a construção do método de validação de idades, foram passadas as seguintes regras:

  1. A idade do empregado será considerada válida se ele tiver mais de 18 anos;
  2. Empregados com menos de 18 anos devem ser considerados inválidos, pois não são permitidos trabalhadores menores de idade.

Em uma primeira análise, o programador pode se sentir tentado a criar uma exceção para indicar que um trabalhador é menor de idade. A Listagem 13 mostra o código de criação de uma exceção para esse caso.

  
 public class UnderAgeException extends Exception { 
      private int _age;

      public UnderAgeException(String message, int age) {
              super(message);
              this._age = age;
      }

      // Use the getAge method to get the value that caused the exception.
      public int getAge() {
              return _age;
      }
  }
Listagem 13. Classe de exceção para empregado com idade abaixo de 18 anos

Como boa prática da linguagem Java, é importante que o nome da classe de exceção termine com a string Exception, melhorando a compreensão, legibilidade e manutenibilidade do código.

Como podemos ver pela Listagem 13, a exceção UnderAgeException estende a classe Exception e tem apenas um construtor, que chama o construtor da Exception e insere a idade que foi utilizada na criação da exceção. Na Listagem 14 apresentamos um exemplo de como poderíamos utilizar essa exceção.

  
public static void checkEmployeeAge(int age) throws UnderAgeException {
    if (age > 18) { 
       System.out.println("Employee age is OK! 
         Employee has " + age + " years old.");
    }
     else {
       throw new UnderAgeException("Employee can't be under age!", age);
     }
   }
      
   public static void testeEmployeeAge(int age) {
     try {
        checkEmployeeAge(age);
      }
      catch (UnderAgeException e) {
         System.out.print("Exception ocurred! Error message: " + e.getMessage());
         System.out.println(" Employee age: " + e.getAge());
       }
    }

    public static void main(String[] args) {
       testeEmployeeAge(28);
       testeEmployeeAge(16);
    }
  

  -- Output: 
  Employee age is OK! Employee has 28 years old.
  Exception ocurred! Error message: Employee can't be under age! 
  Employee age: 16
Listagem 14. Código que valida a idade de um empregado

Como podemos notar, o método checkEmployeeAge() obedece às regras impostas. Primeiro, valida se a idade do empregado é maior do que 18 anos e, se for, envia uma mensagem dizendo que a idade está OK. Caso contrário, lança uma exceção UnderAgeException, que deverá ser tratada pelo método cliente. O método testEmployeeAge() — método cliente —, por sua vez, chama o método checkEmployeeAge() e trata a exceção UnderAgeException. Caso seja lançada uma exceção desse tipo, o tratamento de exceções irá indicar que ocorreu um problema e informar os seus detalhes. Essas informações estão guardadas no próprio objeto de exceção: a mensagem de erro, obtida através do método e.getMessage(), e a idade que foi enviada e que gerou a exceção, obtida através do método e.getAge(). Conforme podemos observar pelo output, o programa funcionou sem interrupções inesperadas, tendo gerado as mensagens corretas e terminado normalmente.

A solução funciona! Mas, então, qual o problema aqui? Alguns autores, entre eles o autor, não acham uma boa ideia gerar exceções para fazer o que chamamos de “passagem de mensagem”. Como foi dito anteriormente, uma exceção deve ser criada apenas se o acontecimento for exatamente isso, uma exceção. Na vida real, seria uma exceção termos pessoas registradas com menos de 18 anos? Não! É um caso possível e aceitável. Então, por que simplesmente não dizer que pessoas menores de 18 anos não têm idade válida para trabalhar na empresa e deixar de tratar isso como um erro? Mas aí a pergunta: O que seria um erro provável de gerar uma exceção? Um input inválido! Um input inválido é um erro e, como tal, deve ser investigado. Onde aconteceu? Por quê? Como corrigi-lo?

Com base nisso, vamos refazer os exemplos das Listagens 13 e 14 e mostrar como podemos aplicar uma melhor regra de criação e tratamento de exceções. A Listagem 15 mostra uma nova exceção, criada para ser lançada em caso de input inválido.

  
 public class InvalidAgeException extends Exception {
      private int _age;

      public InvalidAgeException(String message, int age) {
              super(message);
              this._age = age;
      }

      // Use the getAge method to get the value that caused the exception.
      public int getAge() {
              return _age;
      }
  }
Listagem 15. Classe de exceção para erros no valor da idade

A classe InvalidAgeException funciona exatamente como UnderAgeException: estende a classe Exception, possui um construtor que recebe informações sobre o erro e guarda a idade que gerou a exceção. Vejamos agora, na Listagem 16, como utilizar essa exceção numa melhor abordagem para a solução do problema proposto.

  
public static boolean checkEmployeeAge(int age) throws InvalidAgeException {
    if (age < 0) {
      throw new InvalidAgeException("Input error! Age can't be less than zero!", age);
    }
    if (age < 18) {
      return false;
    }
    else {
       return true;
    }
  }

  public static void testeEmployeeAge(int age) {
      try {
         if (checkEmployeeAge(age)) {
           System.out.println("Employee age is OK! 
             Employee has " + age + " years old.");
        }
        else {
          System.out.println("Employee is under age! 
           Employee has " + age + " years old.");
        }
      }
      catch (InvalidAgeException e) {
        System.out.print("Exception ocurred! 
          Error message: " + e.getMessage());
         System.out.println(" Age value: " + e.getAge());
      }
    }

    public static void main(String[] args) {
        testeEmployeeAge(28);
        testeEmployeeAge(16);
        testeEmployeeAge(-2);
      }
    

  -- Output: 
  Employee age is OK! Employee has 28 years old.
  Employee is under age! Employee has 16 years old.
  Exception ocurred! Error message: Input error! 
  Age can't be less than zero! Age value: -2
Listagem 16. Exemplo de uso da exceção InvalidAgeException

Como podemos observar, o método checkEmployeeAge() considera válida qualquer idade que seja maior ou igual a 0 e considera como um erro um input negativo, lançando a exceção InvalidAgeException, pois uma idade negativa nunca deveria ser enviada para ser validada. Isso deve desencadear um problema passível de ser investigado e resolvido. Isso é uma exceção! Mais uma vez, olhando para o output do programa, podemos validar que tudo ocorreu conforme planejado, tendo sido enviadas as mensagens de OK, não OK e erro, sem que o programa termine inesperadamente.

A plataforma Java oferece um mecanismo de tratamento de erros e exceções que nos permite construir um código limpo e organizado graças à sua abordagem simples, onde os tratamentos são separados por blocos de código através das instruções try, catch e finally.

Outra vantagem das exceções é a capacidade para propagar os objetos de exceção na pilha de chamadas dos métodos, ajudando na separação e organização do código e fazendo com que seja mais simples de se escrever código genérico e reutilizável, uma vez que os tratamentos específicos dos erros são feitos nos próprios clientes.

Na maior parte das situações, a melhor abordagem será fazer tratamentos de exceções o mais específico possível, no entanto, haverá situações em que o agrupamento no tratamento de exceções será vantajoso e de mais fácil implementação.

Não devemos esquecer que as exceções são destinadas a tratar casos excepcionais. Usá-las para controlar o fluxo normal de execução do código não só obscurece a intenção do código, como também o torna mais lento, uma vez que o tratamento de exceções é um processo relativamente pesado e que deve ser usado com precaução.

A plataforma Java percorreu um grande caminho para nos ajudar a lidar com condições de erros. O seu mecanismo de tratamento de exceções é uma ferramenta poderosa e simples que, quando bem utilizada, é essencial para desenhar uma boa solução de software.

Confira também