
Compartilhar:
Guillaume é um ex-membro da equipe da Vonage. Ele atuou como Promotor Sênior de Desenvolvimento .NET na Vonage. Trabalha com .NET há quase 15 anos, tendo se dedicado, nos últimos anos, à promoção do Software Craftsmanship. Seus temas favoritos incluem qualidade de código, automação de testes, mobbing e code katas. Fora do trabalho, ele gosta de passar tempo com a esposa e a filha, malhar ou jogar videogame.
A Invasão das Monades – Parte 1: O que é uma monade?
Tempo de leitura: 9 minutos
E aí,
Durante uma apresentação, há duas coisas que podem fazer com que os desenvolvedores fujam. A primeira é um palestrante fazendo uma apresentação terrivelmente enfadonha; a segunda é a simples menção da palavra “Monad”.
As mônadas têm a fama de assustar as pessoas. Ao nos aprofundarmos na Teoria das Categorias nos leva a uma teia de terminologias complexas, como “mônadas”, o que torna mais difícil oferecer uma explicação simples e direta.
Esta é a primeira postagem de uma série que tem como objetivo desmistificar as monads e ajudá-lo a tirar proveito delas, usando exemplos da vida real do nosso SDK do .NET. Assista à minha palestra sobre o assunto, “Jogue as exceções... para fora da sua base de código”, que tratou mais das mônadas do que das exceções, mesmo que o título sugira o contrário.
Então... O que é uma mônada?
Lamento desapontá-lo, mas não vou me aprofundar na parte teórica. Eu poderia dizer que uma mônada é um monóide da categoria dos endofunções, mas para onde você iria a partir daí?
Para simplificar, pense em um functor como uma caixa sobre a qual você pode mapear uma função, o que significa que você pode aplicar uma função ao que está dentro da caixa sem alterá-lo. Agora, uma Monad é apenas um passo além de um functor. É como uma caixa superpoderosa que não só permite que você aplique funções, mas também oferece recursos extras para controlar a sequência de operações.
Entendeu? Ainda não? Tudo bem! Vamos passar para um cenário mais prático, com exemplos do mundo real, à medida que aprofundarmos o assunto.
O Gato de Schrödinger
An image illustrating Schrodinger's cat
Sim, esse exemplo vem da Mecânica Quântica, mas não há motivo para se assustar, pois é relativamente simples.
Imagine uma situação em que há uma caixa e um gato. No entanto, essa não é uma caixa comum, pois também contém um dispositivo capaz de liberar veneno mortal a qualquer momento, e você não saberá quando isso acontecerá.
Erwin Schrödingerdefende que, no momento em que você coloca o gato dentro da caixa e a fecha, não é possível determinar se o gato está vivo ou morto até que você abra a caixa novamente. O gato não está simultaneamente morto e vivo; ele está ou morto ou viva, mas é preciso considerar ambas as possibilidades até que se possa observar o estado real.
Essa é uma maneira de ilustrar o conceito de superposição quântica , em que é preciso aceitar a ideia de que uma situação existe em vários estados até que se possa fazer uma observação.
Você deve estar se perguntando aonde quero chegar com tudo isso; bem, vamos dar uma olhada em um trecho de código.
Uma caixa com um gato
Aviso: nenhum gato sofreu danos durante a criação desses trechos de código.
An imagine illustrating cats with their thumbs Up
Aqui está uma implementação relativamente simples da nossa caixa:
internal class SchrodingerBox
{
private readonly Cat? cat;
// Creates a box with an alive cat inside
private SchrodingerBox(Cat cat) => this.cat = cat;
// Creates a box with a dead cat
private SchrodingerBox()
{
}
// Look inside the box
public Cat? OpenBox() => this.cat;
// Shakes the box. The cat doesn't like it. Meow.
public SchrodingerBox Shake()
{
this.cat?.Meow();
return this;
}
// Shakes the box (too hard), then returns a new box with a dead cat
public SchrodingerBox ShakeTooHard()
{
this.Shake();
return new SchrodingerBox();
}
// Creates a box with a cat
public static SchrodingerBox WithAliveCat(Cat cat) => new(cat);
}
internal readonly struct Cat
{
private readonly Action<string> log;
public Cat(Action<string> log) => this.log = log;
public void Meow() => this.log("Meow");
}
Gostaria de destacar alguns pontos importantes aqui:
Ao interagir com a API pública, a única maneira de criar uma caixa com um gato vivo é usando o método de fábrica estático
.WithAliveCat(Cat).Caté uma estrutura e, portanto, não pode ser nulo. Nesse contexto, quando o campo `cat` é declarado como nuloável, entendemos que uma instância deCatdenota um gato vivo, enquanto `null` indica que o gato faleceu.Não há uma maneira direta de verificar o gato dentro da caixa, já que nenhuma propriedade revela o estado do gato. Para determinar se o gato está vivo ou morto, é preciso abrir a caixa usando o
.OpenBox()método.
A caixa oferece três comportamentos distintos:
Sacudindo a caixa: se o gato estiver vivo, ele pode não gostar da sacudida e dos “miau”. O uso do operador de propagação nula garante que só chamemos
.Meow()se o gato estiver vivo. Nada acontece se o gato já não estiver entre os vivos.Agitar a caixa com muita força: Assim como no comportamento anterior, o bicho vai “miauar” se estiver vivo. No entanto, nesse caso, é liberado veneno, e o gato tem um fim trágico.
Abrindo a caixa: Ao abrir a caixa, recebemos o gato, seja vivo (uma instância) ou morto (null).
Para ver como a caixa funciona, vamos dar uma olhada em um trecho de código:
private readonly ITestOutputHelper helper;
private Cat GetAliveCat() => new(value => this.helper.WriteLine(value));
// "Meow" is logged three times in the console.
SchrodingerBox.WithAliveCat(this.GetAliveCat())
.Shake()
.Shake()
.Shake();
// "Meow" is logged twice in the console, given we killed the cat on the second call.
SchrodingerBox.WithAliveCat(this.GetAliveCat())
.Shake()
.ShakeTooHard()
.Shake();
// OpenBox() returns an instance when the cat is alive.
SchrodingerBox.WithAliveCat(this.GetAliveCat())
.Shake()
.OpenBox()
.Should()
.Be(this.GetAliveCat());
// OpenBox() returns null when the cat is dead.
SchrodingerBox.WithAliveCat(this.GetAliveCat())
.ShakeTooHard()
.OpenBox()
.Should()
.BeNull();
Você vai perceber que podemos mexer na caixa sem precisar verificar o estado do gato; mesmo sacudindo a caixa quando o gato já está morto, isso não traz nenhuma consequência.
Bem, tudo isso é muito bom, mas, da forma como está, esse exemplo não tem muita utilidade. Integrar esse bloco ao seu código não vai resolver nenhum problema concreto.
No entanto, isso não significa que devamos descartá-la completamente. Atualmente, nossa caixa contém um gato, mas Cat é apenas uma estrutura específica. Com o poder dos genéricos, poderíamos substituí-la por qualquer tipo!
Uma caixa de T
An image illustrating a box of tea
Quando se trata de substituir “Cat” por um tipo genérico, há um aspecto importante a se considerar: sacudir a caixa não faz mais sentido. Qual seria o objetivo de tal ação?
Daqui para frente, permitiremos qualquer valor em nossa caixa — mas ele precisa ser universalmente útil. Precisamos oferecer funcionalidades como alterar um valor ou transformá-lo em outro tipo.
É aqui que .Map<TResult>(Func<T, TResult>) entra em cena!
internal class SchrodingerBox<T> where T : struct
{
private bool IsSome { get; }
private readonly T value;
// Constructor for Some
private SchrodingerBox(T value)
{
this.value = value;
this.IsSome = true;
}
// Constructor for None
private SchrodingerBox() => this.IsSome = false;
// Applies the function on the value IF our box contains a value
public SchrodingerBox<TResult> Map<TResult>(Func<T, TResult> map) =>
this.IsSome
? Some(map(this.value))
: SchrodingerBox<TResult>.None();
// Creates a box without a value
public static SchrodingerBox<T> None() => new();
public T? OpenBox() => this.IsSome ? this.value : null;
// Creates a box with a value
public static SchrodingerBox<TResult> Some<TResult>(TResult value) where TResult : struct => new(value);
}
A Map operação representa um avanço significativo, permitindo-nos “alterar” o valor subjacente. Bem, “alterar” talvez não seja o termo correto, já que não estamos modificando diretamente o valor. Em vez disso, estamos usando-o para gerar um novo valor que é colocado em outro contêiner.
Você pode considerar essa operação semelhante à .Select(Func<TSource, TResult>) no LinQ.
// We start with 3 and perform three increments. The result is then 6.
SchrodingerBox<int>.Some(3)
.Map(value => value + 1)
.Map(value => value + 1)
.Map(value => value + 1)
.OpenBox()
.Should()
.Be(6);
// The box is empty, and despite three increments, the result remains null.
SchrodingerBox<int>.None()
.Map(value => value + 1)
.Map(value => value + 1)
.Map(value => value + 1)
.OpenBox()
.Should()
.BeNull();
Esse conceito está alinhado com o .Shake() :
Quando não há nenhum valor na caixa, nenhuma ação é realizada.
An image illustrating the None status
Mas, quando a caixa contém um valor, ela aplica a função a esse valor e apresenta o resultado em uma nova caixa.
An image illustrating the Some status
Fonte: Blog de Aditya Bhargava blog — provavelmente os melhores desenhos que já vi para ilustrar esse mecanismo
Embora tenhamos abordado apenas uma operação de transformação simples, conseguimos transformar nossa caixa em um funtor! Lembra-se da nossa explicação anterior?
"Pense em um functor como uma caixa sobre a qual você pode mapear uma função, o que significa que você pode aplicar uma função ao que está dentro da caixa sem alterar a própria caixa."
Então, o que falta para que nossa caixa se transforme em uma Mônada?
Bind monádico
Você deve ter percebido que, ao usar .Map<TResult>(Func<T, TResult>), o estado interno da nossa caixa permanece inalterado. Seja em um Some ou None estado, ao mapeá-la, esse estado será preservado, e é impossível alterá-lo.
No entanto, é aqui que o Bind mecanismo ganha destaque! Ao contrário de Map, que recebe uma função que retorna um valor, o Bind método espera uma função que retorna uma nova caixa, esperando o seguinte parâmetro Func<T, SchrodingerBox<TResult>>.
Vamos ver como ele se sai em comparação com Map:
public SchrodingerBox<TResult> Map<TResult>(Func<T, TResult> map) where TResult : struct =>
this.IsSome
// The outcome of the "map" operation is a result,
// which means we need to wrap this value within a box
? Some(map(this.value))
: SchrodingerBox<TResult>.None();
public SchrodingerBox<TResult> Bind<TResult>(Func<T, SchrodingerBox<TResult>> bind) where TResult : struct =>
this.IsSome
// The outcome of the "bind" operation is already a box,
// so, we can return it
? bind(this.value)
: SchrodingerBox<TResult>.None();
Com Bind, podemos implementar comportamentos que modificam o estado da caixa com base em uma lógica de negócios específica. Você pode pensar: “A caixa inicial que continha o gato já fazia isso, passando do estado ‘vivo’ para ‘morto’” — e você está certo. No entanto, há uma diferença significativa: anteriormente, a caixa era responsável por essa mudança, já que abrigava a lógica (.ShakeTooHard()). Agora, a responsabilidade recai sobre a função. Delegamos isso com sucesso ao chamador, ampliando as possibilidades da nossa caixa.
No próximo exemplo, apresentamos um novo método, .Increment(int), que incrementa o valor enquanto ele permanecer abaixo de três. No entanto, se o valor for igual ou superior a três, ele retorna uma caixa vazia.
private static SchrodingerBox<int> Increment(int value) =>
value < 3 ? SchrodingerBox<int>.Some(value + 1) : SchrodingerBox<int>.None();
// We successfully incremented our value to three
SchrodingerBox<int>.Some(0)
.Bind(Increment)
.Bind(Increment)
.Bind(Increment)
.OpenBox()
.Should()
.Be(3);
// On the last call, our "Increment" method returns an empty box
// This signifies our box transitioned from a "Some" state to a "None" state.
// The box didn't not produce the change; the ".Increment(int)" function did.
SchrodingerBox<int>.Some(0)
.Bind(Increment)
.Bind(Increment)
.Bind(Increment)
.Bind(Increment)
.OpenBox()
.Should()
.Be(null);
Nossa caixa finalmente é uma Monad! Agora ela inclui tanto Map e Bind , permitindo-nos manipular um valor encapsulado sem conhecer seu estado inicial.
No entanto, ainda há mais um aspecto que precisamos abordar.
Abrindo a caixa
An image illustrating a gift box
Nossa Monad, frequentemente conhecida como Optional (Option ou Maybe, dependendo da sua preferência de linguagem), pode estar em um de dois estados: pode representar a presença de um valor (Some) ou a ausência de um valor (None).
Atualmente, quando abrimos a caixa, ela pode retornar `null` se não houver nenhum valor dentro. No entanto, isso não se alinha à intenção da nossa Monad, pois a ausência de valor é diferente de um valor `null`.
Isso levanta uma questão intrigante: como representar a ausência de valor? Bem, não se representa — em vez disso, define-se um comportamento alternativo.
E é aí que .Match<TResponse>(Func<T, TResponse> some, Func<TResponse> none) entra em cena!
// Applies a function depending on the state of the box to return a TResponse
public TResponse Match<TResponse>(Func<T, TResponse> some, Func<TResponse> none) =>
this.IsSome ? some(this.value) : none();
.Match(some, none) avaliará o estado da Monad e chamará a função correspondente.
Se o estado for “Some”, ele chamará
somecom o valor interno para gerar um resultado.Se o estado for None, ele invocará
none, recorrendo a um mecanismo de fallback para gerar um resultado.
Agora, vamos aplicar Match no lugar de Openboxusando o exemplo anterior:
// When the stae is Some(3)
// Match will employ the some function
// and return "The value is some 3!"
SchrodingerBox<int>.Some(0)
.Bind(Increment)
.Bind(Increment)
.Bind(Increment)
.Match(some => $"The value is some {some}!", () => "The value is none")
.Should()
.Be("The value is some 3!");
// When the state is None (because Bind returned an empty box in the last call),
// Match will employ the none function
// and return "The value is none"
SchrodingerBox<int>.Some(0)
.Bind(Increment)
.Bind(Increment)
.Bind(Increment)
.Bind(Increment)
.Match(some => $"The value is some {some}!", () => "The value is none")
.Should()
.Be("The value is none");
Inicialmente, nossa caixa continha um int. Ao extrair o valor da caixa, nos concentramos no “quadro geral” e geramos uma mensagem para o usuário final. Outra abordagem poderia ter envolvido fornecer um int quando o estado fosse None, supondo que isso fosse relevante.
A cereja do bolo: conseguimos nos livrar da nulabilidade — isso é sempre uma vitória!
Vale ressaltar que todo o fluxo de trabalho permanece consistente, independentemente de haver ou não um valor. Isso não é coincidência, e explorarei as razões por trás disso mais adiante nesta série.
No entanto, essa mudança exige uma reflexão mais ampla sobre a finalidade do valor — quais são as implicações da ausência de um valor quando a possibilidade de ser nulo não é mais uma opção?
O que isso significa no contexto de uma operação de ponta a ponta?
Quando devemos abrir a caixa?
An image illustrating a family opening christmas gifts
Esse costuma ser o momento em que as pessoas enfrentam dificuldades — pelo menos, foi assim para mim —, porque tendemos a cometer o erro típico de iniciantes de extrair o valor antes da hora.
Manter o valor dentro da caixa faz sentido, desde que ambos os estados resultem em efeitos colaterais distintos.
Por exemplo, sua mônada terá uma duração relativamente curta se você puder gerar um valor bem no início, como demonstrado no .Match(some, none) exemplo. Por outro lado, se todo o seu fluxo depender da presença de um valor, como na atualização de um usuário, você terá que mantê-lo até o final.
Recomendo manter sua Monad ativa pelo maior tempo possível, extraindo o valor no último momento.
Tudo gira em torno dos estados
An image illustrating traffic lights
As mônadas giram em torno do conceito de gerenciamento e manipulação de estados.
Obviamente, nossa Monad é apenas um exemplo entre muitos outros, cada um deles projetado para lidar com diversos estados. Aqui estão alguns outros, juntamente com os diferentes estados com os quais lidam:
Resultado (Sucesso|Falha)
Qualquer um dos dois (Esquerda|Direita)
Validação (Sucesso|Erro de validação)
etc.
Para esta apresentação, criamos nossa própria caixa, mas é importante ressaltar que você não precisa fazer o mesmo!
Existem inúmeras bibliotecas disponíveis que oferecem monads prontas para uso. Aqui estão algumas recomendações:
Language-Ext é o meu favorito, mas pode ser um pouco complicado para iniciantes devido ao seu amplo conjunto de recursos
Conclusão
Espero que você tenha gostado de ler este artigo, e tentei fazer com que as mônadas parecessem menos assustadoras para você. Elas podem ser complicadas quando você começa a se aprofundar nelas, mas acredite em mim, vale a pena. Elas mudaram a maneira como eu encaro o software, e espero que façam o mesmo por você.
Lembre-se do que Douglas Crockford disse certa vez: “[...] As mônadas também são amaldiçoadas, e a maldição da mônada é que [...] uma vez que você entende, perde a capacidade de explicar isso para qualquer pessoa”
Como mencionei anteriormente, este post no blog foi apenas um aquecimento. O assunto é tão vasto que não caberá todo em um único post, e estamos prestes a explorar algumas novidades legais no próximo. Então, fiquem ligados.
Se você tiver alguma dúvida ou só quiser bater um papo, fique à vontade para me mandar uma mensagem no meu LinkedIn ou junte-se a nós no Slack dos desenvolvedores da Vonage.
Boa programação e até mais!
Compartilhar:
Guillaume é um ex-membro da equipe da Vonage. Ele atuou como Promotor Sênior de Desenvolvimento .NET na Vonage. Trabalha com .NET há quase 15 anos, tendo se dedicado, nos últimos anos, à promoção do Software Craftsmanship. Seus temas favoritos incluem qualidade de código, automação de testes, mobbing e code katas. Fora do trabalho, ele gosta de passar tempo com a esposa e a filha, malhar ou jogar videogame.