Novembro de 2015

Volume 30, Número 12

Essential .NET - Tratamento de Exceção de C#

Por Mark Michaelis | Novembro de 2015

Mark MichaelisBem-vindo à coluna inaugural do Essential .NET. Aqui, você poderá seguir tudo que está acontecendo no mundo do Microsoft .NET Framework, seja em relação aos avanços no C# vNext (atualmente C# 7.0), até internos do .NET aprimorados ou acontecimentos na frente Roslyn e .NET Core (como movimentação do MSBuild para software livre).

Escrevo e desenvolvo com o .NET desde que ele foi anunciado para visualização em 2000. A maior parte do que escrevo não será apenas sobre coisas novas, mas sobre como desfrutar da tecnologia com uma visão voltada para melhores práticas.

Moro em Spokane, Washington, onde sou o “Nerd chefe” de uma empresa de consultoria de alto nível chamada IntelliTect (IntelliTect.com). A IntelliTect é especializada em desenvolver os itens mais difíceis com excelência. Sou um Microsoft MVP (atualmente para C#) há 20 anos, além de diretor regional da Microsoft por oito anos desse tempo total. Hoje, esta coluna é lançada com uma visão sobre diretrizes de tratamento de exceções atualizadas.

O C# 6.0 incluiu dois novos recursos de tratamento de exceções. Primeiro, inclui suporte para condições de exceção – a capacidade de fornecer uma expressão que filtra uma exceção de entrar no bloco catch antes de a pilha ser desenrolada. Segundo, inclui suporte assíncrono de um bloco catch, o que não era possível no C# 5.0 quando a assincronia foi adicionada à linguagem. Além disso, ocorreram outras alterações nas últimas cinco versões do C# e do .NET Framework correspondente, alterações que, em alguns casos, são significativas o suficiente para exigir edições às diretrizes de codificação do C#. Nesta edição, examinarei diversas destas alterações e fornecerei diretrizes de codificação atualizadas relacionadas ao tratamento de exceções, capturando as exceções.

Capturando exceções: Revisão

Como já é bem compreendido, lançar um tipo de exceção em especial permite que o catcher use o tipo de exceção em si para identificar o problema. Em outras palavras, não é necessário capturar a exceção e usar uma instrução switch na mensagem de exceção para determinar qual ação deve ser tomada em relação à exceção. Em vez disso, o C# permite diversos blocos catch, cada um direcionando um tipo de exceção específico, como mostrado na Figura 1.

Figura 1 - Capturando Tipos de Exceções Diferentes

using System;
public sealed class Program
{
  public static void Main(string[] args)
    try
    {
       // ...
      throw new InvalidOperationException(
         "Arbitrary exception");
       // ...
   }
   catch(System.Web.HttpException exception)
     when(exception.GetHttpCode() == 400)
   {
     // Handle System.Web.HttpException where
     // exception.GetHttpCode() is 400.
   }
   catch (InvalidOperationException exception)
   {
     bool exceptionHandled=false;
     // Handle InvalidOperationException
     // ...
     if(!exceptionHandled)
       // In C# 6.0, replace this with an exception condition
     {
        throw;
     }
    }  
   finally
   {
     // Handle any cleanup code here as it runs
     // regardless of whether there is an exception
   }
 }
}

Quando ocorrer uma exceção, a execução seguirá para o primeiro bloco catch que poderá tratá-la. Se houver mais de um bloco catch associado à tentativa, o grau de uma correspondência será determinado pela cadeia de herança (supondo que não haja nenhuma condição de exceção C# 6.0) e a primeira a corresponder processará a exceção. Por exemplo, mesmo que a exceção lançada seja do tipo System.Exception, esta relação ocorre por meio da herança, já que System.Invalid­OperationException é derivado, principalmente, de System.Exception. Como InvalidOperationException é a correspondência mais próxima da exceção lançada, catch(InvalidOperationException...) capturará a exceção e não o bloco catch(InvalidOperationException...), se houver um.

Os blocos catch devem aparecer em ordem (novamente supondo que não haja uma condição de exceção do C# 6.0), do mais específico ao mais geral, a fim de evitar erro em tempo de compilação. Por exemplo, adicionar um bloco catch(Exception...) antes de qualquer outra exceção resultará em um erro de compilação, pois todas as exceções anteriores são derivadas de System.Exception no mesmo ponto em que sua cadeia de herança. Observe também que um parâmetro nomeado para o bloco catch não é necessário. Na verdade, uma captura final sem ser até o tipo de parâmetro é permitida, infelizmente, como discutido sob o bloco catch geral.

Na ocasião, depois de capturar uma exceção, você poderá determinar que, na verdade, não é possível tratar corretamente a exceção. Neste cenário, você tem duas principais opções. A primeira é relançar uma exceção diferente. Há três cenários comuns para isso fizer sentido:

Cenário nº 1 A exceção capturada não identifica, de modo suficiente, o problema que a disparou. Por exemplo, ao chamar System.Net.WebClient.DownloadString com uma URL válida, o tempo de execução deve lançar um System.Net.WebException quando não houver uma conexão de rede – a mesma exceção lançada com uma URL não existente.

Cenário nº 2 A exceção capturada inclui dados privados que não devem ser expostos a uma cadeia de chamadas. Por exemplo, uma versão inicial do CLR v1 (pré-alfa) tinha uma exceção com algo como “Exceção de segurança: você não tem permissão para determinar o caminho de c:\temp\foo.txt.”

Cenário nº 3 O tipo de exceção é muito específico para o chamador poder tratar. Por exemplo, uma exceção System.IO (como Unauthorized­AccessException IOException FileNotFoundException DirectoryNotFoundException PathTooLongException, NotSupportedException ou SecurityException ArgumentException) ocorre no servidor ao invocar um serviço Web para pesquisar um CEP.

Ao relançar uma exceção diferente, preste atenção ao fato que ela poderia perder a exceção original (supostamente intencional, no caso do Cenário 2). Para evitar que isso ocorra, determine a propriedade InnerException da exceção de encapsulamento, que costuma ser atribuída pelo construtor, com a exceção obtida, a menos que fazer isso exponha dados privados que não devem ser expostos ainda mais na cadeia de chamada. Ao fazer isso, o rastreamento de pilha continua disponível.

Se você não definir a exceção interna, mas ainda especificar a instância de exceção depois da instrução throw (exceção throw), o rastreamento de pilha de local será definido na instância de exceção. Mesmo se você relançar a exceção capturada anteriormente, cujo rastreamento de pilha já está definido, ela será redefinida.

Uma segunda opção ao capturar uma exceção é determinar que, na verdade, não é possível tratá-la corretamente. Sob este cenário, você precisará relançar a mesma exceção, enviando-a ao próximo manipulador na cadeia de chamada. O bloco catch InvalidOperationException da Figura 1 demonstra isso. Uma instrução throw é exibida sem nenhuma identificação da exceção a ser lançada (throw está por si só), apesar de uma instância de exceção ser exibida no escopo do bloco catch que poderia ser relançado. O lançamento de uma exceção específica atualizaria todas as informações da pilha para corresponder à nova localização de lançamento. Como resultado, todas as informações da pilha que indicam o site de chamada em que a exceção ocorreu originalmente, seriam perdidas, tornando significativamente mais difícil o diagnóstico do problema. Após determinar que um bloco catch não pode tratar a exceção de modo suficiente, a exceção deve ser relançada usando uma instrução throw vazia.

Independentemente de você estar relançando a mesma exceção ou encapsulando uma, a diretriz geral é para evitar relatórios ou registros em log da exceção na parte mais inferior da pilha de chamadas. Em outras palavras, não registre uma exceção todas as vezes que você capturá-la e relançá-la. Isso levará a um email secundário desnecessário nos arquivos de log sem adicionar o valor, pois o mesmo item será registrado todas as vezes. Além disso, a exceção inclui os dados de rastreamento de pilha de quando ela foi lançada, para que não seja necessário registrá-los todas as vezes. De qualquer forma, registre a exceção sempre que ela for tratada ou, caso ela não seja tratada, registre a exceção antes de desligar um processo.

Lançando Exceções Existentes Sem Substituir as Informações da Pilha

No C#5.0, foi adicionado um mecanismo que permite o lançamento de uma exceção lançada anteriormente sem perder as informações de rastreamento de pilha na exceção original. Isso permite a você relançar exceções, por exemplo, mesmo que seja fora de um bloco catch e, portanto, sem usar um lançamento vazio. Apesar de ser raro ter que fazer isso, em algumas ocasiões, as exceções são encapsuladas ou salvas até que a execução do programa seja movida para fora do bloco catch. Por exemplo, o código multi-thread pode encapsular uma exceção com uma AggregateException. O .NET Framework 4.5 fornece uma classe System.Runtime.ExceptionServices.ExceptionDispatchInfo especificamente para tratar este cenário por meio do uso de seus métodos Throw de instância e Capture estáticos. A Figura 2 demonstra como relançar a exceção sem redefinir as informações do rastreamento de pilha ou usando uma instrução de lançamento vazia.

Figura 2 - Usando ExceptionDispatchInfo para Relançar uma Exceção

using System
using System.Runtime.ExceptionServices;
using System.Threading.Tasks;
Task task = WriteWebRequestSizeAsync(url);
try
{
  while (!task.Wait(100))
{
    Console.Write(".");
  }
}
catch(AggregateException exception)
{
  exception = exception.Flatten();
  ExceptionDispatchInfo.Capture(
    exception.InnerException).Throw();
}

Com o método ExeptionDispatchInfo.Throw, o compilador não o trata como uma instrução de retorno, da mesma forma que ele pode ser uma instrução de lançamento normal. Por exemplo, se a assinatura de método retornou um valor, mas nenhum valor foi retornado do caminho de código com ExceptionDispatchInfo.Throw, o compilador emitirá um erro indicando que nenhum valor foi retornado. Na ocasião, os desenvolvedores podem ser forçados a seguir ExceptionDispatchInfo.Throw com uma instrução de retorno, mesmo que tal instrução nunca seja executada no tempo de execução. Em vez disso, ela será lançada.

Capturando Exceções no C# 6.0

A diretriz de tratamento de exceção geral é para evitar a captura de exceções que você não consegue abordar por completo. Entretanto, como as expressões catch antes do C# 6.0 podiam filtrar somente pelo tipo de exceção, a capacidade de verificar os dados de exceção e o contexto antes de desenrolar a pilha no bloco catch exigia que o bloco catch se tornasse o manipulador da exceção antes de examiná-lo. Infelizmente, após determinar o não tratamento da exceção, é trabalhoso gravar um código que permite um bloco catch diferente dentro do mesmo contexto para manusear a exceção. Além disso, relançar a mesma exceção resulta em ter que invocar o processo de exceção de duas etapas novamente, um processo que envolve, primeiro, a entrega da exceção na cadeia de chamada, até ela encontrar uma que possa tratá-la e, segundo, desenrolar a pilha de chamadas para cada quadro entre a exceção e o catch de captura.

Quando a exceção é lançada, em vez de desenrolar a pilha de chamadas no bloco catch somente para que a exceção seja relançada, já que um exame adicional da exceção revelou que ela não seria manuseada de modo suficiente é, obviamente preferível, não capturar a exceção no primeiro momento. A partir do C# 6.0, uma expressão condicional adicional está disponível para blocos catch. Em vez de limitar se um bloco catch é correspondente somente com base em uma correspondência de tipo de exceção, o C# 6.0 inclui suporte para uma cláusula condicional. A cláusula when permite a você fornecer uma expressão booliana que filtra o bloco catch para tratar somente da exceção, se esta condição for true. O bloco System.Web.HttpException na Figura 1 demonstrou isso com um operador de comparação de igualdade.

Um resultado interessante da condição de exceção é que, quando uma condição de exceção é fornecida, o compilador não força os blocos catch para aparecerem na ordem da cadeia de herança. Por exemplo, uma captura do tipo System.ArgumentException com uma condição de exceção acompanhante pode, agora, aparecer antes do tipo System.ArgumentNullException mais específico, mesmo que ele seja derivado do primeiro item. Isso é importante porque permite a você gravar uma condição de exceção específica emparelhada a um tipo de exceção geral, seguido por um tipo de exceção mais específico (com ou sem uma condição de exceção). O comportamento do tempo de execução permanece consistente com versões mais antigas do C#; as exceções são capturadas pelo primeiro bloco catch correspondente. A complexidade adicionada é, simplesmente, se uma correspondência de bloco catch é determinada pela combinação da condição de tipo e exceção, e o compilador só reforça o relativo de ordem para os blocos catch sem condições de exceção. Por exemplo, um catch(System.Exception) com uma condição de exceção pode aparecer antes de um catch(System.Argument­Exception) com ou sem uma condição de exceção. Entretanto, quando um catch para um tipo de exceção sem uma condição de exceção for exibido, nenhuma captura de um bloco de exceção mais específico (say catch(System.ArgumentNullException)) pode ocorrer quando houver uma condição de exceção. Isso faz com que o programador tenha a “flexibilidade” de codificar condições de exceção que estão, potencialmente, fora de serviço – com condições de exceção anteriores capturando exceções destinadas às posteriores, renderizando, potencialmente, as posteriores sem intenção de ser alcançadas. Por fim, a ordem dos blocos catch é similar à maneira como você ordenaria instruções if-else. Quando a condição é atendida, todos os outros blocos catch são ignorados. Diferentemente das condições em uma instrução if-else, entretanto, todos os blocos catch devem incluir a verificação de tipo de exceção.

Diretrizes de manuseio de Exceção Atualizadas

O exemplo do operador de comparação na Figura 1 é essencial, mas a condição de exceção não é limitada à simplicidade. Você pode, por exemplo, fazer uma chamada de método para validar uma condição. O único requisito é que a expressão é um predicado, retornando um valor booliano. Em outras palavras, você pode, basicamente, executar qualquer código que desejar de dentro da cadeia de chamada de exceção de catch. Isso abre a possibilidade de nunca mais ter que capturar e relançar a mesma exceção novamente; basicamente, você consegue restringir o contexto suficientemente antes de capturar a exceção para somente capturá-la quando for válido fazer isso. Portanto, a diretriz para evitar a captura de exceções que você não consegue tratar por completo se torna uma realidade. Na verdade, qualquer verificação condicional relacionada a uma instrução de lançamento vazia poderá ser sinalizada com um code smell e evitada. Considere adicionar uma condição de exceção em favor de ter que usar uma instrução de lançamento vazia, exceto para persistir um estado volátil antes de um processo terminar.

Dito isso, os desenvolvedores devem limitar cláusulas condicionais para verificar somente o contexto. Isso é importante porque se a expressão condicional em si lançar uma exceção, esta nova exceção será ignorada e a condição será tratada como false. Por este motivo, você deve evitar lançar exceções na expressão condicional da exceção.

Bloco Catch Geral

O C# exige que qualquer objeto que o código lançar seja derivado de System.Exception. Entretanto, este requisito não é universal para todos as linguagens. O C/C++, por exemplo, permite que qualquer tipo de objeto seja lançado, incluindo exceções gerenciadas que não derivam de System.Exception ou, até mesmo, tipos primitivos, como int ou string. A partir do C# 2.0, todas as exceções, sejam elas derivadas ou não de System.Exception, serão propagadas para os assemblies C#, como derivadas de System.Exception. O resultado é que os blocos catch System.Exception capturarão todas as exceções “manuseadas de modo razoável” não capturadas por blocos anteriores. Entretanto, antes do C# 1.0, se uma exceção não derivada de System.Exception fosse lançada de uma chamada de método (residindo em um assembly não gravado no C#), a exceção não seria capturada por um bloco catch(System.Exception). Por este motivo, o C# também dá suporte a um bloco catch geral (catch{ }), que agora se comporta de modo idêntico ao bloco catch(exceção System.Exception), exceto que não há um nome de variável ou tipo. A desvantagem disso é que simplesmente não há uma instância de exceção para acessar e, portanto, não há como saber o curso apropriado da ação. Não seria possível nem registrar a exceção ou reconhecer o caso improvável, em que tal exceção seria inócua.

Na prática, o bloco catch(System.Exception) e o bloco catch geral, referenciados aqui como bloco catch System.Exception, devem ser evitados, exceto sob a pretensão de “manusear” a exceção ao registrá-la antes de desligar o processo. Seguindo o princípio geral de somente capturar exceções que você manuseia, seria presunçoso gravar o código para o qual o programador declara – esta captura pode manusear todas as exceções que podem ser lançadas. Primeiro, o esforço de catalogar todas as exceções (principalmente no corpo de um Principal, em que a quantidade de código de execução é a maior e o contexto, provavelmente, o menor) parece ser monumental, exceto pelo mais simples dos programas. Segundo, há diversas exceções possíveis que podem ser lançadas inesperadamente.

Antes do C# 4.0, havia um terceiro conjunto de exceções de estado corrompidas para o qual um problema não poderia ser recuperado de modo geral. Entretanto, este conjunto é menos preocupante a partir do C# 4.0, já que a captura de System.Exception (ou um bloco de captura geral) não capturará, na verdade, tais exceções. (Tecnicamente, você pode decorar um método com System.Runtime.ExceptionServices.HandleProcessCorruptedStateExceptions para que até mesmo estas exceções sejam capturadas, mas a probabilidade de você conseguir resolver de modo suficiente tal exceção é extremamente desafiadora. Veja bit.ly/1FgeCU6 para obter mais informações.)

Uma observação técnica a ser notada sobre exceções de estado corrompidas é que elas não serão passadas pelos blocos catch System.Exception quando lançadas pelo tempo de execução. Um lançamento explícito de uma exceção de estado corrompida, como System.StackOverflowException ou outra System.SystemException será, na verdade, capturado. Entretanto, este lançamento seria extremamente enganoso e tem suporte somente por motivos de compatibilidade com versões anteriores. A diretriz atual não é para lançar nenhuma das exceções de estado corrompido (incluindo System.StackOverflowException, System.SystemException, System.OutOfMemoryException, System.Runtime.Interop­Services.COMException, System.Runtime.InteropServices.SEH­Exception e System.ExecutionEngineException).

Para resumir, evite usar um bloco catch System.Exception, a menos que seja para lidar com a exceção com um código de limpeza e registrar a exceção antes de relançar ou desligar normalmente o aplicativo. Por exemplo, se o bloco catch salvar com sucesso dados voláteis (algo que pode não ser, necessariamente, suposto como tal, também pode estar corrompido) antes de desligar o aplicativo ou relançar a exceção. Ao encontrar um cenário pelo qual o aplicativo é encerrado por ser inseguro para continuar a execução, o código deverá invocar o método System.Environment.FailFast. Evite System.Exception e blocos catch gerais, exceto para registrar normalmente a exceção antes de desligar o aplicativo.

Conclusão

Neste artigo, forneci diretrizes atualizadas para o tratamento de exceções – captura de exceções e atualizações causadas por aprimoramentos no C# e o .NET Framework que ocorreram nas últimas versões. Apesar de haver algumas novas diretrizes, muitas delas continuam como antes. Veja aqui um resumo das diretrizes para capturar exceções:

  • EVITE capturar exceções que você não consegue tratar por completo.
  • EVITE ocultar (descartar) exceções que você não consegue tratar por completo.
  • USE um lançamento para relançar uma exceção, em vez de lançar <objeto de exceção> em um bloco catch.
  • DEFINA a propriedade InnerException da exceção de encapsulamento com a exceção capturada, a menos que isso exponha dados privados.
  • CONSIDERE a condição de uma exceção em favor de ter que relançar uma exceção depois de capturar uma que você não consegue tratar.
  • EVITE lançar exceções da expressão condicional de exceção.
  • ATENÇÃO ao relançar diferentes exceções.
  • Use raramente System.Exception e blocos catch gerais, exceto para registrar a exceção antes de desligar o aplicativo.
  • EVITE o relatório ou o registro em log da exceção na parte inferior da pilha de chamadas.

Acesse itl.tc/ExceptionGuidelinesForCSharp para uma análise dos detalhes de cada um deles. Em uma coluna futura, planejo focar mais nas diretrizes para lançar exceções. Por enquanto, posso dizer que um tema para lançar exceções é: O destinatário de uma exceção é um programador e não o usuário final de um programa.

Observe que a maior parte deste material foi retirada da próxima edição do meu livro “Essential C# 6.0 (5th Edition)” (Addison-Wesley, 2015), disponível agora em itl.tc/EssentialCSharp.


Mark Michaelis é o fundador da IntelliTect, onde atua como arquiteto técnico principal e treinador. Há quase 20 anos, trabalha como Microsoft MPV e é Diretor Regional da Microsoft desde 2007. Michaelis atua em diversas equipes de análise de design de software da Microsoft, incluindo C#, Microsoft Azure, SharePoint e Visual Studio ALM. Ele dá palestras em conferências de desenvolvedores e escreveu diversos livros, incluindo o mais recente, “Essential C# 6.0 (5th Edition)”. Você pode contatá-lo pelo Facebook, em facebook.com/Mark.Michaelis, pelo seu blog IntelliTect.com/Mark, no Twitter: @markmichaelis ou pelo email mark@IntelliTect.com.

Agradecemos aos seguintes especialistas técnicos pela revisão deste artigo: Kevin Bost, Jason Peterson e Mads Torgerson