
Compartir:
Guillaume es desarrollador senior de .Net en Vonage. Ha estado trabajando en .Net durante casi 15 años, mientras que en los últimos años se ha centrado en la defensa de Software Craftsmanship. Sus temas favoritos son la calidad del código, la automatización de pruebas, el mobbing y las katas de código. Fuera del trabajo, le gusta pasar tiempo con su mujer y su hija, hacer ejercicio o jugar.
La invasión de las mónadas - Parte 1: ¿Qué es una mónada?
Tiempo de lectura: 9 minutos
Hola,
Durante una presentación, hay dos cosas que pueden hacer huir a los desarrolladores. La primera es que el ponente haga una presentación terriblemente aburrida; la segunda es la mera mención de la palabra "mónada".
Las mónadas tienen fama de asustar a la gente. Profundizar en la Teoría de Categorías introduce un entramado de terminologías complejas, como "mónadas", que hace más difícil ofrecer una explicación sencilla y directa.
Este es el primer post de una serie que pretende desmitificar las mónadas y ayudarle a sacarles partido, utilizando ejemplos reales de nuestro .NET SDK. Vea mi charla relacionada "Lanza excepciones... fuera de tu código base"que trataba más sobre Mónadas que sobre excepciones, aunque el título diga lo contrario.
¿Qué es una mónada?
Siento decepcionarle, pero no voy a profundizar en el aspecto teórico. Podría decir que una Mónada es un Monoide de la categoría de endofunctorespero, ¿dónde ir desde allí?
Para simplificarlo, piensa en un functor como una caja sobre la que puedes mapear una función, lo que significa que puedes aplicar una función a lo que hay dentro de la caja sin mutarlo. Ahora, una mónada es sólo un paso adelante de un functor. Es como una caja superpoderosa que no sólo te permite mapear funciones sino que también ofrece capacidades extra para controlar la secuencia de operaciones.
¿Lo has entendido? ¿Todavía no? No pasa nada. Pasaremos a un escenario más pragmático para ver ejemplos del mundo real a medida que profundicemos.
El gato de Schrodinger
An image illustrating Schrodinger's cat
Sí, este ejemplo procede de la Mecánica Cuántica, pero no hay por qué asustarse, ya que es relativamente sencillo.
Imagine un escenario con una caja y un gato. Sin embargo, esta no es la típica caja porque también contiene un dispositivo que puede liberar veneno mortal en cualquier momento, y no sabrás cuándo ocurre.
La perspectiva de Erwin SchrödingerLa perspectiva de Erwin Schrödinger es que en el momento en que colocas el gato dentro de la caja y la cierras, no puedes determinar si el gato está vivo o muerto hasta que vuelvas a abrir la caja. El gato no está simultáneamente muerto y vivo; está bien muerto o vivo, pero tienes que considerar ambas posibilidades hasta que puedas observar el estado real.
Esta es una forma de ilustrar el concepto de superposición cuántica en el que hay que pensar que una situación existe en varios estados hasta que se puede hacer una observación.
Te preguntarás a dónde quiero llegar con todo esto; pues bien, veamos algo de código.
Una caja con un gato
Descargo de responsabilidad: no se ha hecho daño a ningún gato durante la creación de estos fragmentos de código.
An imagine illustrating cats with their thumbs Up
He aquí una aplicación relativamente sencilla de nuestra caja:
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");
}
Quiero hacer hincapié en algunos puntos clave:
Al interactuar con la API pública, la única forma de crear una caja con un gato vivo es utilizando el método de fábrica método de fábrica estático
.WithAliveCat(Cat).Cates un struct y, por tanto, no puede ser nulo. En este contexto, cuando el campo gato se declara anulable, entendemos que una instancia deCatdenota un gato vivo, mientras que null indica que el gato ha fallecido.No hay forma directa de inspeccionar al gato dentro de la caja, ya que ninguna propiedad expone el estado del gato. Para determinar si el gato está vivo o muerto, debe abrir la caja utilizando el método
.OpenBox()método.
La caja ofrece tres comportamientos distintos:
Sacudir la caja: Si el gato está vivo, puede que no aprecie las sacudidas y los "maullidos". El uso del operador de propagación nula garantiza que sólo llamemos a
.Meow()si el gato está vivo. No ocurre nada si el gato ya no está entre los vivos.Sacudir la caja con demasiada fuerza: Al igual que en el comportamiento anterior, el llamado "maullará" si está vivo. Sin embargo, en este caso se libera veneno y el gato tiene un final desafortunado.
Abrir la caja: Al abrir la caja, recibimos el gato, vivo (una instancia) o muerto (nulo).
Para ver la caja en acción, veamos algo 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();
Observará que podemos trabajar con la caja sin necesidad de comprobar el estado del gato; incluso sacudir la caja cuando el gato está muerto no acarrea ninguna consecuencia.
Ahora, todo eso está muy bien, pero en su forma actual, este ejemplo no ofrece mucha utilidad. Integrar esta caja en tu código base no resolverá ningún problema real.
Sin embargo, eso no significa que debamos descartarla por completo. Actualmente, nuestra caja contiene un gato, pero Cat es sólo una estructura específica. Con el poder de los genéricos, ¡podríamos cambiarla por cualquier tipo!
Una caja de T
An image illustrating a box of tea
Cuando se trata de sustituir a Cat por un tipo genérico, hay que tener en cuenta un aspecto importante: Sacudir la caja ya no tiene sentido. ¿Cuál sería el propósito de tal acción?
En adelante, permitiremos cualquier valor en nuestra caja - pero tiene que ser universalmente útil. Tenemos que ofrecer funcionalidades como mutar un valor o transformarlo en otro tipo.
Aquí es donde .Map<TResult>(Func<T, TResult>) entra en juego.
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);
}
La operación Map representa un avance significativo, ya que nos permite "alterar" el valor subyacente. Bueno, "alterar" quizá no sea el término adecuado, ya que no estamos modificando directamente el valor. En su lugar, lo estamos utilizando para generar un nuevo valor colocado en otro contenedor.
Puedes pensar en esta operación como algo similar a .Select(Func<TSource, TResult>) en 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();
Este concepto se alinea con el anterior .Shake() método:
Cuando la casilla carece de valor, no se realiza ninguna acción.
An image illustrating the None status
Pero cuando la caja contiene un valor, aplica la función a ese valor y entrega el resultado en una nueva caja.
An image illustrating the Some status
Fuente: Blog de Aditya Bhargava blog - probablemente los mejores dibujos que he visto para ilustrar este mecanismo
Aunque sólo hemos cubierto una simple operación de transformación, ¡hemos conseguido convertir nuestra caja en un Functor! ¿Recuerdas nuestra explicación anterior?
"Piensa en un functor como una caja sobre la que puedes mapear una función, lo que significa que puedes aplicar una función a lo que hay dentro de la caja sin mutar la propia caja".
Entonces, ¿qué falta para que nuestra caja evolucione hacia una mónada?
Enlace monádico
Habrás notado que al utilizar .Map<TResult>(Func<T, TResult>)el estado interno de nuestra caja permanece inalterado. Ya sea en un Some o None el mapeo preservará ese estado, y es imposible alterarlo.
Sin embargo, aquí es donde el Bind mecanismo se convierte en el centro de atención. A diferencia de Mapque toma una función que devuelve un valor, el método Bind anticipa una función que devuelve una nueva caja, que espera el siguiente parámetro Func<T, SchrodingerBox<TResult>>.
Veamos cómo se compara con 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();
Con Bindpodemos implementar comportamientos que modifiquen el estado de la caja basándose en una lógica de negocio específica. Puede que piense: "La caja inicial que contenía el gato ya hacía eso, pasar de vivo a fallecido", y tiene razón. Sin embargo, hay una diferencia importante: antes, la caja tenía la responsabilidad de ese cambio, ya que albergaba la lógica (.ShakeTooHard()). Ahora la responsabilidad recae en la función. Delegamos con éxito esto en el llamador, ampliando las posibilidades de nuestra caja.
En el siguiente ejemplo, introducimos un nuevo método, .Increment(int)que incrementa el valor mientras se mantenga por debajo de tres. Sin embargo, si el valor es igual o superior a tres, devuelve una caja vacía.
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);
¡Por fin nuestra caja es una mónada! Ahora incluye tanto Map y Bind lo que nos permite manipular un valor envuelto sin conocer su estado inicial.
Sin embargo, aún nos queda un aspecto por abordar.
Abrir la caja
An image illustrating a gift box
Nuestra Mónada, a menudo conocida como Opcional (Opción o Tal vez, dependiendo de su preferencia de lenguaje), puede estar en uno de dos estados: puede representar la presencia de un valor (Algunos) o la ausencia de un valor (Ninguno).
Actualmente, cuando abrimos la caja, puede devolver null si no hay ningún valor dentro. Sin embargo, esto no se alinea con la intención de nuestra Mónada porque la ausencia de valor es diferente de un valor nulo.
Esto plantea una pregunta intrigante: ¿Cómo se representa la ausencia de valor? No se hace, sino que se ofrece un comportamiento alternativo.
Y aquí es donde .Match<TResponse>(Func<T, TResponse> some, Func<TResponse> none) entra en juego.
// 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) evaluará el estado de la Mónada e invocará la función correspondiente.
Si el estado es Some, llamará a
somecon el valor interno para generar un resultado.Si el estado es Ninguno, invocará a
noneconfiando en un mecanismo alternativo para generar un resultado.
Ahora, apliquemos Match en lugar de Openboxdel ejemplo 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, nuestra caja contenía un int. Al extraer el valor de la caja, nos centramos en el "panorama general" y generamos un mensaje para el usuario final. Otro enfoque podría haber consistido en proporcionar un valor por defecto int cuando el estado era Ninguno, asumiendo que sería significativo.
La guinda del pastel: nos hemos librado de la anulabilidad, ¡y eso siempre es una victoria!
Vale la pena señalar que todo el flujo de trabajo sigue siendo coherente, tanto si hay un valor presente como si no. Esto no es una coincidencia, y voy a explorar las razones subyacentes más adelante en la serie.
Sin embargo, este cambio requiere una consideración más amplia de la finalidad del valor: ¿cuáles son las implicaciones de la ausencia de un valor cuando la anulabilidad ya no es una opción?
¿Qué significa en el contexto de una operación de extremo a extremo?
¿Cuándo debemos abrir la caja?
An image illustrating a family opening christmas gifts
Este es a menudo el punto en el que los individuos se enfrentan a retos - al menos, ese fue mi caso - porque tendemos a cometer el error de novato de extraer el valor antes de tiempo.
Mantener el valor dentro de la caja tiene sentido siempre que ambos estados den lugar a efectos secundarios distintos.
Por ejemplo, tu mónada tendrá una vida relativamente corta si puedes generar un valor bastante pronto, como se demuestra con el .Match(some, none) ejemplo. Por el contrario, si todo tu flujo depende de la presencia de un valor, como la actualización de un usuario, tendrás que mantenerlo hasta el final.
Recomiendo mantener la mónada activa el mayor tiempo posible, extrayendo el valor en el momento más tardío.
Todo gira en torno a los Estados
An image illustrating traffic lights
Las mónadas giran en torno al concepto de gestión y manipulación de estados.
Obviamente, nuestra mónada es sólo un ejemplo entre muchos otros, cada uno diseñado para manejar diversos estados. Aquí hay algunos otros, junto con los diferentes estados que manejan:
Resultado (éxito|fracaso)
Cualquiera (Izquierda|Derecha)
Validación (Éxito|ErrorValidación)
etc.
Para esta presentación, hemos desarrollado nuestra caja personalizada, pero es importante señalar que no tienes por qué hacer lo mismo.
Existen numerosas bibliotecas que ofrecen mónadas ya creadas. He aquí algunas recomendaciones:
Language-Ext es mi favorito, pero puede resultar un poco abrumador para los principiantes debido a su amplio conjunto de funciones.
Para terminar
Espero que te hayas divertido leyendo este artículo, y he intentado que las mónadas te den menos miedo. Pueden ser complicadas cuando te sumerges en ellas por primera vez, pero créeme, merece la pena. Cambiaron mi forma de enfocar el software, y espero que hagan lo mismo contigo.
Recuerde lo que Douglas Crockford dijo una vez: "[...] las mónadas también están malditas, y la maldición de la mónada es que [...] una vez que lo entiendes, pierdes la capacidad de explicárselo a nadie"
Como he dicho antes, esta entrada del blog era sólo un calentamiento. El tema es tan amplio que no cabe todo en una entrada, y estamos a punto de explorar algunas cosas nuevas en la próxima. Estén atentos.
Si tienes alguna pregunta o simplemente quieres charlar, no dudes en ponerte en contacto conmigo a través de mi cuenta de LinkedIn o únete a nosotros en Slack para desarrolladores de Vonage.
Feliz codificación y hasta luego.
Compartir:
Guillaume es desarrollador senior de .Net en Vonage. Ha estado trabajando en .Net durante casi 15 años, mientras que en los últimos años se ha centrado en la defensa de Software Craftsmanship. Sus temas favoritos son la calidad del código, la automatización de pruebas, el mobbing y las katas de código. Fuera del trabajo, le gusta pasar tiempo con su mujer y su hija, hacer ejercicio o jugar.