Código limpo e a arte de lidar com exceções

Publicados: 2022-03-11

As exceções são tão antigas quanto a própria programação. Na época em que a programação era feita em hardware, ou via linguagens de programação de baixo nível, exceções eram usadas para alterar o fluxo do programa e evitar falhas de hardware. Hoje, a Wikipedia define exceções como:

condições anômalas ou excepcionais que exigem processamento especial – muitas vezes alterando o fluxo normal de execução do programa…

E que lidar com eles requer:

construções de linguagem de programação especializada ou mecanismos de hardware de computador.

Portanto, exceções requerem tratamento especial e uma exceção sem tratamento pode causar um comportamento inesperado. Os resultados são muitas vezes espetaculares. Em 1996, a famosa falha no lançamento do foguete Ariane 5 foi atribuída a uma exceção de estouro não tratado. Os piores bugs de software da história contém alguns outros bugs que podem ser atribuídos a exceções não tratadas ou mal tratadas.

Com o tempo, esses erros e inúmeros outros (que talvez não fossem tão dramáticos, mas ainda catastróficos para os envolvidos) contribuíram para a impressão de que as exceções são ruins .

Mas as exceções são um elemento fundamental da programação moderna; eles existem para tornar nosso software melhor. Em vez de temer exceções, devemos abraçá-las e aprender a tirar proveito delas. Neste artigo, discutiremos como gerenciar exceções de forma elegante e usá-las para escrever um código limpo que seja mais sustentável.

Tratamento de exceções: é uma coisa boa

Com o surgimento da programação orientada a objetos (OOP), o suporte a exceções tornou-se um elemento crucial das linguagens de programação modernas. Um sistema robusto de tratamento de exceções é construído na maioria das linguagens, hoje em dia. Por exemplo, Ruby fornece o seguinte padrão típico:

 begin do_something_that_might_not_work! rescue SpecificError => e do_some_specific_error_clean_up retry if some_condition_met? ensure this_will_always_be_executed end

Não há nada de errado com o código anterior. Mas o uso excessivo desses padrões causará cheiros de código e não será necessariamente benéfico. Da mesma forma, o uso indevido deles pode realmente causar muitos danos à sua base de código, tornando-a frágil ou ofuscando a causa dos erros.

O estigma em torno das exceções muitas vezes faz com que os programadores se sintam perdidos. É um fato da vida que as exceções não podem ser evitadas, mas muitas vezes nos ensinam que elas devem ser tratadas com rapidez e decisão. Como veremos, isso não é necessariamente verdade. Em vez disso, devemos aprender a arte de lidar com exceções com elegância, tornando-as harmoniosas com o restante do nosso código.

A seguir estão algumas práticas recomendadas que ajudarão você a adotar exceções e fazer uso delas e de suas habilidades para manter seu código sustentável , extensível e legível :

  • manutenibilidade : nos permite encontrar e corrigir facilmente novos bugs, sem o medo de quebrar a funcionalidade atual, introduzir mais bugs ou ter que abandonar o código completamente devido ao aumento da complexidade ao longo do tempo.
  • extensibilidade : nos permite adicionar facilmente à nossa base de código, implementando requisitos novos ou alterados sem quebrar a funcionalidade existente. A extensibilidade fornece flexibilidade e permite um alto nível de reutilização para nossa base de código.
  • legibilidade : nos permite ler facilmente o código e descobrir seu propósito sem gastar muito tempo cavando. Isso é fundamental para descobrir bugs e códigos não testados com eficiência.

Esses elementos são os principais fatores do que poderíamos chamar de limpeza ou qualidade , que não é uma medida direta em si, mas sim o efeito combinado dos pontos anteriores, como demonstrado neste quadrinho:

"WTFs/m" por Thom Holwerda, OSNews

Com isso dito, vamos mergulhar nessas práticas e ver como cada uma delas afeta essas três medidas.

Nota: Apresentaremos exemplos do Ruby, mas todas as construções demonstradas aqui possuem equivalentes nas linguagens OOP mais comuns.

Sempre crie sua própria hierarquia ApplicationError

A maioria das linguagens vem com uma variedade de classes de exceção, organizadas em uma hierarquia de herança, como qualquer outra classe OOP. Para preservar a legibilidade, a capacidade de manutenção e a extensibilidade de nosso código, é uma boa ideia criar nossa própria subárvore de exceções específicas do aplicativo que estendem a classe de exceção básica. Investir algum tempo na estruturação lógica dessa hierarquia pode ser extremamente benéfico. Por exemplo:

 class ApplicationError < StandardError; end # Validation Errors class ValidationError < ApplicationError; end class RequiredFieldError < ValidationError; end class UniqueFieldError < ValidationError; end # HTTP 4XX Response Errors class ResponseError < ApplicationError; end class BadRequestError < ResponseError; end class UnauthorizedError < ResponseError; end # ... 

Exemplo de hierarquia de exceção de aplicativo: StandardError está no topo. ApplicationError herda dele. ValidationError e ResponseError herdam disso. RequiredFieldError e UniqueFieldError herdam de ValidationError, enquanto BadRequestError e UnauthorizedError herdam de ResponseError.

Ter um pacote de exceções extensível e abrangente para nosso aplicativo facilita muito o manuseio dessas situações específicas do aplicativo. Por exemplo, podemos decidir quais exceções manipular de maneira mais natural. Isso não apenas aumenta a legibilidade do nosso código, mas também aumenta a capacidade de manutenção de nossos aplicativos e bibliotecas (gems).

Do ponto de vista da legibilidade, é muito mais fácil de ler:

 rescue ValidationError => e

Do que ler:

 rescue RequiredFieldError, UniqueFieldError, ... => e

Do ponto de vista da manutenção, digamos, por exemplo, estamos implementando uma API JSON e definimos nosso próprio ClientError com vários subtipos, para serem usados ​​quando um cliente enviar uma solicitação incorreta. Se qualquer um deles for gerado, o aplicativo deverá renderizar a representação JSON do erro em sua resposta. Será mais fácil corrigir, ou adicionar lógica, a um único bloco que trata ClientError s em vez de fazer um loop sobre cada possível erro do cliente e implementar o mesmo código de manipulador para cada um. Em termos de extensibilidade, se posteriormente tivermos que implementar outro tipo de erro de cliente, podemos confiar que já será tratado corretamente aqui.

Além disso, isso não nos impede de implementar tratamento especial adicional para erros de clientes específicos anteriormente na pilha de chamadas ou alterar o mesmo objeto de exceção ao longo do caminho:

 # app/controller/pseudo_controller.rb def authenticate_user! fail AuthenticationError if token_invalid? || token_expired? User.find_by(authentication_token: token) rescue AuthenticationError => e report_suspicious_activity if token_invalid? raise e end def show authenticate_user! show_private_stuff!(params[:id]) rescue ClientError => e render_error(e) end

Como você pode ver, levantar essa exceção específica não nos impediu de poder tratá-la em diferentes níveis, alterando-a, elevando-a novamente e permitindo que o manipulador da classe pai a resolvesse.

Duas coisas a serem observadas aqui:

  • Nem todos os idiomas suportam a geração de exceções de dentro de um manipulador de exceção.
  • Na maioria das linguagens, gerar uma nova exceção de dentro de um manipulador fará com que a exceção original seja perdida para sempre, portanto, é melhor aumentar novamente o mesmo objeto de exceção (como no exemplo acima) para evitar perder o controle da causa original da erro. (A menos que você esteja fazendo isso intencionalmente).

Nunca rescue Exception

Ou seja, nunca tente implementar um manipulador catch-all para o tipo de exceção base. Resgatar ou capturar todas as exceções no atacado nunca é uma boa ideia em nenhuma linguagem, seja globalmente em um nível de aplicativo básico ou em um pequeno método oculto usado apenas uma vez. Não queremos resgatar Exception porque ela ofuscará o que realmente aconteceu, prejudicando tanto a capacidade de manutenção quanto a extensibilidade. Podemos perder muito tempo depurando qual é o problema real, quando pode ser tão simples quanto um erro de sintaxe:

 # main.rb def bad_example i_might_raise_exception! rescue Exception nah_i_will_always_be_here_for_you end # elsewhere.rb def i_might_raise_exception! retrun do_a_lot_of_work! end

Você deve ter notado o erro no exemplo anterior; o return está digitado incorretamente. Embora os editores modernos forneçam alguma proteção contra esse tipo específico de erro de sintaxe, este exemplo ilustra como rescue Exception prejudica nosso código. Em nenhum momento o tipo real da exceção (neste caso um NoMethodError ) é abordado, nem é exposto ao desenvolvedor, o que pode nos fazer perder muito tempo correndo em círculos.

Nunca rescue mais exceções do que você precisa

O ponto anterior é um caso específico desta regra: Devemos sempre ter cuidado para não generalizar demais nossos manipuladores de exceção. As razões são as mesmas; sempre que resgatamos mais exceções do que deveríamos, acabamos ocultando partes da lógica do aplicativo dos níveis mais altos do aplicativo, sem mencionar a supressão da capacidade do desenvolvedor de lidar com a exceção por conta própria. Isso afeta severamente a extensibilidade e a capacidade de manutenção do código.

Se tentarmos manipular diferentes subtipos de exceção no mesmo manipulador, introduziremos blocos de código gordos que têm muitas responsabilidades. Por exemplo, se estamos construindo uma biblioteca que consome uma API remota, lidar com um MethodNotAllowedError (HTTP 405), geralmente é diferente de lidar com um UnauthorizedError (HTTP 401), mesmo que ambos sejam ResponseError s.

Como veremos, muitas vezes existe uma parte diferente do aplicativo que seria mais adequada para lidar com exceções específicas de uma maneira mais DRY.

Portanto, defina a responsabilidade única de sua classe ou método e lide com o mínimo de exceções que satisfaçam esse requisito de responsabilidade . Por exemplo, se um método é responsável por obter informações de estoque de uma API remota, ele deve lidar com exceções que surgem da obtenção apenas dessas informações e deixar o tratamento dos outros erros para um método diferente projetado especificamente para essas responsabilidades:

 def get_info begin response = HTTP.get(STOCKS_URL + "#{@symbol}/info") fail AuthenticationError if response.code == 401 fail StockNotFoundError, @symbol if response.code == 404 return JSON.parse response.body rescue JSON::ParserError retry end end

Aqui definimos o contrato para este método para obter apenas as informações sobre o estoque. Ele lida com erros específicos do endpoint , como uma resposta JSON incompleta ou malformada. Ele não lida com o caso de falha ou expiração da autenticação, ou se o estoque não existir. Eles são de responsabilidade de outra pessoa e são explicitamente passados ​​pela pilha de chamadas onde deve haver um lugar melhor para lidar com esses erros de maneira DRY.

Resista ao desejo de lidar com exceções imediatamente

Este é o complemento do último ponto. Uma exceção pode ser tratada em qualquer ponto da pilha de chamadas e em qualquer ponto da hierarquia de classes, portanto, saber exatamente onde lidar com isso pode ser confuso. Para resolver esse enigma, muitos desenvolvedores optam por lidar com qualquer exceção assim que ela surge, mas investir tempo pensando nisso geralmente resultará em encontrar um local mais apropriado para lidar com exceções específicas.

Um padrão comum que vemos em aplicativos Rails (especialmente aqueles que expõem APIs somente JSON) é o seguinte método de controlador:

 # app/controllers/client_controller.rb def create @client = Client.new(params[:client]) if @client.save render json: @client else render json: @client.errors end end

(Observe que, embora tecnicamente não seja um manipulador de exceção, funcionalmente, ele serve ao mesmo propósito, pois @client.save só retorna false quando encontra uma exceção.)

Nesse caso, no entanto, repetir o mesmo manipulador de erros em cada ação do controlador é o oposto de DRY e prejudica a capacidade de manutenção e a extensibilidade. Em vez disso, podemos usar a natureza especial da propagação de exceções e tratá-las apenas uma vez, na classe do controlador pai, ApplicationController :

 # app/controllers/client_controller.rb def create @client = Client.create!(params[:client]) render json: @client end
 # app/controller/application_controller.rb rescue_from ActiveRecord::RecordInvalid, with: :render_unprocessable_entity def render_unprocessable_entity(e) render \ json: { errors: e.record.errors }, status: 422 end

Dessa forma, podemos garantir que todos os erros ActiveRecord::RecordInvalid sejam tratados adequadamente e DRY-ly em um só lugar, no nível base do ApplicationController . Isso nos dá a liberdade de mexer com eles se quisermos lidar com casos específicos no nível inferior, ou simplesmente deixá-los se propagarem normalmente.

Nem todas as exceções precisam de tratamento

Ao desenvolver uma gem ou uma biblioteca, muitos desenvolvedores tentarão encapsular a funcionalidade e não permitir que nenhuma exceção se propague para fora da biblioteca. Mas, às vezes, não é óbvio como lidar com uma exceção até que o aplicativo específico seja implementado.

Tomemos o ActiveRecord como exemplo da solução ideal. A biblioteca fornece aos desenvolvedores duas abordagens para a completude. O método save trata exceções sem propagá-las, simplesmente retornando false , enquanto save! gera uma exceção quando falha. Isso dá aos desenvolvedores a opção de lidar com casos de erro específicos de maneira diferente ou simplesmente lidar com qualquer falha de maneira geral.

Mas e se você não tiver tempo ou recursos para fornecer uma implementação tão completa? Nesse caso, se houver alguma incerteza, é melhor expor a exceção e liberá-la na natureza.

Aqui está o porquê: Estamos trabalhando com a movimentação de requisitos quase o tempo todo, e tomar a decisão de que uma exceção sempre será tratada de uma maneira específica pode realmente prejudicar nossa implementação, prejudicando a extensibilidade e a capacidade de manutenção e potencialmente adicionando uma enorme dívida técnica, especialmente ao desenvolver bibliotecas.

Veja o exemplo anterior de um consumidor de API de ações buscando preços de ações. Optamos por lidar com a resposta incompleta e malformada no local e optamos por tentar novamente a mesma solicitação até obtermos uma resposta válida. Mas, posteriormente, os requisitos podem mudar, de modo que devemos voltar aos dados históricos de estoque salvos, em vez de tentar novamente a solicitação.

Neste ponto, seremos obrigados a alterar a própria biblioteca, atualizando como esta exceção é tratada, pois os projetos dependentes não irão tratar esta exceção. (Como poderiam? Nunca foi exposto a eles antes.) Também teremos que informar os proprietários dos projetos que contam com nossa biblioteca. Isso pode se tornar um pesadelo se houver muitos desses projetos, já que eles provavelmente foram criados com base na suposição de que esse erro será tratado de uma maneira específica.

Agora, podemos ver para onde estamos indo com o gerenciamento de dependências. A perspectiva não é boa. Essa situação acontece com bastante frequência e, na maioria das vezes, degrada a utilidade, a extensibilidade e a flexibilidade da biblioteca.

Então, aqui está o resultado final: se não estiver claro como uma exceção deve ser tratada, deixe-a propagar normalmente . Há muitos casos em que existe um local claro para lidar com a exceção internamente, mas há muitos outros casos em que é melhor expor a exceção. Portanto, antes de optar por lidar com a exceção, pense duas vezes. Uma boa regra geral é apenas insistir em lidar com exceções quando você estiver interagindo diretamente com o usuário final.

Siga a convenção

A implementação do Ruby e, mais ainda, do Rails, segue algumas convenções de nomenclatura, como distinguir entre method_names e method_names! Com um estrondo." Em Ruby, o bang indica que o método irá alterar o objeto que o invocou, e em Rails, significa que o método irá lançar uma exceção se não executar o comportamento esperado. Tente respeitar a mesma convenção, especialmente se você for tornar sua biblioteca de código aberto.

Se fôssemos escrever um novo method! com um estrondo em uma aplicação Rails, devemos levar essas convenções em consideração. Não há nada que nos obrigue a levantar uma exceção quando este método falha, mas ao desviar-se da convenção, este método pode enganar os programadores fazendo-os acreditar que terão a chance de lidar com as próprias exceções, quando, na verdade, não o farão.

Outra convenção Ruby, atribuída a Jim Weirich, é usar fail para indicar falha de método, e usar raise apenas se você estiver re-aumentando a exceção.

Um aparte, porque eu uso exceções para indicar falhas, quase sempre uso a palavra-chave fail em vez da palavra-chave raise em Ruby. Fail e raise são sinônimos, portanto não há diferença, exceto que fail comunica mais claramente que o método falhou. A única vez que uso raise é quando estou pegando uma exceção e fazendo re-raise, porque aqui não estou falhando, mas sim levantando uma exceção de forma explícita e proposital. Esta é uma questão de estilo que sigo, mas duvido que muitas outras pessoas o façam.

Muitas outras comunidades de linguagem adotaram convenções como essas sobre como as exceções são tratadas, e ignorar essas convenções prejudicará a legibilidade e a manutenção do nosso código.

Logger.log(tudo)

Essa prática não se aplica apenas a exceções, é claro, mas se há uma coisa que sempre deve ser registrada, é uma exceção.

Logging é extremamente importante (importante o suficiente para Ruby enviar um logger com sua versão padrão). É o diário de nossos aplicativos, e ainda mais importante do que manter um registro de como nossos aplicativos são bem-sucedidos, é registrar como e quando eles falham.

Não há escassez de bibliotecas de log ou serviços baseados em log e padrões de design. É fundamental manter o controle de nossas exceções para que possamos revisar o que aconteceu e investigar se algo não parece certo. Mensagens de log adequadas podem apontar os desenvolvedores diretamente para a causa de um problema, economizando um tempo imensurável.

Essa confiança de código limpo

O tratamento de exceção limpo enviará a qualidade do seu código para a lua!
Tweet

As exceções são uma parte fundamental de toda linguagem de programação. Eles são especiais e extremamente poderosos, e devemos aproveitar seu poder para elevar a qualidade do nosso código em vez de nos exaurir lutando com eles.

Neste artigo, mergulhamos em algumas boas práticas para estruturar nossas árvores de exceção e como pode ser benéfico para legibilidade e qualidade estruturá-las logicamente. Analisamos diferentes abordagens para lidar com exceções, seja em um local ou em vários níveis.

Vimos que é ruim “pegar todos”, e que não há problema em deixá-los flutuar e borbulhar.

Analisamos onde lidar com exceções de maneira DRY e aprendemos que não somos obrigados a lidar com elas quando ou onde elas surgem pela primeira vez.

Discutimos quando exatamente é uma boa ideia lidar com eles, quando é uma má ideia e por que, em caso de dúvida, é uma boa ideia deixá-los se propagarem.

Por fim, discutimos outros pontos que podem ajudar a maximizar a utilidade das exceções, como seguir convenções e registrar tudo.

Com essas diretrizes básicas, podemos nos sentir muito mais confortáveis ​​e confiantes lidando com casos de erro em nosso código e tornando nossas exceções realmente excepcionais!

Agradecimentos especiais a Avdi Grimm e sua incrível palestra Exceptional Ruby, que ajudou muito na elaboração deste artigo.

Relacionado: Dicas e práticas recomendadas para desenvolvedores Ruby