Tratamento de erros usando exceções sob a perspectiva de um programador de C++

Um tópico sobre o qual sua compreensão pode evoluir muito com o tempo, pois pouca experiência tem uma grande contribuição para impedir que os conceitos se tornem claros é o tópico de tratamento de erros usando exceções. Em C++, esse é um assunto até mais polêmico que em muitas outras linguagens e eu resolvi compartilhar um pouco de minha compreensão nesse texto.

Eu quero que você se esforce muito para não pensar nas implicações de performance que exceções introduzem no sistema, pelo menos até terminarmos de analisar as outras facetas dessa ideia, pois há muitos argumentos irracionais, que, na minha experiência, vêm principalmente de pessoas que estão falsamente preocupadas com performance. Se esforce para manter em mente as frases “um erro é uma exceção?” e “o que é um caso excepcional?”. Elas vão ajudar a entender motivações e serão discutidas mais adiante no texto.

Exceções – resumo/revisão rápida

Exceções formam um conceito de programação idealizado para tornar difícil o ato de ignorar erros, separar código do fluxo normal do programa do código de tratamento de erros — aumentando a legibilidade, diminuindo erros e melhorando a manutenção do sistema — e transportar tanta informação sobre o erro quanto possível.

A motivação para exceções terem sido idealizadas é inspiradora, principalmente quando se pensa em estilos de notificações de erros alternativos que eram/são comuns, como variáveis globais (errno), valores de retorno onde alguns valores possíveis são reservados para indicar erro (normalmente -1), valores de retorno representando um código de erro, valores de retorno indicando sucesso ou erro e outros. Algumas dessas técnicas alternativas poluem abstrações, como no caso onde o valor de retorno da função torna-se reservado para o erro e a função precisa ser refatorada para enviar o resultado através dos argumentos passados para ela.

Algumas das técnicas alternativas evoluíram para soluções que chegaram a adquirir o meu selo de “minimamente respeitável”, e agora nós temos outras soluções para se levar a sério, como, por exemplo, verificações forçadas estaticamente através do sistema de tipos (veja o exemplo de Rust). Mas, esse texto é sobre exceções, então, a partir de agora, evitarei menções desnecessárias a essas abordagens alternativas.

Uma proposta de “expected” para C++ sugere quatro características importantes para um sistema de tratamento de erros:

  • Visibilidade do erro.
  • Informação nos erros.
  • Código limpo.
  • Erros não intrusivos.

Eu recomendo a leitura da parte inicial da proposta para entender essas quatro características. Elas ajudam a julgar o que é um bom sistema de tratamento de erros, ressaltando indicativos importantes.

Isso é introdução mais que o suficiente para se começar a descrever o que são e como funcionam exceções. Exceções definem construções para o controle de fluxo de execução do programa onde separamos código “normal” de código “excepcional”. E existe a exceção, usada para representar e transportar informações sobre um erro. Quando um erro é detectado e a exceção correspondente é gerada, o fluxo de execução normal é abortado e o fluxo de execução excepcional é iniciado, recebendo a exceção lançada para que se possa fazer a decisão de tratamento de erro apropriada.

Sintaticamente, envolvemos o fluxo de código normal para o qual somos capazes de tratar alguma exceção usando um bloco “try” e, logo em seguida, introduzimos um bloco “catch”, que contém o código para o fluxo excepcional. Um exemplo é dado abaixo:

Bem simples e, para muitos novatos, epifanicamente elegante.

Onde exceções decepcionam

O principal problema de usabilidade com exceções, tal como é implementado na linguagem C++, é que você nunca está certo de que está tratando todas as exceções possíveis. Você chama uma função, verifica a assinatura dela, e não sabe quais possíveis exceções serão lançadas. E se, após uma atualização, a função passar a lançar um novo tipo de exceção, todo o código que a chama vai continuar compilando normalmente, sem nem mesmo um aviso sobre a necessidade de atualizar o código chamador, mas causará um erro em tempo de execução nos piores momentos — que são os momentos quando o programador está confiante que o sistema está pronto para produção.

Em Java, há uma funcionalidade chamada de exceções verificadas, em que as possíveis exceções a serem lançadas por uma função fazem parte de sua assinatura. Ou seja, o sistema de exceções é integrado ao sistema de tipos para aumentar a segurança do sistema. Com tal funcionalidade, se, para uma dada função, não há a declaração de que uma exceção X é possivelmente lançada, e essa possibilidade existe no corpo da função, um erro de compilação é gerado. Ou você trata todas as exceções que podem ser lançadas dentro da própria função, ou informa ao código chamador que é possível que algumas exceções sejam lançadas.

Há quem considere catch(…) com rethrow um devido tratamento de exceção, mas a exceção não está sendo tratada. Você está meramente ignorando toda a informação sobre o erro e transferindo a responsabilidade de tratá-la para algum outro lugar. Esse padrão é útil, no entanto, para fornecer garantias de exceções básicas e fortes, dois conceitos presentes na STL.

Não é possível deferir o tratamento de exceções, situação que já podemos encontrar em casos apenas um pouco mais distantes que os imaginados e idealizados. Em programação concorrente (assíncrona ou paralela), o ponto onde uma operação é iniciada e onde é completada podem ser diferentes. Tais pontos sendo diferentes, a exceção não é retornada para o código que requisitou a realização da operação, e você simplesmente usa outro paradigma para tratamento de erros. É até mais complicado quando se envolve threads — problema para o qual o padrão C++11 adicionou exception_ptr. Um dos paradigmas, o paradigma de futures & promises, restaura o suporte a exceções nesses casos, fazendo com que a operação de desembrulhar o valor, através da função-mebro get, possivelmente lance uma exceção, mas, para funcionar, esse truque se apoia no fato de que as exceções em C++ não são verificadas.

Um ponto final sobre a complexidade que exceções introduzem está relacionada a código de finalização, que sempre precisa ser chamado, independente de uma exceção ter sido lançada ou não. Se a linguagem não suporta RAII, adiciona blocos “finally” para resolver o problema. Se a linguagem suporta RAII, um conjunto de convenções e regras é criado e a possibilidade de se lançar uma exceção enquanto outra já está sendo lançada nasce, o que normalmente provoca o programa a ser terminado.

Basicamente, exceções podem se tornar o novo goto e, elas são até abusadas, para, por exemplo, se implementar sistema de pattern matching em C++.

Performance

Finalmente, o tópico que causa polêmica e que desencoraja o uso de exceções em qualquer caso que não seja excepcional, mas cuja preocupação ajuda a definir o que é um caso excepcional.

A primeira observação a se fazer sobre exceções é que elas não são lentas. Citando outro texto de minha própria autoria:

O documento TR18015 detalha duas abordagens principais para a implementação de suporte a exceções e o que o documento menciona como “abordagem tabela” é muito barata, realizando boa parte do que precisa ser computado em tempo de compilação e estruturando o código de forma que não há penalidade em tempo de execução para o fluxo de execução normal do programa. O custo, nessa abordagem, é que o programa fica maior, mas a parte extra pode ser movida para não interferir negativamente no uso de cache e, em sistemas com suporte a memória virtual, essa parte pode até ficar na swap até que torne-se necessária.

uma resposta no StackOverflow, caso você não queira se dar ao trabalho de passar pelo TR18015. A implementação do suporte a exceções é bem elegante e a discussão deveria deixar de ser se exceções são lentas e passar a ser quando usar exceções.

E cuidado com os benchmarks injustos que alimentem esse mito, assim como o P. alerta.

Então usar exceções pode conduzir a um código até mais rápido do que manualmente verificar se erros ocorreram, mas caso um erro aconteça, haverá uma drástica redução na performance. E, como exceções dependem do mecanismo de RTTI, pode ser que tratar erros usando exceções deixe de ser uma opção em sistemas de tempo real.

A solução para esse problema está em colocar uma linha divisória entre erros e exceções. Ou, mais objetivamente, o que é um caso excepcional. Se você usar exceções apenas em casos excepcionais, não há problema nenhum em utilizá-las devido a alguma degradação de performance, pois você atingiu um caso excepcional de qualquer forma.

Um exemplo de um caso excepcional é quando o seu sistema encontra um erro de lógica, pois continuar executando o programa pode causar corrupção do seu estado interno e ele deveria ser encerrado o mais cedo possível, parando somente para mostrar uma mensagem de erro ao usuário.

Outro exemplo de caso excepcional é um cliente de um jogo online, onde, caso a conexão caia, você não se importa com o tempo gasto para lançar a exceção, pois o sistema não pode fazer nada de útil enquanto o erro não for tratado, e reconectar ao servidor é uma operação que vai custar mais tempo que o tratamento da exceção, de qualquer forma. Esse mesmo exemplo de “queda de conexão”, entretanto, pode virar um caso não excepcional em um contexto diferente. No contexto de um servidor que está servindo milhares de clientes, por exemplo, quedas de conexões são esperadas o tempo todo e você não quer que o gargalo de lançar milhares de exceções diminua a capacidade do seu servidor.

O usuário do reddit @sazzer ilustra mais alguns casos do que é uma situação excepcional.

Conclusão

Responder a pergunta “esse é um caso excepcional?” é uma tarefa complicada, mas, pode se tornar mais fácil, fazendo uso de bom senso, das ferramentas certas e de um bom entendimento de suas implicações.

Além disso, um mesmo erro pode ser excepcional ou não a depender do contexto, então é preferível que se evite a dependência em exceções.

Aliado a todos os problemas que exceções trazem, eu sugiro que seu uso, em C++, seja reduzido. Entretanto, você simplesmente não pode se livrar de exceções usando C++ idiomático, então compreendê-las é importante.

Um sistema de tratamento de erros unificado é atraente, pois elimina a chance de escolhermos a abordagem errada em algum ponto. Entretanto, um sistema de tratamento de erros unificado deveria ter todas as vantagens que exceções possuem e se livrar de seus problemas mais sérios, inclusive os problemas de performance que impossibilita seu uso em sistemas de tempo real.

Pessoalmente, o sistema de tratamento de erros que eu vejo como ideal é a abordagem que é adotada em Rust, que emprega uso de conceitos como algebraic data types e pattern matching para oferecer um sistema de tratamento de erros bem expressivo e que é difícil de usar erroneamente.

UPDATE:

Acabei encontrando um texto interessante sobre a luta de um usuário para diminuir o tamanho da imagem do programa quando exceções são habilitadas.

Tags:, , ,

Comentários (with MarkDown support)

Preencha os seus dados abaixo ou clique em um ícone para log in:

Logotipo do WordPress.com

Você está comentando utilizando sua conta WordPress.com. Sair / Alterar )

Imagem do Twitter

Você está comentando utilizando sua conta Twitter. Sair / Alterar )

Foto do Facebook

Você está comentando utilizando sua conta Facebook. Sair / Alterar )

Foto do Google+

Você está comentando utilizando sua conta Google+. Sair / Alterar )

Conectando a %s

%d blogueiros gostam disto: