TudoSobre.dev

O universo dev ao seu alcance.

TudoSobre.dev

O universo dev ao seu alcance.

Null, o erro de um bilhão de dólares – parte 2

Na parte 1 falamos sobre os problemas das referências nulas, e também sobre as funções puras e impuras. Começamos a falar sobre monads, e os benefícios que este pattern traz. Vimos também a definição mínima de um tipo monad.

Tipo OptionResult em C#

Para demonstrar os benefícios do uso de monads, vamos criar um tipo monad que trata duas situações em C#: a ocorrência de valores nulos e a ocorrência de erros de execução. Chamaremos de OptionResult, e ele será de grande utilidade para ser usado como tipo de retorno de métodos de negócio, onde várias operações são executadas, e a ocorrência de exceções e valores nulos é muito comum. Primeiro, vamos definir alguns tipos que serão necessários:

/// <summary>
/// Tipo que denota a ausência de valor
/// </summary>
public readonly struct None
{
    public static None Value => new();
}

/// <summary>
/// Tipo usado para métodos que não retornam valor (substituto ao tipo void)
/// </summary>
public readonly struct Unit
{
    public static Unit Value => new();
}

/// <summary>
/// Informações sobre o erro
/// </summary>
public readonly struct Error
{
    /// <summary>
    /// Mensagem de erro
    /// </summary>
    public string Message { get; }

    /// <summary>
    /// Exceção ocorrida (opcional)
    /// </summary>
    public Exception? Exception { get; }

    /// <summary>
    /// Informações adicionais sobre o erro (opcional)
    /// </summary>
    public object? ErrorData { get; }

    public Error(string message, Exception? ex = null, object? errorData = null)
    {
        Message = message;
        Exception = ex;
        ErrorData = errorData;
    }
}

E agora, a implementação do tipo OptionResult:

public readonly struct OptionResult<T>
{
    private readonly T? value = default;
    private readonly Error error;

    public bool HasValue => value is Unit || !EqualityComparer<T>.Default.Equals(value, default);
    public bool HasError => !EqualityComparer<Error>.Default.Equals(error, default);

    private OptionResult(T value)
    {
        this.value = value;
    }

    private OptionResult(None _)
    {
        value = default;
    }

    private OptionResult(Error error)
    {
        this.error = error;
    }

    /// <summary>
    /// Retorna o valor correspondente ao estado do objeto, 
    /// como um pattern matching.
    /// </summary>
    /// <typeparam name="TOut">Tipo de saída</typeparam>
    /// <param name="some">Função a ser executada caso haja valor</param>
    /// <param name="none">Função a ser executada caso NÃO haja valor</param>
    /// <param name="error">Função a ser executada em caso de erro</param>
    /// <returns></returns>
    public TOut Match<TOut>(Func<T, TOut> some, Func<TOut> none, Func<Error, TOut> error)
        => (HasValue, HasError) switch
        {
            (_, true) => error(this.error),
            (true, _) => some(value!),
            _ => none()
        };

    /// <summary>
    /// Mapeia um valor para outro, através da aplicação de uma função.
    /// </summary>
    /// <typeparam name="TOut">Tipo de saída</typeparam>
    /// <param name="func">Função a ser aplicada</param>
    /// <returns><![CDATA[OptionResult<TOut>]]></returns>
    public OptionResult<TOut> Then<TOut>(Func<T, TOut> func)
        => (HasValue, HasError) switch
        {
            (_, true) => error,
            (true, _) => Try(value, func!),
            _ => None.Value
        };

    /// <summary>
    /// Mapeia um valor para outro, através da aplicação de uma função.
    /// </summary>
    /// <typeparam name="TOut">Tipo de saída</typeparam>
    /// <param name="func">Função a ser aplicada</param>
    /// <returns><![CDATA[OptionResult<TOut>]]></returns>
    public OptionResult<TOut> Then<TOut>(Func<T, OptionResult<TOut>> func)
        => (HasValue, HasError) switch
        {
            (_, true) => error,
            (true, _) => Try(value, func!),
            _ => None.Value
        };

    /// <summary>
    /// Envolve uma função em um bloco try-catch.
    /// </summary>
    private OptionResult<TOut> Try<TIn, TOut>(TIn? value, Func<TIn?, TOut> function, Action<Error>? errorHandler = null)
    {
        try
        {
            return function(value);
        }
        catch (Exception ex)
        {
            var error = new Error(ex.Message, ex);
            errorHandler?.Invoke(error);

            return error;
        }
    }

    /// <summary>
    /// Envolve uma função em um bloco try-catch.
    /// </summary>
    private OptionResult<TOut> Try<TIn, TOut>(TIn? value, Func<TIn?, OptionResult<TOut>> function, Action<Error>? errorHandler = null)
    {
        try
        {
            return function(value);
        }
        catch (Exception ex)
        {
            var error = new Error(ex.Message, ex);
            errorHandler?.Invoke(error);

            return error;
        }
    }

    public static implicit operator OptionResult<T>(T value) => new(value);
    public static implicit operator OptionResult<T>(None none) => new(none);
    public static implicit operator OptionResult<T>(Error error) => new(error);
}

Ok, temos nosso tipo implementado. Vamos então vê-lo em ação. Graças às sobrecargas do operador de cast implícito (linhas 105 a 107), é possível atribuir um valor T diretamente a um objeto OptionResult<T>, assim como um objeto de erro (Error):

// Atribuindo uma string 
OptionResult<string> optTexto = "Olá mundo!";
// optTexto.HasValue = true
// optTexto.HasError = false

// Atribuindo uma string nula
string stringNula = null;
OptionResult<string> optNada = stringNula;
// optNada.HasValue = false
// optNada.HasError = false

// Atribuindo um objeto de erro
OptionResult<string> optErro = new Error("Ocorreu um erro");
// optErro.HasValue = false
// optErro.HasError = true

Agora, imagine o método GetById, onde, dado um ID, retorne um objeto Pessoa (se existir), None (se não existir) ou Error (se ocorrer um erro). Isso tudo é possível devido ao tipo de retorno do método GetById ser OptionResult<Pessoa>:

// Tipo Pessoa
public record Pessoa(long Id, string Nome, string Email);

// Método GetById: dado o ID, tenta obter os dados de uma pessoa
public OptionResult<Pessoa> GetById(long id)
{
    try
    {
        // Tenta obter do banco de dados. Se existir no banco,
        // retorna os dados, senão, retorna null.
        //  ...
        return pessoa;
    }
    catch (Exception ex)
    {
        // Em caso de erro, retorna objeto de erro.
        return new Error("Erro ao obter dados da pessoa.", ex);
    }
}

Ao chamar o método GetById, temos que tratar a possibilidade do retorno bem-sucedido do dado (some), o retorno de valor nulo (none) ou o retorno de erro (error).

// Pattern matching do resultado
var result = GetById(1).Match(
                 some: pessoa => pessoa.ToString(),  
                 none: () => "Nada"
                 error: e => e.Message
             );

Por design somos obrigados a tratar todas as situações, evitando erros NullReferenceException inesperados, assim como exceções não tratadas. No paradigma funcional, isso recebe o nome de Railway Oriented Programming, pois seria como um trem em uma linha férrea, se estiver tudo ok ele continua o percurso (caminho feliz), mas se ocorrer algum problema mecânico, basta o trem desviar para outro trilho e resolver o problema, de modo a não atrapalhar o bom andamento da linha férrea.

Além do método Match, temos também o método Then, onde é possível criar um pipeline de execuções. Se ocorrer um erro em qualquer passo, o resultado final será de erro.

// Dado um objeto pessoa do tipo Pessoa:
var result = Validate(pessoa)            // Validate: retorna OptionResult<Pessoa>
                 .Then(p => Insert(p))   // Insert: retorna OptionResult<Pessoa>
                 .Then(p => Publish(p)); // Publish: retorna OptionResult<Pessoa>

OptionResult e ValueTypes

No caso dos Value Types (tipos que não aceitam null, como tipos numéricos, bool, char, DateTime, etc), os valores default é que serão considerados None. Por exemplo:

  • Tipos numéricos (int, long, float, double, decimal, etc):  0
  • char: ‘\0’
  • bool: false
  • DateTime: DateTime.MinValue
  • struct: será None se todas as propriedades value type estiverem com valor default, e todas as propriedades reference type com valor null.

Código-fonte

O código-fonte com a implementação e testes unitários está disponível no GitHub:

🔗 https://github.com/bragil/optionresultlab

Referências

Null, o erro de um bilhão de dólares – parte 2

Um pensamento em “Null, o erro de um bilhão de dólares – parte 2

Deixe um comentário

O seu endereço de e-mail não será publicado. Campos obrigatórios são marcados com *

Rolar para o topo