https://d226lax1qjow5r.cloudfront.net/blog/blogposts/the-monad-invasion-part-1-whats-a-monad/the-monad-invasion.png

L'invasion des monades - Partie 1 : Qu'est-ce qu'une monade ?

Publié le November 30, 2023

Temps de lecture : 10 minutes

Bonjour à tous,

Lors d'une présentation, deux choses peuvent potentiellement faire fuir les développeurs. La première est un orateur qui fait une présentation terriblement ennuyeuse ; la seconde est la simple mention du mot "Monad".

Les monades ont la réputation d'effrayer les gens. En se plongeant dans la théorie des catégories introduit un réseau de terminologies complexes, comme les "monades", ce qui rend plus difficile une explication simple et directe.

Ce billet est le premier d'une série visant à démystifier les Monads et à vous aider à en tirer profit, à l'aide d'exemples concrets tirés de notre NET SDK. Regardez mon exposé sur le sujet "Lancez des exceptions... hors de votre base de code"qui portait plus sur les Monads que sur les exceptions, même si le titre dit le contraire.

Qu'est-ce qu'une monade ?

Je suis désolé de vous décevoir, mais je n'aborderai pas l'aspect théorique. Je pourrais dire que une Monade est un monoïde de la catégorie des endofoncteursmais où va-t-on à partir de là ?

Pour simplifier, considérez un foncteur comme une boîte sur laquelle vous pouvez mapper une fonction, ce qui signifie que vous pouvez appliquer une fonction à ce qui se trouve à l'intérieur de la boîte sans la modifier. Une monade est une étape supplémentaire par rapport à un foncteur. Il s'agit d'une boîte surpuissante qui permet non seulement d'appliquer des fonctions, mais qui offre également des possibilités supplémentaires pour contrôler la séquence des opérations.

Vous avez compris ? Pas encore ? Ce n'est pas grave ! Nous passerons à un scénario plus pragmatique pour des exemples concrets au fur et à mesure que nous approfondirons la question.

Le chat de Schrodinger

An image illustrating Schrodinger's cat

Oui, cet exemple est tiré de la mécanique quantique, mais il n'y a pas lieu de s'inquiéter, car il est relativement simple.

Imaginez un scénario avec une boîte et un chat. Mais il ne s'agit pas d'une boîte ordinaire, car elle contient également un dispositif qui peut libérer un poison mortel à tout moment, sans que vous puissiez savoir quand cela se produit.

Le point de vue d'Erwin SchrödingerLe point de vue d'Erwin Schrödinger est qu'au moment où vous placez le chat dans la boîte et que vous la fermez, vous ne pouvez pas déterminer si le chat est vivant ou mort jusqu'à ce que vous ouvriez à nouveau la boîte. Le chat n'est pas simultanément mort et vivant ; il est soit mort soit vivant, mais vous devez envisager les deux possibilités jusqu'à ce que vous puissiez observer l'état réel.

C'est une façon d'illustrer le concept de superposition quantique où il faut accepter l'idée qu'une situation existe dans plusieurs états jusqu'à ce que l'on puisse faire une observation.

Vous vous demandez peut-être où je veux en venir avec tout cela ; eh bien, regardons un peu de code.

Une boîte avec un chat

Avertissement : aucun chat n'a été blessé lors de la création de ces extraits de code.

An imagine illustrating cats with their thumbs Up

Voici une mise en œuvre relativement simple de notre boîte :

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");
}

Je voudrais insister sur quelques points essentiels :

  • Lors de l'interaction avec l'API publique, la seule façon de créer une boîte avec un chat vivant est d'utiliser la méthode méthode d'usine statique .WithAliveCat(Cat).

  • Cat est une structure et ne peut donc pas être nulle. Dans ce contexte, lorsque le champ cat est déclaré nullable, nous comprenons qu'une instance de Cat désigne un chat vivant, tandis que null indique que le chat est décédé.

  • Il n'y a pas de moyen direct d'inspecter le chat à l'intérieur de la boîte, car aucune propriété n'expose le statut du chat. Pour déterminer si le chat est vivant ou décédé, vous devez ouvrir la boîte à l'aide de la méthode .OpenBox() méthode.

La boîte propose trois comportements distincts :

  • Secouer la boîte : Si le chat est vivant, il risque de ne pas apprécier les secousses et les "miaulements". L'utilisation de l'opérateur de propagation nulle garantit que nous n'appelons .Meow() si le chat est vivant. Rien ne se produit si le chat n'est plus parmi les vivants.

  • Secouer la boîte trop fort : comme dans le cas précédent, le chat miaulera s'il est vivant. Cependant, dans ce cas, le poison est libéré et le chat connaît une fin malheureuse.

  • Ouverture de la boîte : En ouvrant la boîte, nous recevons le chat, soit vivant (une instance), soit décédé (nul).

Pour voir la boîte en action, regardons un peu de code :

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();

Vous remarquerez que nous pouvons travailler avec la boîte sans avoir besoin de vérifier l'état du chat ; même secouer la boîte lorsque le chat est décédé n'entraîne aucune conséquence.

C'est bien beau, mais dans sa forme actuelle, cet exemple n'est pas très utile. L'intégration de cette boîte dans votre base de code ne résoudra aucun problème réel.

Cela ne signifie pas pour autant qu'il faille s'en débarrasser complètement. Actuellement, notre boîte contient un chat, mais Cat n'est qu'une structure spécifique. Grâce à la puissance des génériques, nous pourrions la remplacer par n'importe quel type !

Une boîte de T

An image illustrating a box of tea

Lorsqu'il s'agit de remplacer Cat par un type générique, il y a un aspect important à prendre en compte : Secouer la boîte n'a plus de sens. Quel serait le but d'une telle action ?

À l'avenir, nous autoriserons n'importe quelle valeur dans notre boîte, mais elle doit être universellement utile. Nous devons offrir des fonctionnalités telles que la mutation d'une valeur ou la transformation d'une valeur en un autre type.

C'est là que .Map<TResult>(Func<T, TResult>) entre en jeu !

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);
}

L'opération Map représente une avancée significative, puisqu'elle nous permet de "modifier" la valeur sous-jacente. En fait, "modifier" n'est peut-être pas le terme approprié, car nous ne modifions pas directement la valeur. En effet, nous ne modifions pas directement la valeur, mais nous l'utilisons pour générer une nouvelle valeur placée dans un autre conteneur.

Vous pouvez considérer cette opération comme étant similaire à .Select(Func<TSource, TResult>) dans 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();

Ce concept s'aligne sur la méthode précédente. .Shake() précédente :

Lorsque la case n'a pas de valeur, aucune action n'est entreprise.

An image illustrating the None status

Mais lorsque la boîte contient une valeur, il applique la fonction à cette valeur et fournit le résultat dans une nouvelle boîte.

An image illustrating the Some status

Source : Le blog d'Aditya Bhargava blog d'Aditya Bhargava - probablement les meilleurs dessins que j'ai vus pour illustrer ce mécanisme

Bien que nous n'ayons couvert qu'une simple opération de transformation, nous avons réussi à transformer notre boîte en un foncteur ! Vous vous souvenez de notre explication précédente ?

"Pensez à un foncteur comme à une boîte sur laquelle vous pouvez mapper une fonction, ce qui signifie que vous pouvez appliquer une fonction à ce qui se trouve à l'intérieur de la boîte sans modifier la boîte elle-même".

Alors, que manque-t-il pour que notre boîte évolue vers une Monade ?

Liaison monadique

Vous avez peut-être remarqué que lors de l'utilisation de .Map<TResult>(Func<T, TResult>)l'état interne de notre boîte reste inchangé. Qu'il s'agisse d'un Some ou None la cartographie préservera cet état, et il est impossible de l'altérer.

Mais c'est là que le mécanisme Bind est à l'honneur ! Contrairement à Mapqui prend une fonction retournant une valeur, la méthode Bind anticipe une fonction qui renvoie une nouvelle boîte, qui attend le paramètre suivant Func<T, SchrodingerBox<TResult>>.

Voyons ce qu'il en est par rapport à 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();

Avec Bindnous pouvons mettre en œuvre des comportements qui modifient l'état de la boîte en fonction d'une logique métier spécifique. Vous pourriez penser : "La boîte initiale contenant le chat faisait déjà cela, en passant de l'état vivant à l'état décédé" - et vous avez raison. Cependant, il y a une distinction importante : auparavant, la boîte était responsable de ce changement puisqu'elle hébergeait la logique (.ShakeTooHard()). Aujourd'hui, la responsabilité incombe à la fonction, la responsabilité incombe à la fonction. Nous avons réussi à déléguer cette responsabilité à l'appelant, élargissant ainsi les possibilités de notre boîte.

Dans l'exemple suivant, nous introduisons une nouvelle méthode, .Increment(int)qui incrémente la valeur tant qu'elle reste inférieure à trois. Toutefois, si la valeur est égale ou supérieure à trois, elle renvoie une boîte vide.

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);

Notre boîte est enfin une Monade ! Elle comprend maintenant les deux éléments Map et Bind ce qui nous permet de manipuler une valeur enveloppée sans connaître son état initial.

Cependant, il reste encore un aspect à aborder.

Ouverture de la boîte

An image illustrating a gift box

Notre monade, souvent appelée Optional (Option ou Maybe, selon votre préférence linguistique), peut se trouver dans l'un des deux états suivants : elle peut représenter la présence d'une valeur (Some) ou l'absence d'une valeur (None).

Actuellement, lorsque nous ouvrons la boîte, elle peut renvoyer null s'il n'y a pas de valeur à l'intérieur. Cependant, cela ne correspond pas à l'intention de notre monade car l'absence de valeur est différente d'une valeur nulle.

Cela soulève une question intrigante : Comment représenter l'absence de valeur ? Eh bien, vous ne le faites pas - au lieu de cela, vous fournissez un comportement de repli.

Et c'est là que .Match<TResponse>(Func<T, TResponse> some, Func<TResponse> none) entre en jeu !

// 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) évaluera l'état de la monade et invoquera la fonction correspondante.

  • Si l'état est Some, il appellera some avec la valeur interne pour générer un résultat.

  • Si l'état est Aucun, il invoquera noneen s'appuyant sur un mécanisme de secours pour générer un résultat.

Appliquons maintenant Match à la place de Openboxen utilisant l'exemple précédent :

// 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");

Initialement, notre boîte contenait un int. Lors de l'extraction de la valeur de la boîte, nous nous sommes concentrés sur la "vue d'ensemble" et avons généré un message pour l'utilisateur final. Une autre approche aurait pu consister à fournir une valeur par défaut int par défaut lorsque l'état était None, en supposant qu'elle serait significative.

La cerise sur le gâteau : nous avons réussi à nous débarrasser de la nullité - c'est toujours une victoire !

Il est intéressant de noter que l'ensemble du flux de travail reste cohérent, qu'une valeur soit présente ou non. Il ne s'agit pas d'une coïncidence, et j'explorerai les raisons sous-jacentes plus loin dans cette série.

Toutefois, ce changement nécessite une réflexion plus large sur l'objectif de la valeur - quelles sont les implications de l'absence d'une valeur lorsque la nullité n'est plus une option ?

Qu'est-ce que cela signifie dans le contexte d'une opération de bout en bout ?

Quand faut-il ouvrir la boîte ?

An image illustrating a family opening christmas gifts

C'est souvent à ce stade que les individus rencontrent des difficultés - en tout cas, c'était le cas pour moi - parce que nous avons tendance à commettre l'erreur de débutant d'extraire la valeur prématurément.

Le maintien de la valeur à l'intérieur de la boîte est logique tant que les deux états entraînent des effets secondaires distincts. effets secondaires distincts.

Par exemple, votre monade aura une durée de vie relativement courte si vous pouvez générer une valeur assez tôt, comme le montre l'exemple suivant .Match(some, none) exemple. Au contraire, si tout votre flux dépend de la présence d'une valeur, comme la mise à jour d'un utilisateur, vous devrez vous y tenir jusqu'à la fin.

Je recommande de garder votre monade active aussi longtemps que possible, en extrayant la valeur au dernier moment.

Tout tourne autour des États

An image illustrating traffic lights

Les monades s'articulent autour du concept de gestion et de manipulation des états.

Bien entendu, notre monade n'est qu'un exemple parmi tant d'autres, chacun étant conçu pour gérer différents états. En voici quelques autres, avec les différents états qu'elles gèrent :

  • Résultat (succès|échec)

  • Soit (Gauche|Droite)

  • Validation (succès|erreur de validation)

  • etc.

Pour cette présentation, nous avons développé notre boîte personnalisée, mais il est important de noter que vous n'êtes pas obligé de faire la même chose !

Il existe de nombreuses bibliothèques qui proposent des monades prêtes à l'emploi. Voici quelques recommandations :

Conclusion

J'espère que vous avez pris du plaisir à lire cet article, et que j'ai essayé de rendre les Monades moins effrayantes pour vous. Elles peuvent être délicates quand on s'y plonge pour la première fois, mais croyez-moi, cela en vaut la peine. Ils ont changé ma façon d'aborder les logiciels, et j'espère qu'ils feront de même pour vous.

Souvenez-vous de ce que Douglas Crockford a dit un jour : "[...] les monades sont aussi maudites, et la malédiction de la monade est que [...] une fois que vous avez compris, vous perdez la capacité de l'expliquer à qui que ce soit"

Comme je l'ai indiqué précédemment, ce billet n'était qu'un échauffement. Le sujet est tellement vaste qu'il ne tient pas dans un seul billet, et nous nous apprêtons à explorer de nouvelles choses intéressantes dans le prochain billet. Restez donc à l'écoute.

Si vous avez des questions ou si vous voulez simplement discuter, n'hésitez pas à me contacter sur mon LinkedIn ou rejoignez-nous sur le Slack des développeurs de Vonage.

Bon codage, et à plus tard !

Partager:

https://a.storyblok.com/f/270183/384x384/fdffb72c8b/guillaume-faas.png
Guillaume FaasDéveloppeur .Net Senior Advocate

Guillaume est Senior Developer Advocate chez Vonage. Il travaille dans le domaine de .Net depuis près de 15 ans et s'est concentré sur la promotion du Software Craftsmanship au cours des dernières années. Ses sujets de prédilection sont la qualité du code, l'automatisation des tests, le mobbing et les katas du code. En dehors du travail, il aime passer du temps avec sa femme et sa fille, faire de l'exercice ou jouer.