Olá! Este é mais um post da seção Design e é, também, uma versão revisada do post Desempenho - Toda regra tem uma exceção?. No post sobre desempenho exploro a piora do desempenho ao empregar exceções em nosso código de forma indiscriminada, como um fator de validação de regras de negócio.
Neste pretendo abordar o tema exceções de forma mais ampla, explorando diferentes cenários onde exceções fazem ou não sentido.
Vamos lá!
Exceções: usar ou não?
Quando lemos a documentação da Microsoft sobre lançamento e tratamento de exceções (em inglês), encontramos os seguintes trechos (em tradução livre): "Uma exceção é qualquer condição de erro, ou comportamento inesperado, encontrada em um programa em execução. Exceções podem ser lançadas por conta de uma falha em seu código ou um código que você invoca (como em uma biblioteca compartilhada).
(...)
Sua aplicação pode se recuperar de algumas dessas condições mas de outras não. Embora você possa se recuperar da maioria das exceções de aplicação, você não pode da maioria das exceções do ambiente de execução. (...)
Exceções oferecem vantagens sobre outros métodos de notificação de erros, como códigos de retorno. Falhas não passam desapercebidas porque se uma exceção é lançada e você não a trata o ambiente de execução encerra sua aplicação. Valores inválidos não seguem propagando pelo sistema como resultado de um código de erro não identificado."
Fica claro que, a princípio, a Microsoft recomenda o uso de exceções. Certo?
Bom, mais ou menos.
Quando vemos a documentação sobre melhores práticas para exceções (em inglês), encontramos o seguinte trecho, também em tradução livre: "Uma classe pode prover métodos e propriedades que permitam evitar uma chamada que poderia resultar em uma exceção. Por exemplo, a classe FileStream fornece métodos que ajudam a determinar quando o final de um arquivo foi alcançado. Esses métodos podem ser usados para evitar uma exceção que seria lançada caso você tentasse ler além do fim do arquivo.
(...)
Outra forma de evitar exceções é retornando null (ou default) para a maioria dos casos de erro em vez de lançar uma exceção. Uma causa comum de erro pode ser considerada um fluxo normal. Retornando null nestes casos você reduz o impacto sobre o desempenho da aplicação."
Perceba aqui a preocupação com desempenho. É preferível buscar evitar exceções usando null
, tendo como rede de proteção o possível lançamento de uma NullReferenceException
, do que lançar uma exceção de aplicação.
Ou seja, exceções devem ser evitadas e, se usadas, que seja com moderação.
Parêntese: no post sobre desempenho mostro a diferença entre o retorno de um erro tipado e o lançamento de uma exceção. Fiz o mesmo experimento daquele post mas, agora, com o .NET 9 Preview 3. O resultado é o seguinte:
Ok. Mas agora você pode estar se perguntando: não devo usar exceções então?
Não é bem assim. Vejamos a seguir.
Cada caso é um caso (mas nem tanto)
Sendo bem direto, entendo existir um único caso onde usar exceções é um imperativo: ao desenvolver uma biblioteca. Isso por um motivo muito simples: exceções são a forma padrão de notificação de erros no .NET e, se cada biblioteca tiver seu próprio meio de notificação a aplicação pode ficar muito confusa e propensa a erros.
Entendo este caso como uma regra. Ou seja, não faz sentido empregar qualquer outro meio de notificação que não seja exceções. Um bom meio de observar isso é como o .NET funciona na interoperabilidade com componentes COM que, por natureza, retornam códigos de resultado (HRESULT). O ambiente de execução do .NET intercepta este código e, caso represente uma falha, lança uma exceção correspondente ao código de erro ou um COMException caso o código não lhe seja conhecido.
Sobre o null
, não recomendo seu uso de forma alguma, e a Microsoft, mais recentemente, também não.
Sim, entendo que o doc tem um trecho dedicado a essa abordagem (e suspeito que esteja desatualizado) mas devemos nos lembrar sempre do Nullable Reference Types (em inglês) que é um mecanismo para evitarmos situações que lancem a famigerada NullReferenceException
. Ou seja, não é uma boa ideia trabalhar com null
como retorno. Aliás, há um termo, cunhado pelo próprio criador do null
, Tony Hoare, que é o Erro de um Bilhão de Dólares (em inglês). Se o próprio criador do null
o percebe como uma má ideia, faz sentido crer que seja mesmo!
Neste caso, precisamos de outra abordagem, que vamos tratar a seguir.
Impedindo Erros
Antes de mais nada precisamos entender que exceções são o meio mais seguro de notificar erros (não só no .NET mas imagino que em qualquer outro runtime).
Portanto, se você não se sente confortável para abrir mão delas, inclusive prefere sacrificar desepenho por essa segurança, é uma escolha legítima.
A intenção deste post é sugerir uma abordagem que, a partir das melhores práticas sugeridas pela própria Microsoft, permita não apenas ganho de desempenho como, também, um código mais semântico.
Vejamos como faze-lo.
Retornos Tipados
A Microsoft recomenda não utilizar códigos de erro pois uma falha em sua checagem pode resultar em um estado inválido da aplicação, o que é um risco tão real quanto sério. É aqui que a segurança oferecida pelas exceções se mostra útil e, também, onde a abordagem aqui sugerida demonstra seu valor.
A fim de evitar o retorno por código de erros é ideal que se utilize outros tipos de retorno, que demandem necessariamente uma checagem, para que o fluxo da aplicação possa seguir sem criar um estado inválido.
Dois exemplos são Option<T>
e Result<TResult, TError>
apresentados no post sobre Mônadas.
Option<T>
Option<T>
é recomendado para métodos que precisam retornar um valor, como uma consulta ao banco de dados.
Considere o seguinte exemplo:
[HttpGet]
public IActionResult ById(Guid id)
{
Option<User> user = userDataAccess.GetById(id);
return user ? Ok(user) : NotFound();
}
Aqui, caso user
não seja encontrado, retornamos um código 404 no método ById
.
Mas vamos olhar mais de perto esse objeto de acesso a dados que é invocado:
public Option<User> GetById(Guid id)
{
try
{
return context.Users.SingleOrDefault(u => u.Id == id).ToOption();
}
catch(Exception exc)
{
//Logs, métricas e outros tratamentos
return Option.None<User>();
}
}
Repare que, por estarmos na borda da aplicação, podemos tratar uma exceção imediatamente se necessário e simplesmente retornar um Option<T>
com ausência de valor (None
).
Essa simples medida já nos poupa a penalização de desempenho com a escalada da exceção pela pilha de chamadas (call stack). Ao mesmo tempo, na execução da consulta, é possível evitar o valor nulo invocando o método .ToOption()
que o transformaria em um Option<User>.None
.
Talvez você esteja se perguntando se o uso de Option<T>
não seria análogo ao de null
e a resposta é: sim e não. Sim porque é necessário um check para verificar se o valor existe para seguir com o fluxo caso os métodos próprios de Option<T>
não sejam utilizados. E não porque o tipo de retorno deixa explícito que sempre haverá uma instância não nula como retorno, ainda que significando ausência de valor e, caso o desenvolvedor se esqueça de fazer a checagem, uma exceção de Option<T>
a NoneValueException
vai se encarregar de servir de rede de proteção para que o fluxo seja interrompido.
Nota: ao utilizar bibliotecas de terceiros, ou do framework que dependa de recursos do sistema, busque sempre utilizar um bloco
try-catch
(principalmente se não houver documentação). Tal como dito na nota acima, é a forma padrão de notificação do .NET e, portanto, é esperado que algum método venha a lançar alguma.Para tratar eventuais nulos nesse tipo de componente, basta invocar o método desejado e adicionar
.ToOption()
para que o mesmo seja convertido para umOption<T>
.
Result<TReturn, TError>
Aqui temos outro exemplo de como evitar exceções, desta vez em operações que podem retornar erros tipados que emulam as exceções de aplicação.
Considere o seguinte exemplo:
[HttpPost]
public IActionResult AddAddress(AddAddressRequest request)
{
var result = Address.Create(request.Street, request.ZipCode);
if(result)
return Created(string.Empty, newAddress);
return result switch
{
InvalidStreetError => "Invalid Street",
InvalidZipCodeError => "Invalid ZipCode",
_ => "Unspecified Error"
};
}
Aqui temos uma tentativa de criação de endereço, que está sujeita a certas validações. Vamos ver no detalhe como isso acontece.
public class Address
{
...
public static Result<Address, IAddressError> Create(string street, string zipCode)
{
if(string.IsNullOrEmpty(street))
return AddressErrors.InvalidStreetError;
if(string.IsNullOrEmpty(zipCode))
return AddressErrors.InvalidZipCodeError;
...
return address;
}
}
...
public interface IAddressError {}
public struct InvalidStreetError : IAddressError {}
public struct InvalidZipCodeError : IAddressError {}
public static class AddressErrors
{
public InvalidStreetError InvalidStreetError => new InvalidStreetError();
public InvalidZipCodeError InvalidZipCodeError => new InvalidZipCodeError();
}
Aqui vemos o método Create
validando os dados de entrada para a criação de endereços. Caso haja algum erro é retornado um tipo equivalente e, do contrário, é retornado o novo endereço.
Desta forma, com um simples teste de resultado e um pattern matching, que simula um bloco try-catch
, temos o que precisamos para decidir como seguir com o fluxo a partir de um sucesso ou erro.
Considerações Finais
Neste post entendemos como exceções de aplicação (chamadas de business exceptions no post original) não apenas prejudicam o desempenho como são desnecessárias e, por isso, devem ser evitadas a depender dos recursos disponíveis e pelo risco da applicação ser encerrada por força de uma exceção não tratada. Entendemos, também, como um antigo método de controle de fluxo, o retorno de null
, não é desejável.
Para ter acesso aos tipos Option<T>
, Result<TResult, TError>
entre outros, te convido a conhecer o Moonad, uma biblioteca que escrevi a partir das mônadas do F# para facilitar seu uso no C#.
Gostou? Me deixe saber pelos indicadores. Fique à vontade para me procurar nas redes ou deixar comentários por aqui.
Muito obrigado pela leitura e até a próxima!
Top comments (0)