https://d226lax1qjow5r.cloudfront.net/blog/blogposts/the-monad-invasion-part-2-monads-in-action/the-monad-invasion_part2.png

La invasión de las mónadas - Parte 2: ¡Mónadas en acción!

Publicado el December 12, 2023

Tiempo de lectura: 8 minutos

Hola amigos,

En nuestro anterior post "La invasión de las mónadas - Parte 1: ¿Qué es una mónada?"elegimos un enfoque pragmático para introducir las mónadas, construyendo las nuestras de forma incremental. A estas alturas, ya deberías estar familiarizado con el concepto, entendiendo cómo transformar su valor con .Map() o .Bind() y cómo extraerlo con .Match().

En este artículo, queremos demostrar aplicaciones prácticas para varias Mónadas, y usaremos un ejemplo del SDK .NET de Vonage. Pero primero, hagamos una rápida recapitulación.

Resumen rápido

Anteriormente creamos la Optional Mónada, que puede existir en uno de dos estados: Some, que indica la presencia de un valor, o Noneque indica la ausencia de valor.

private static SchrodingerBox<int> Half(int value) =>
    value % 2 == 0 
        ? SchrodingerBox<int>.Some(value / 2)
        : SchrodingerBox<int>.None();

private static int Increment(int value) => value + 1;

SchrodingerBox<int>.Some(0)
    .Map(Increment) // Some(0) becomes Some(1)
    .Map(Increment) // Some(1) becomes Some(2)
    .Bind(Half) // Some(2) becomes Some(1)
    .Map(Increment) // Some(1) becomes Some(2)
    .Match(some => $"The value is some {some}!", () => "The value is none")
    .Should()
    .Be("The value is some 2!");

SchrodingerBox<int>.Some(0)
    .Map(Increment) // Some(0) becomes Some(1)
    .Bind(Half) // Some(1) becomes None - the Monad's state has changed
    .Map(Increment) // None remains None
    .Match(some => $"The value is some {some}!", () => "The value is none")
    .Should()
    .Be("The value is none");

Nuestra caja ofrece tres funcionalidades esenciales:

  • .Map(operation) le permite generar un nuevo valor basado en uno existente y envolver el nuevo valor en una nueva caja. La operación sólo se ejecuta si la caja está en Some estado.

  • .Bind(operation) es un mecanismo de transformación similar a .Map(). Sin embargo, se diferencia por permitir la alteración del estado de la caja, ya que la función devuelve una caja en lugar de un valor. Al igual que .Map()la operación sólo se ejecuta si la caja está en estado Some estado.

  • .Match(some, none) evalúa el estado de la caja e invoca la función correspondiente.

La casilla permite manipular un valor sin conocer su estado actual.

En esencia, el estado se vuelve irrelevante ya que su secuencia de operaciones sigue siendo la misma. Como probablemente hayas observado, el código no tiene construcciones de bifurcación (if/else).

¿Cuándo merece la pena utilizar una mónada?

An image for exposing two different states

Dada su naturaleza de estados opuestos, las mónadas resultan irrelevantes en escenarios con operaciones que producen un resultado singular. Para que las mónadas sean aplicables, la operación debe presentar al menos dos resultados posibles distintos.

Hasta ahora hemos trabajado con la mónada Optional pero existen muchas más. Para compartir ejemplos de otras mónadas, vamos a explorar qué mónadas de la biblioteca Lenguaje-Ext y descubramos sus contextos útiles.

Opción

Dada nuestra implementación anterior, ya deberías estar bastante familiarizado con ella. Brilla cuando envuelve un valor opcional o un valor que puede (o no) existir.

private async Task<Option<User>> FindUser(Guid id)
{
    var user = await this.repository.Users.FirstOrDefaultAsync(user => user.Id == id);
    return user ?? Option<User>.None;
}

Pruebe

La Try<T> mónada representa una operación que puede fallar:

  • Success indica que la operación se ha realizado correctamente, obteniéndose el resultado <T>.

  • Exception indica que la operación resultó en una excepción. La mónada devolverá la excepción en lugar de lanzarla.

private static Try<decimal> Divide(decimal value, decimal divisor) => 
        Try(() => value / divisor);

En el ejemplo anterior, podemos encapsular una operación potencialmente "arriesgada" dentro de un archivo Try.

  • Si se invoca Divide(50,2) devolverá un Success estado con el valor 25.

  • Si se invoca Divide(50,0) devolverá un estado Exception con un estado DivideByZeroException.

O bien

La Either<L, R> representa una operación que puede devolver dos tipos distintos, definidos por Lefto Right. Esta Mónada es extremadamente versátil, dado que <L, R> son ambos tipos genéricos. Sin embargo, el Left state suele representar errores o casos excepcionales.

private static Either<Error, decimal> Divide(decimal value, decimal divisor) =>
    divisor == 0
        ? new Error("Cannot divide by 0.")
        : value / divisor;

private record Error(string Message);

En línea con el escenario anterior:

  • Si se invoca Divide(50,2) devolverá un Right estado con el valor 25.

  • Si se invoca Divide(50,0) devolverá un estado Left con un registro Error explicando por qué no se ha procesado.

Validación

La Validation<Fail, Success> Monad representa una operación de validación que puede fallar por múltiples razones.

private record User(string Firstname, string Lastname, MailAddress Email);

private static Validation<string, User> CreateUser(string firstname, string lastname, string email)
{
    var errors = new Seq<string>();
    if (string.IsNullOrWhiteSpace(firstname))
    {
        errors = errors.Add("Firstname cannot be empty.");
    }
    
    if (string.IsNullOrWhiteSpace(lastname))
    {
        errors = errors.Add("Lastname cannot be empty.");
    }

    if (!MailAddress.TryCreate(email, out var address))
    {
        errors = errors.Add("Invalid mail address.");
    }

    return errors.Any()
        ? Validation<string, User>.Fail(errors)
        : Validation<string, User>.Success(new User(firstname, lastname, address));
}

En este ejemplo:

  • Si se invoca CreateUser("Jane", "Doe", "jane.doe@email.com") devolverá un Success con una instancia User válida.

  • Si se invoca CreateUser(null, null, null) (o cualquier valor inválido) devolverá un fichero Failure con la colección de errores encontrados.

Transparencia referencial

An image to illustrate transparency

Todas estas mónadas tienen algo en común: todas son transparentes sobre los posibles resultados de una operación, ya sea que falle, lance una excepción o devuelva varios valores. Como tal, el tipo de retorno del método debe comunicar el resultado de esos resultados en lugar de un mecanismo invisible e impredecible (una excepción).

Aquí hablamos de Transparencia referencial. Se aplica cuando podemos siempre sustituir una expresión por su valor.

// Referentially Opaque examples
public User FindUser(Guid id) => this.users.First(user => user.Id == id);
public int Divide(int a, int b) => a/b;
public void SetName(string name)
{
    ArgumentNullException.ThrowIfNull(name);
    this.Name = name;
}

// Referentially Transparent examples
public Maybe<User> FindUser(Guid id) => this.users.FirstOrDefault(user => user.Id == id) ?? Maybe<User>.None;
public Try<decimal> Divide(decimal value, decimal divisor) => Try(() => value / divisor);
public int Add(int a, int b) => a+b;
public Either<Error, Unit> SetName(string name)
{
    if (name is null)
    {
        return new Error("Name cannot be null);
    }
    
    this.Name = name;
    return Unit.Value;
}

Probablemente se habrá dado cuenta de que .SetName() devuelve un Either<Error, Unit>. Es posible que se haya encontrado con Unit en bibliotecas como MediatR o Language-Ext. Es una construcción simple que representa un tipo con un único valor posible. Lo utilizamos como marcador de posición para operaciones que no devuelven un valor pero pueden devolver otro estado. En nuestro ejemplo, .SetName() es un comando que no devuelve un valor pero puede fallar. Por lo tanto, la mónada Either<Error, Unit> tiene dos estados posibles: Derecha (sin valor) o Izquierda (con un Error).

Aunque la transparencia referencial no resuelve intrínsecamente problemas específicos, mejora significativamente la previsibilidad y legibilidad del código. Hacer todo explícito en la firma del método minimiza el potencial de lo inesperado. El tío Bob hace hincapié en su libro "Clean Code: A Handbook of Agile Software Craftsmanship" que "la proporción de tiempo dedicado a leer frente al dedicado a escribir es muy superior a 10 a 1. Estamos constantemente leyendo código antiguo como parte del esfuerzo por escribir código nuevo... Por lo tanto, hacer que sea fácil de leer hace que sea fácil de escribir". Esto pone de relieve la importancia de la claridad y la transparencia del código para facilitar tanto la comprensión como un desarrollo eficaz.

Esta fue mi principal motivación para crear "Lanza excepciones... fuera de tu código base".

Ejemplo de SDK de Vonage

Hasta ahora nos hemos centrado en ejemplos relativamente sencillos. Sin embargo, ¿qué pasa con el uso de mónadas en flujos más complejos? Veamos un ejemplo de nuestro SDK .NET que incluye nuestra API de autenticación de dos factores Verify.

Mónadas personalizadas

Anteriormente, construimos un Optional y mostramos un conjunto existente basado en Lenguaje-Extpero varias bibliotecas también ofrecen implementaciones de Mónadas. En el caso de nuestro SDKevitamos deliberadamente depender de bibliotecas externas como Language-Ext. De hecho, las mónadas forman parte de la API pública del SDK, y depender de una biblioteca externa introduciría una dependencia sobre la que tendríamos un control limitado.

Nuestro enfoque consistió en crear nuestras implementaciones de mónadas personalizadas adaptadas a las necesidades específicas del SDK. Esta estrategia nos permitió mantener el control sobre el diseño y la funcionalidad de estas mónadas, evitando al mismo tiempo depender de bibliotecas externas.

Además, nuestro objetivo era presentar una versión ligera y fácil de usar de mónadas específicas, garantizando una adopción más fácil por parte de los desarrolladores que trabajan con nuestro SDK.

Nuestro SDK implementa las siguientes mónadas:

  • Result<T> Mónada, similar a un Either<IFailure, T> con una sintaxis más concisa - C# es verboso en cuanto a los genéricos.

  • Maybe<T> Mónada, similar a un Option<T>.

Ejemplo con autenticación de dos factores

2FA es un flujo de trabajo en dos pasos. En primer lugar iniciamos un proceso de autenticación, cuyo resultado es que el cliente recibe un código de validación basado en el flujo de trabajo especificado (SMS, WhatsApp, Email, Voice, SilentAuth). Una vez recibido el código, lo enviamos a nuestra API para su verificación.

var myPhoneNumber = ...
var result = await StartVerificationRequest.Build()
    .WithBrand("Monads Inc.")
    .WithWorkflow(SmsWorkflow.Parse(myPhoneNumber))
    .Create() // Build the authentication request
    .BindAsync(request => verifyClient.StartVerificationAsync(request)) // Process the authentication request
    .BindAsync(VerifyCodeAsync) // Start the second step if it's a success
    .Match(AuthenticationSuccessful, AuthenticationFailed);

private async Task<Result<Unit>> VerifyCodeAsync(StartVerificationResponse response)
{
    var myCode = ... // Receive verification code based on the specified workflow
    return await response
        .BuildVerificationRequest(myCode) // Build the verification request
        .BindAsync(request => verifyClient.VerifyCodeAsync(request)); // Process the verification request
}

Todo lo que hemos cubierto hasta ahora sigue siendo aplicable ya que nuestro flujo de trabajo contiene la obtención de la entrada del usuario, las operaciones de análisis y la realización de llamadas a la API.

Una vez más, hemos eliminado con éxito la ramificación de nuestro código, manteniendo un flujo consistente independientemente del estado de la mónada. Vale la pena señalar que nuestro proceso puede fallar en cinco lugares distintos:

  • Al crear la solicitud de autenticación

  • Al procesar la solicitud de autenticación

  • Al elaborar la solicitud de verificación

  • Al tramitar la solicitud de verificación

  • Al llamar al segundo paso VerifyCodeAsync

Si recuerdas, en nuestro post anterior, hablamos de mantener una Mónada activa el mayor tiempo posible. En este escenario, el valor permanece en nuestra Result<T> desde el principio hasta el final del flujo, permitiéndonos encadenar toda la secuencia de operaciones.

Ser o no ser... ¿Puro?

An image to illustrate purity

En nuestro ejemplo anterior, habrás notado que nuestra Mónada no permanecía puro durante el flujo de trabajo. De hecho, proporcionamos funciones a funciones .Bind()como request => verifyClient.StartVerificationAsync(request) o request => verifyClient.VerifyCodeAsync(request). ¿Pero es problemático?

Por definición, un pura función no hace referencia a ningún estado global y no debe no producir ningún efecto secundario. Produce sistemáticamente una salida que sólo depende de la entrada, garantizando la misma salida para una entrada específica - en otras palabras, una alta previsibilidad.

Aunque el concepto de "mónada pura" puede ser discutido, es esencial entender que el uso de mónadas no requiere necesariamente que la mónada misma sea pura. En cambio, las mónadas se utilizan a menudo para estructurar cálculos que implican impuras impuras, respetando los principios de la programación funcional. De hecho, las interacciones con recursos externos, como bases de datos o API, introducen efectos secundarios.

Mónadas impuras... ¿Con excepciones?

El comportamiento por defecto asegura que nuestras mónadas no lanzarán ninguna excepción, incluso si la función parámetro dentro de ellas lo hace. Esta elección de diseño nos ayuda a mantener referential transparency.

public Result<TB> Bind<TB>(Func<T, Result<TB>> bind)
{
    try
    {
        return this.IsFailure
            ? Result<TB>.FromFailure(this.failure)
            : bind(this.success);
    }
    catch (Exception exception)
    {
        return SystemFailure.FromException(exception).ToResult<TB>();
    }
}

public Result<TB> Map<TB>(Func<T, TB> map)
{
    try
    {
        return this.IsFailure
            ? Result<TB>.FromFailure(this.failure)
            : Result<TB>.FromSuccess(map(this.success));
    }
    catch (Exception exception)
    {
        return SystemFailure.FromException(exception).ToResult<TB>();
    }
}

Sin embargo, ¿qué pasa si prefieres seguir con las excepciones en lugar de extraer el valor con .Match()? Queríamos que nuestras mónadas fueran versátiles, así que introdujimos una funcionalidad GetSuccessUnsafe() función. Esta función lanzará una excepción si la mónada se encuentra en el estado Failure estado.

public T GetSuccessUnsafe() => this.IfFailure(value => throw value.ToException());

El tipo y los datos de la excepción dependen del valor de fallo subyacente:

  • ResultFailure lanzará un mensaje VonageException

  • ParsingFailure lanzará un mensaje VonageException

  • HttpFailure lanzará un mensaje VonageHttpRequestException

  • AuthenticationFailure lanzará un mensaje VonageAuthenticationException

  • Y así sucesivamente...

A continuación te mostramos cómo puedes incorporar excepciones a tu flujo monádico utilizando el mismo ejemplo anterior:

try
{
    await StartVerificationRequest.Build()
        .WithBrand("Monads Inc.")
        .WithWorkflow(SmsWorkflow.Parse(myPhoneNumber))
        .Create()
        .BindAsync(request => verifyClient.StartVerificationAsync(request))
        .BindAsync(VerifyCodeAsync)
        .GetSuccessUnsafe(); // Will throw exception if in the Failure state
    AuthenticationSuccessful();
}
catch (Exception exception)
{
    AuthenticationFailed(exception);
}

Tanto si elige el enfoque monádico estándar como si opta por las excepciones, nuestro objetivo es dar cabida a diferentes estilos de gestión de errores, garantizando que nuestras mónadas se ajusten a sus preferencias de codificación.

Conclusión

Hemos concluido el segundo post de nuestra serie "La invasión de las mónadas". El objetivo de este artículo era mostrar varios conjuntos de mónadas, incluidas nuestras implementaciones personalizadas, e ilustrar su uso en flujos de trabajo ampliados.

Llegados a este punto, deberías darte cuenta de que Monads ofrece un enfoque alternativo a la gestión de errores en tus flujos de trabajo, y no es casualidad. De hecho, nuestro próximo post traerá más luz sobre la metodología detrás del encadenamiento de operaciones. Permanece atento para más información.

Si tienes alguna pregunta o quieres charlar conmigo, no dudes en escribirme a mi cuenta de LinkedIn o únete a nosotros en Slack para desarrolladores de Vonage.

Feliz codificación, ¡y hasta luego!

Compartir:

https://a.storyblok.com/f/270183/384x384/fdffb72c8b/guillaume-faas.png
Guillaume FaasPromotor senior de desarrollo .Net

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.