
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
- Railway Oriented Programming
- The Result Monad
- Functors, Applicatives e Monads explicados com desenhos
Um pensamento em “Null, o erro de um bilhão de dólares – parte 2”