Pequenas falhas (faults) – como bugs ou validações insuficientes em “entradas” fornecidas por usuários – deixam o sistema em estado inconsistente, o que, ocasionalmente, dá origem a erros (errors), ou seja, comportamentos indesejados do software que, eventualmente, culminam em defeitos (failures), geralmente indisponibilidade
Serviços remotos, eventualmente, param de responder. Bancos de dados e sistemas de arquivos, eventualmente, têm lentidão acima do comum. Interfaces com usuário, eventualmente, “deixam passar” dados sem verificações básicas. Todos esses cenários indicam falhas.
Bons programadores entendem que que falhas acontecem e adotam “codificação defensiva”, ou seja, prevendo e indicando respostas apropriadas a esses eventos de forma a preservar o funcionamento e a utilidade do sistema. Ou seja, previnem defeitos, mitigando a possibilidade de ocorrência de erros, através do tratamento adequado de falhas.
Uma falha é como uma pequena rachadura na tela de um celular que, inicialmente, incomoda apenas por seu aspecto estético. Porém, o problema maior é que essa “rachadura” cresce com o tempo, fazendo com que “gestos” deixem de funcionar (erro), até, finalmente, tornar o dispositivo inútil (defeito).
Diferenciando falhas excepcionais e normais
O tratamento adequado de uma falha depende se sua condição é de normalidade ou de exceção. Há dois aspectos que determinam a “condição” de uma falha: frequência e externalidade.
Frequência
Um tipo de falha será considerado normal quando a sua possibilidade e, consequentemente, frequência de ocorrência for alta ou muito alta. Por outro lado, será considerado excepcional quando a possibilidade e, consequentemente, frequência de ocorrência for baixa ou muito baixa.
A frequência de um tipo de falha pode ser analisado conforme duas perspectivas:
- Tempo transcorrido entre as falhas – quanto maior o intervalo, maior a excepcionalidade;
- Proporção de processamentos falhos em relação ao total de processamentos – quanto menor, maior a excepcionalidade.
Externalidade
Devemos assumir que agentes externos, como usuários e outros sistemas, podem falhar. Assim, falhas provenientes desses agentes, devem ser consideradas normais e códigos responsáveis por essa “comunicação” devem se comportar de acordo. Por outro lado, agentes internos, como objetos da aplicação, devem ser “corretos” e, consequentemente, falhas provenientes deles devem ser consideradas excepcionais.
Essa distinção na forma de tratar falhas, inclusive, tem justificado a elaboração de padrões de design como a arquitetura hexagonal – ou ports and adapters – de Alistair Cockburn.
Nesse padrão, o código é segmentado em duas categorias:
- adapters, onde objetos interagem com agentes externos e onde falhas tendem a ser “normais”
- application, onde objetos interagem apenas com agentes internos (adapters e outros objetos da própria application) onde as falhas devem ser “excepcionais”.
Exemplos de falhas excepcionais e normais
Para tornar mais evidente a distinção entre falhas normais e excepcionais, consideremos alguns exemplos práticos:
- Usuários fornecendo dados inválidos para um sistema é uma falha que deve ser aceita como frequente e, obviamente, com alta externalidade. Logo, deve ser tratada como falha normal no código da parte do sistema que interage com o usuário, daí a introdução de verificações e validações.0Considerações?x
- Parâmetros de processamento inválidos para comportamentos de objetos com nenhuma externalidade devem ser considerados excepcionais. Caso estes parâmetros tenham sido fornecidos por usuários e “passaram direto” pelo código que controla essa interação, identifica-se uma falha de implementação de tal código.0Considerações?x
- O banco de dados não responder será, geralmente, uma condição excepcional, dado a baixa frequência.0Considerações?x
- Um sistema remoto não estar disponível, também, geralmente será uma condição excepcional, exceto em cenários onde a indisponibilidade percebida é frequente.0Considerações?x
Lidando com “falhas excepcionais”
Falhas excepcionais são graves pois oferecem maior possibilidade de se converterem em erros e, até mesmo, defeitos. Por isso, devem ser tratadas com cuidado extra.
Expressando “excepcionalidades”
A grande maioria das linguagens de programação orientadas a objetos permitem que expressemos excepcionalidade através de objetos especiais: as exceções.
Uma exceção é um tipo de objeto, com algumas informações contextuais, “lançado” na stack de execução e que interrompe o fluxo natural da aplicação. No .NET, uma exceção é um objeto herdado da classe System.Exception. Uma exceção é lançada de uma área do código em que ocorreu um problema.
No exemplo que segue, a classe Event, que foi projetada para ter baixa externalidade, valida seus parâmetros “lançando” exceções caso os parâmetros fornecidos para seu construtor sejam inválidos (nulos, no exemplo).
public class Event { public string Id {get; private set;} public string Message {get; private set; } public Event(string id, string message) { Id = id ?? throw new ArgumentNullException(nameof(id)); Message = message ?? throw new ArgumentNullException(nameof(message)); } }
A exceção, quando lançada, é passada pilha acima até que o aplicativo trate dela ou o programa seja encerrado.
public class Program { public static void Main() { A(); // no catch. Program termination! } public static void A() { B(); // no catch } public static void B() { C(); // no catch } public static void C() { throw new System.Exception(); } }
“Capturando” exceções
Eventualmente, nossos códigos precisam lidar com comportamentos que lançam exceções. Nesse casos, precisamos decidir se desejamos as “capturar” ou, simplesmente, permitir que elas “subam” na stack.
No exemplo que segue, o método B captura a Exception lançada no método C, impedindo o encerramento precoce do sistema.
public class Program { public static void Main() { A(); } public static void A() { try { B(); } catch { System.Console.WriteLine("Exception detected!"); } } public static void B() { C(); // no catch } public static void C() { throw new System.Exception(); } }
A captura de exceções pode ser qualificada de forma a oferecer tratamentos específicos para tipos diferentes de exceção.
using System; using System.IO; using static System.Console; public class ProcessFile { public static void Main() { try { using (StreamReader sr = File.OpenText(@"c:\dir\data.txt")) WriteLine($"The first line of this file is {sr.ReadLine()}"); } catch (FileNotFoundException e) { WriteLine($"The file was not found: '{e}'"); } catch (DirectoryNotFoundException e) { WriteLine($"The directory was not found: '{e}'"); } catch (IOException e) { Console.WriteLine($"The file could not be opened: '{e}'"); } } }
Além de “filtrar” exceções pelo tipo da exceção, há a possibilidade de adicionar condicionais relacionados a valor de propriedades do objeto descritivo.
try { //... } catch (Exception ex) when (ex.Message.Contains("404")) { //.. throw // relançando a exception }
Garantindo a execução de código em situações excepcionais
Quando ocorre uma exceção, a execução é interrompida e o fluxo de execução é desviado para um manipulador apropriado, ou, em casos extremos, a execução do programa é encerrada. Há cenários em que, quando isso ocorre, deseja-se garantir que, antes de desviar a execução para um handler, outro código seja executado. Em .NET, essa característica é possível pela adoção de blocos finally.
using System; using static System.Console; string input = ReadLine(); int n; try { n = int.Parse(input); WriteLine($"{n} is a valid integer"); } catch { WriteLine($"'{input}' is an invalid integer."); } finally { WriteLine("This statement is always executed."); }
Lidando com “falhas normais”
Falhas normais devem ser esperadas e tratadas com simplicidade. Seja pela frequência da ocorrência ou pela aproximação de agentes externos (como usuários), tais falhas devem ser contornadas com o “menor alarde” possível.
Ignorar a possibilidade de falhas nunca é uma boa ideia
Não é incomum encontrar códigos com propriedades que não executam quaisquer verificações em seus valores. Tal abordagem é válida apenas para DTOs.
public class Employee { public string Id { get; set; } public string Name { get; set; } public string Phone { get; set; } public string Salary { get; set; } }
Tais objetos assumem, de maneira excessivamente otimista, anêmica, que dados fornecidos para tais propriedades estarão dentro de intervalos aceitáveis. Entretanto, quando este tipo de falha ocorrer, provavelmente, irá implicar em erros.
Também não é incomum encontrar métodos sem qualquer validação para parâmetros.
public class GreetingsService { public string ComposeGreetingsMessage(Customer c) { string message = $"Dear {c.Name}, \n .. "; //.. } }
A ausência de tais verificações pode acarretar no lançamento de exceções, que são adequadas para falhas excepcionais, em códigos com falhas normais.
Tentando se “recuperar” de uma falha
Algumas falhas podem ser contornadas com alguns artifícios simples. Considere o código que segue:
public class Operations { public int Divide(int a, int b) => b == 0 ? a : b / a; }
No exemplo, se a condicional não tivesse sido incluída, e o valor fornecido para b fosse 0, haveria o lançamento de uma exeção (DivideByZeroException). O artifício utilizado, evita a necessidade de uma exceção. Entretanto, obviamente, tal “saída criativa” pode representar um problema, visto que o comportamento seria, como indicado no código, matematicamente impreciso.
A ideia de “corrigir” fontes de falhas é impedir o lançamento de exceções em cenários normais (uso frequente ou alta externalidade).
Outro exemplo de “recuperação” pode ser percebido no código que segue:
class Person { // .. string _name; public string Name { get => _name; set { if (!string.IsNullOrEmpty(value)) { _name = value; } } } //.. }
Aqui, o código impede que um valor inválido seja atribuído para a propriedade. Entretanto, a falta de algum aviso deixa espaço para que “clientes” assumam que a atribuição foi bem sucedida, podendo gerar estados inconsistentes.
Gerando notificações
Outra abordagem tremendamente comum para tratar com falhas “normais”, decorrentes, principalmente, da falta de validação de dados fornecidos por usuários é implantar algum mecanismo de geração de notificações.
A ideia consiste em criar um agente de verificação para um determinado conjunto de valores, geralmente expresso em DTO, antes de tentar disparar ações em objetos de menor externalidade, onde tais valores impróprios causariam uma falha excepcional.
Conceitos relacionados
CQS – Command-Query Separation
Bertrand Meyer indica que todo método deveria funcionar como um comando – que executa alguma ação, potencialmente causando mudanças de estado na aplicação – ou uma consulta – que retorna algum tipo de dado – porém, nunca as duas coisas.
Em outras palavras, um método deveria retornar valores apenas quando for “referencialmente transparente”, sem causar efeitos colaterais.
Um método, por exemplo, responsável por efetivar a gravação de dados em um banco de dados não deveria retornar valor (sendo void).
public interface ICustomerRepository { void Save(Customer customer); }
Segundo essa regra, caso algo de errado aconteça durante a execução de um comando, uma exceção deveria ser lançada.
Em .NET, uma importante violação de CQS pode ser identificada no método MoveNext na interface IEnumerator. Ele “move” o cursor da enumeração para um novo elemento retornando um boolean para indicar se a operação foi, ou não, possível.
TryXXX
Eventualmente, consultas (conforme a ideia indicada em CQS) não são capazes de gerar resultado adequado, frente a parâmetros fornecidos. Nesses casos, precisam gerar uma exceção (que é ideal apenas para cenários excepcionais, mas pesadas para cenários de normalidade), ou retornar nulidade.
No código abaixo, considerando que structs em C# não podem ser nulas, o método Parse, deverá lançar uma exceção caso a string fornecida não contiver um Cpf válido. Daí, a alternativa de fornecer um método de verificação para os clientes.
public struct Cpf { // ... public static bool CanParse(string cpf) { .. } public static Cpf Parse(string cpf) { .. } public static implicit operator Cpf(string cpf) => Parse(cpf); }
O “problema” dessa abordagem é potencial processamento em duplicidade. Uma alternativa melhor é implementar o padrão TryXXX, como indicado no código abaixo.
public struct Cpf { // ... public static bool CanParse(string cpf) { .. } public static Cpf Parse(string cpf) { Cpf result; if (!TryParse(cpf, out result)) { throw new ArgumentException($"The value '{cpf}' is not a valid Cpf", nameof(cpf)); } return result } public static bool TryParse(string cpf, out Cpf result) { .. } public static implicit operator Cpf(string cpf) => Parse(cpf); }
Antes de avançar…
Neste capítulo apresentei estratégias para lidar com falhas, em programas orientados a objetos, de forma a impedir que estas se convertam em erros e defeitos.
Há, em algumas áreas da comunidade desenvolvedora, uma certa “antipatia” pela utilização de recursos como exceptions. Boa parte dela, evidentemente provocada pela incapacidade de diferenciar falhas normais e excepcionais.
Minha recomendação para você, caso já tenha experiência programando usando OO, é revisitar suas decisões recentes conforme o padrão que indiquei aqui.
Fantástico, são as boas práticas, eu não programo em C#, mas em Delphi, o ponto é, os princípios são os mesmos para todo o programador.
Vou divulgar o conteúdo na empresa na qual trabalho
Conteúdo preciso e de altíssima qualidade como sempre. Obrigado pela contribuição.
Ótimo conteúdo, muito didático Elemar. Sobre as exceções excepcionais muitas vezes as mesmas podem ser tratadas com mensagens ‘amigáveis’ para o usuário, contanto, o desenvolvedor pode não estar ciente de suas ocorrências. A melhor forma de monitorar essas falhas seria a gravação de algum log para ser verificado posteriormente? Ou haveria outra proposta para o monitoramento? Desde já agradeço.