https://a.storyblok.com/f/270183/1368x665/9810908b7d/checked-exceptions_24.png

Por que você deve evitar o uso de exceções verificadas em Java

Publicado em September 12, 2023

Tempo de leitura: 10 minutos

Fiz uma breve palestra sobre esse assunto no Devoxx UK 2023. Confira em YouTube.

Introdução

A maioria das linguagens de programação convencionais possui construções específicas para lidar com exceções. O que são exceções? São casos de comportamento anormal resultantes de entradas incomuns ou de um estado atípico do sistema. Os mecanismos de tratamento de exceções interrompem o fluxo de execução típico do programa, conforme observado na Wikipedia. Você provavelmente está familiarizado com o try-catch-finally se você usa uma linguagem de programação orientada a objetos, como C# ou Java. No entanto, talvez você não esteja familiarizado com o conceito de exceções verificadas. Recentemente, descobri que esse “recurso” é praticamente exclusiva do Java — pelo menos quando se trata das principais linguagens de programação. Neste artigo, espero convencê-lo de por que as exceções verificadas são ruins e, se você não for um desenvolvedor Java, por que deve ficar grato por sua linguagem não as ter!

Diferença entre exceções verificadas e não verificadas

A ideia das exceções verificadas é impor o tratamento explícito de exceções em tempo de compilação, na tentativa de garantir a “integralidade” do código. Um método que gera uma exceção verificada pode lançar essa exceção, que passa a fazer parte da assinatura do método. Qualquer chamador desse método deve, então, propagar a exceção, incluindo-a também na assinatura de seu método, ou lidar com ela usando um catch . Para demonstrar, considere o código a seguir:

public class ExceptionsDemo {

    public static void main(String[] args) throws Throwable {
        code();
    }

    static class BaseChecked extends Exception {}
    static class CheckedA extends BaseChecked {}
    static class CheckedB extends BaseChecked {}

    static class BaseUnchecked extends RuntimeException {}
    static class UncheckedA extends BaseUnchecked {}
    static class UncheckedB extends BaseUnchecked {}

    interface MyApi {
        void checked() throws CheckedA, CheckedB;
        void unchecked();
    }

    static class MyApiImpl implements MyApi {
        @Override
        public void checked() throws CheckedA {
            throw new CheckedA();
        }

        @Override
        public void unchecked() {
            throw (Math.random() > 0.5) ? new UncheckedA() : new UncheckedB();
        }
    }

    static void code() {
        MyApi api = new MyApiImpl();
        api.unchecked();
    }
}

Aqui temos duas hierarquias de exceções: BaseChecked e BaseUnchecked, cada uma com duas subclasses. Observe como BaseChecked herda de Exception, enquanto BaseUnchecked herda de RuntimeException. Para ilustrar a diferença, considere a MyApi interface, que declara dois métodos. O checked() método declara em sua assinatura que um método implementador pode lançar tanto um CheckedA ou CheckedB , enquanto unchecked() não declara nada. Ao implementar a interface, é possível lançar qualquer exceção não verificada a partir do unchecked() método. Isso é essencialmente o que é uma exceção não verificada: uma exceção que é uma subclasse de java.lang.RuntimeException (direta ou indiretamente).

Em contrapartida, as exceções verificadas são subclasses de java.lang.Exception. O compilador exige que as exceções verificadas sejam declaradas na assinatura do método para que possam ser lançadas. No entanto, observe que, embora o MyApi::checked método da interface declare CheckedA e CheckedB como possíveis exceções, a implementação (MyApiImpl::checked) lança apenas CheckedA e não precisa declarar CheckedB. Assim, métodos sobrescritos não precisam declarar as exceções de seus pais em suas próprias assinaturas, a menos que sejam lançadas a partir do método. No entanto, novas exceções verificadas que não sejam declaradas pelo método super não podem ser lançadas, pois isso violaria os princípios polimórficos nos quais a linguagem se baseia.

Semântica peculiar

A semântica fica mais fácil de entender com a ajuda de um compilador; portanto, se você não estiver familiarizado com exceções em Java, recomendo que experimente o código no seu IDE. Por exemplo, observe como o compilador obriga você a tratar tanto CheckedA e CheckedB se você alterar a chamada de api.unchecked() para api.checked() no exemplo a seguir. Isso ocorre porque você está chamando o método da interface, e não o da implementação.

static void code() {
        MyApi api = new MyApiImpl();
        try {
            api.checked();
        }
        catch (CheckedA | CheckedB ex) {
            ex.printStackTrace();
        }
    }

É claro que você poderia simplesmente jogá-lo:

static void code() throws CheckedA, CheckedB {
    MyApi api = new MyApiImpl();
    api.checked();
}

ou tratar uma exceção e lançar a outra:

static void code() throws CheckedB {
    MyApi api = new MyApiImpl();
    try {
        api.checked();
    }
    catch (CheckedA ax) {
        // TODO handle here
    }
}

Você também poderia tornar seu código “à prova do futuro” para garantir a máxima compatibilidade, declarando BaseChecked na assinatura do método:

static void code() throws BaseChecked {
    MyApi api = new MyApiImpl();
    api.checked();
}

Se você não quiser sobrecarregar a assinatura do seu método, pode envolvê-la em um RuntimeException:

static void code() {
    MyApi api = new MyApiImpl();
    try {
        api.checked();
    }
    catch (BaseChecked ex) {
        throw new RuntimeException(ex);
    }
}

Observe que ainda é possível interceptá-la no código de chamada, mas será necessário chamar getCause() para obter a exceção original que foi lançada, desta forma:

public static void main(String[] args) throws Throwable {
    try {
        code();
    }
    catch (Exception ex) {
        assert ex.getCause() instanceof BaseChecked;
    }
}

Arremesso Furtivo

Há também um truque oculto que descobri ao pesquisar esse assunto; fiquei surpreso quando me deparei com ele pela primeira vez. Você sabia que o Java permite contornar efetivamente as exceções verificadas? Antes de entrar nesse assunto, é preciso saber que RuntimeException na verdade, `extends` Exception, apesar de a primeira ser não verificada e a segunda ser verificada! O compilador verifica a hierarquia de classes e abre uma exceção explícita (desculpem o trocadilho) para RuntimeException e suas subclasses. Aqui está o recurso que constitui o truque

public static <E extends Throwable> void sneakyThrow(Exception ex) throws E {
    throw (E) ex;
}

static void code() {
    MyApi api = new MyApiImpl();
    try {
        api.checked();
    }
    catch (BaseChecked ex) {
        sneakyThrows(ex);
    }
}

Observe como podemos chamar o api.checked() método e, efetivamente, lançar a exceção verificada sem declará-la na assinatura do método code()! E não, isso não é apenas um recurso sintático para encapsular uma exceção. Se você tentar capturá-la no chamador, verá que é a mesma exceção que foi lançada, e não uma RuntimeException que a encapsule.

public static void main(String[] args) throws Throwable {
    try {
        code();
    }
    catch (Exception ex) {
        System.out.println(ex.getClass().getName());
    }
}

Subtipos de exceção de shadowing

Se a seção anterior deixou você de queixo caído, então consegui, em parte, passar minha mensagem: as exceções verificadas são complicadas! Especialmente em uma linguagem que suporta tanto exceções verificadas quanto não verificadas, a semântica e a interação entre elas podem ser bastante complexas e, às vezes, confusas. Isso é ainda mais agravado pelo fato de que as exceções verificadas são o “padrão” em Java. Isso cria um problema de “ocultação” das exceções não verificadas. Considere, por exemplo, o código a seguir:

static void code() throws Exception {
    MyApi api = new MyApiImpl();
    if (Math.random() > 0.67) {
        api.checked();
    }
    else {
        api.unchecked();
    }
}

Observe como a assinatura do método agora declara o java.lang.Exception em sua throws cláusula. Se chamarmos o método, agora precisamos lidar com isso. Mas o que acontece quando, em vez de lançar BaseChecked, for lançada uma BaseUnchecked exceção é lançada? E se não quisermos capturá-la RuntimeException? Bem, nesse caso, temos que relançá-la. A maneira mais comum de fazer isso é capturá-la RuntimeException primeiro, assim:

public static void main(String[] args) throws Throwable {
    try {
        code();
    }
    catch (RuntimeException ex) {
        throw ex;
    }
    catch (Exception ex) {
        // Handle checked
    }
}

Da mesma forma, se você quisesse lidar com CheckedA, CheckedB e BaseUnchecked explicitamente, mas não RuntimeException, você pode fazer assim:

public static void main(String[] args) throws Throwable {
    try {
        code();
    }
    catch (BaseUnchecked ex) {
        // Handle BaseUnchecked
    }
    catch (RuntimeException ex) {
        throw ex;
    }
    catch (Exception ex) {
        if (ex instanceof CheckedA) {
            // Handle CheckedA
        }
        else if (ex instanceof CheckedB) {
            // Handle CheckedB
        }
    }
}

Assim, você captura exceções da mais específica à mais genérica, quase como uma switch instrução. Você percebe o problema? Quanto mais genérica for uma exceção, mais informações ela oculta. Você é forçado a lidar com o tipo mais genérico de exceção, e cabe a você decifrar quais subtipos específicos podem ser lançados por um método. Isso não pode ser comunicado por meio das assinaturas dos métodos — o compilador não pode ajudá-lo nesse caso. Portanto, você deve confiar na documentação ou até mesmo no conhecimento do código-fonte para decifrar quais possíveis exceções um método pode lançar. Isso, sem dúvida, vai contra o objetivo principal das exceções verificadas. É verdade que o uso inadequado de exceções nas assinaturas de métodos é mais um problema de design do que da linguagem, mas basta uma “maçã podre” para corromper suas assinaturas de métodos.

Na prática, isso é especialmente comum com java.io.IOException, onde há muitas subclasses que descrevem problemas específicos; mas, se você estiver usando um método de biblioteca que lança IOException, você efetivamente abre mão da capacidade de lançar um subtipo mais específico, a menos que esteja disposto a capturar e tratar explicitamente todas as outras exceções de E/S possíveis — o que eu não recomendaria!

Evolução da API e abstração com vazamentos

Por definição, as exceções verificadas devem ser declaradas na assinatura de um método para que possam ser lançadas. Isso significa que, se sua implementação mudar — por exemplo, se você chamar um método de biblioteca que lance uma IOException ou outra exceção verificada — você será obrigado a tratá-la dentro da sua implementação ou envolvê-la em um RuntimeException. A primeira opção torna seu código mais pesado e dificulta a compreensão do método. A segunda é uma solução alternativa; no entanto, não é necessariamente uma “mudança que quebra a compatibilidade”, pois seu método não precisa declarar todas as possíveis exceções não verificadas. A melhor maneira de comunicar tal mudança é usar o @throws Javadoc na documentação da assinatura do método, desta forma:

/**
 * Method that does X.
 * 
 * @throws UncheckedA If A goes wrong.
 * @throws UncheckedB If B goes wrong.
 */
static void code() {
    new MyApiImpl().unchecked();
}

Dessa forma, os usuários do seu método não precisam se dar ao trabalho de descobrir quando uma possível exceção pode ser lançada. No entanto, isso impõe uma carga extra aos mantenedores, que precisam documentar explicitamente as exceções que podem ser lançadas, e aos usuários, que precisam ler a documentação. Você é explícito quanto às exceções das quais tem conhecimento nesse método e quando elas podem ser lançadas. Isso é, sem dúvida, mais comunicativo do que simplesmente lançar uma exceção verificada e confiar que o compilador obrigue seus usuários a tratá-la. Além disso, dessa forma, você pode ser seletivo quanto às informações que expõe aos usuários. Você não precisa declarar detalhes de baixo nível sobre exceções que PODEM ser lançadas, mas que são altamente improváveis ou até mesmo impossíveis. Isso me leva ao meu próximo ponto.

Algumas exceções NUNCA podem ocorrer

Em alguns casos, as exceções verificadas devem ser capturadas mesmo quando se possa provar que nunca serão lançadas. Aqui está um caso trivial:

static void code() throws URISyntaxException {
    URI url = new URI("https://example.com");
}

A maioria das cadeias de caracteres são URIs válidas, já que java.net.URI ele suporta URIs parciais e pode determinar quais segmentos estão especificados. Cientes disso, os mantenedores do JDK disponibilizam uma maneira alternativa de criar uma URI. A [documentação] desse método (https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/net/URI.html#create(java.lang.String)) deve dar uma ideia do motivo de sua existência.

static void code() {
    URI url = URI.create("example");
}

O uso excessivo de exceções verificadas pode tornar uma biblioteca desagradável de se trabalhar e prejudicar ainda mais suas métricas. Um exemplo particularmente irritante, baseado na minha experiência pessoal, é o Jackson ObjectMapper. No SDK Java da Vonage, a maioria dos nossos objetos de domínio precisa ser serializada para JSON, mas fazer isso usando o Jackson exige que a gente lide com JsonProcessingException. A única maneira dessa exceção ser lançada é se a classe tiver um uso incorreto de anotações. Existe até mesmo uma pergunta popular no StackOverflow sobre isso, pois, ao buscar 100% de cobertura de código, é necessário cobrir o catch , mesmo que, na prática, a exceção nunca venha a ser lançada.

Propagação por padrão

Na maioria das vezes, você provavelmente vai querer propagar (“enviar para cima”) uma exceção de qualquer maneira. A vantagem das exceções não verificadas é que lidar com elas é opcional. Por padrão, elas são lançadas até que sejam capturadas ou tratadas pelo UncaughtExceptionHandler (o comportamento padrão é imprimir o rastreamento da pilha da thread). Com exceções verificadas, é preciso propagá-las explicitamente, o que, como já discutido, sobrecarrega as assinaturas dos métodos e transfere o problema para quem chama o método, mesmo que, na prática, a exceção talvez nunca ocorra.

Perder a preferência

Há um bom motivo para o Java ser uma das únicas linguagens de programação amplamente utilizadas que possui exceções verificadas (consulte esta pergunta do StackOverflow). Outras linguagens da JVM, como Kotlin, Scala, Groovy, Clojure etc., não possuem exceções verificadas — pelo menos, não obrigam você a tratá-las. A documentação da linguagem Kotlin resume o raciocínio muito bem e cita a entrevista de 2003 com o principal projetista da linguagem C# sobre os motivos para dispensar as exceções verificadas.

Mesmo dentro da própria linguagem Java, basta observar as APIs mais recentes da biblioteca padrão para perceber que as exceções verificadas devem ser usadas com moderação. APIs introduzidas no Java 8, como java.time, java.util.stream e java.util.function todas evitam exceções verificadas. A API Streams, hoje bem estabelecida, é abertamente hostil às exceções verificadas. Isso tem causado muitos problemas para desenvolvedores que desejam usar a API Streams, pois exige soluções alternativas complicadas para propagar exceções verificadas a partir das interfaces funcionais embutidas do Java. Falaremos mais sobre isso adiante.

Outros paradigmas de tratamento de exceções

Minha curiosidade sobre exceções verificadas começou com uma conversa com meu colega, Guillaume. Ele fez uma excelente palestra sobre esse assunto, que defende a posição a favor das exceções verificadas. Ele argumenta que as exceções nem sempre são a ferramenta certa para lidar com erros e defende o uso de mônadas em vez delas. Um exemplo disso é a maneira como a API Java Streams lida com exceções. Veja, por exemplo, o código a seguir:

static void code() {
    OptionalInt resultWrapped = IntStream.range(1, 20)
            .filter(i -> i % 9 == 0 && i % 2 == 0)
            .findAny();
    int guaranteedResult = resultWrapped.orElseThrow(IllegalStateException::new);
    int resultWithAlternative = resultWrapped.orElse(18);
}

Observe que, ao realizar uma filter em um fluxo significa que, ao chamar uma operação terminal como finayAny(), você recebe um wrapper que pode ou não conter um resultado. Para obter o resultado, é preciso chamar orElseThrow(), que também pode receber um Supplier para personalizar a exceção caso o valor esteja ausente. Isso é quase como uma exceção verificada disfarçada, pois você é forçado a reconhecer explicitamente a possível ausência de um valor, mesmo que, na prática, sua presença seja garantida. É claro que isso é, sem dúvida, mais elegante e explícito, já que você também tem a opção de fornecer um valor alternativo com orElse.

No entanto, a palestra de Venkat Subramaniam na Devoxx UK 2023 sobre “Tratamento de exceções na programação funcional e reativa” me fez perceber que as duas visões não são conflitantes. Duas citações diretas de sua palestra, para contextualizar, são:

"O tratamento de exceções é um estilo de programação puramente imperativo." "A programação funcional e o tratamento de exceções são mutuamente exclusivos."

Recomendo vivamente a palestra, que também aborda a questão do uso de exceções verificadas em código funcional, como os Java Streams, conforme discutido anteriormente. A lição principal é que, nos estilos de programação funcional e reativa, o tratamento de exceções é realizado ao longo de todo o pipeline de transformação de dados. Em vez de usar catch e finally , as estruturas reativas utilizam funções explícitas de tratamento de erros aplicadas ao pipeline para lidar com exceções. E isso nos leva de volta à intenção original das exceções verificadas: garantir a integridade e o reconhecimento explícito de erros e falhas no código. Talvez o debate em torno do tratamento de exceções seja, na verdade, sobre quando e onde no código devemos lidar com os erros que possam surgir, e menos sobre os mecanismos usados para fazer isso.

Encerrando

Por enquanto é só isso! Se tiver algum comentário ou sugestão, fique à vontade para entrar em contato conosco no X, anteriormente conhecido como Twitter ou dê uma passada no nosso Slack da Comunidade. Espero que este artigo tenha sido útil e agradeço quaisquer comentários ou opiniões. Se você gostou, confira meus outros artigos sobre Java.

Compartilhar:

https://a.storyblok.com/f/270183/400x400/46a3751f47/sina-madani.png
Sina MadaniEx-funcionários da Vonage

Sina é um ex-membro da equipe da Vonage. Ele atuou como Java Developer Advocate na Vonage. Com formação acadêmica, ele tem uma curiosidade geral por tudo o que se relaciona a carros, computadores, programação, tecnologia e natureza humana. Em seu tempo livre, ele gosta de caminhar ou jogar videogames competitivos.