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

Die Invasion der Monaden - Teil 1: Was ist eine Monade?

Zuletzt aktualisiert am November 30, 2023

Lesedauer: 9 Minuten

Hallo zusammen,

Während einer Präsentation gibt es zwei Dinge, die Entwickler dazu bringen können, die Flucht zu ergreifen. Das erste ist ein Redner, der einen furchtbar langweiligen Vortrag hält; das zweite ist die bloße Erwähnung des Wortes "Monade".

Monaden haben den Ruf, Menschen abzuschrecken. Eintauchen in die Kategorientheorie führt ein Netz komplexer Begriffe wie "Monaden" ein, was eine einfache und geradlinige Erklärung noch schwieriger macht.

Dies ist der erste Beitrag einer Serie, die Monads entmystifizieren und Ihnen helfen soll, sie anhand von Beispielen aus unserem .NET SDK. Sehen Sie sich meinen zugehörigen Vortrag "Throw exceptions... out of your codebase", in dem es mehr um Monads als um Ausnahmen ging, auch wenn der Titel etwas anderes sagt.

Also... Was ist eine Monade?

Es tut mir leid, dass ich Sie enttäuschen muss, aber ich werde nicht auf den theoretischen Aspekt eingehen. Ich könnte sagen, dass eine Monade ist ein Monoid aus der Kategorie der Endofunktorenaber wie geht man dann weiter vor?

Um es einfach auszudrücken: Stellen Sie sich einen Funktor als eine Box vor, auf die Sie eine Funktion abbilden können, was bedeutet, dass Sie eine Funktion auf das anwenden können, was sich innerhalb der Box befindet, ohne es zu verändern. Eine Monade ist nur eine Stufe höher als ein Funktor. Es ist wie eine superstarke Box, die es nicht nur erlaubt, Funktionen abzubilden, sondern auch zusätzliche Fähigkeiten bietet, um die Abfolge von Operationen zu kontrollieren.

Haben Sie es verstanden? Noch nicht? Das ist in Ordnung! Wir werden zu einem pragmatischeren Szenario wechseln, um Beispiele aus der Praxis zu zeigen, während wir uns weiter vertiefen.

Schrödingers Katze

An image illustrating Schrodinger's cat

Ja, dieses Beispiel stammt aus der Quantenmechanik, aber es gibt keinen Grund zur Panik, denn es ist relativ einfach.

Stellen Sie sich ein Szenario mit einer Schachtel und einer Katze vor. Allerdings handelt es sich nicht um eine typische Schachtel, denn sie enthält auch ein Gerät, das jederzeit tödliches Gift freisetzen kann, ohne dass Sie es merken.

Erwin SchrödingerDie Sichtweise von Erwin Schrödinger ist, dass man in dem Moment, in dem man die Katze in die Schachtel legt und sie schließt, nicht feststellen kann, ob die Katze lebt oder tot ist bis man die Schachtel wieder öffnet. Die Katze ist nicht gleichzeitig tot und lebendig; sie ist entweder tot oder lebendig, aber man muss beide Möglichkeiten in Betracht ziehen, bis man den tatsächlichen Zustand beobachten kann.

Dies ist eine Möglichkeit, das Konzept der Quantenüberlagerung bei dem man sich mit dem Gedanken anfreunden muss, dass eine Situation in mehreren Zuständen existiert, bis man eine Beobachtung machen kann.

Sie werden sich vielleicht fragen, worauf ich hinaus will; sehen wir uns doch einmal den Code an.

Eine Box mit einer Katze

Haftungsausschluss: Bei der Erstellung dieser Codeschnipsel sind keine Katzen zu Schaden gekommen.

An imagine illustrating cats with their thumbs Up

Hier ist eine relativ unkomplizierte Umsetzung unserer Box:

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

Ich möchte hier ein paar wichtige Punkte hervorheben:

  • Bei der Interaktion mit der öffentlichen API ist die einzige Möglichkeit, eine Box mit einer lebenden Katze zu erstellen, die Verwendung der statischen Fabrikmethode .WithAliveCat(Cat).

  • Cat ist eine Struktur und kann daher nicht null sein. Wenn in diesem Zusammenhang das Feld cat für nullable erklärt wird, bedeutet dies, dass eine Instanz von Cat eine lebende Katze bezeichnet, während null bedeutet, dass die Katze verstorben ist.

  • Es gibt keine direkte Möglichkeit, die Katze in der Box zu untersuchen, da keine Eigenschaften den Status der Katze anzeigen. Um festzustellen, ob die Katze lebt oder verstorben ist, müssen Sie die Box mit der .OpenBox() Methode öffnen.

Die Box bietet drei verschiedene Verhaltensweisen:

  • Schütteln Sie die Box: Wenn die Katze noch lebt, mag sie das Schütteln nicht und "miaut". Die Verwendung des Null-Fortpflanzungsoperators stellt sicher, dass wir nur .Meow() aufruft, wenn die Katze noch am Leben ist. Wenn die Katze nicht mehr unter den Lebenden weilt, geschieht nichts.

  • Zu starkes Schütteln der Box: Ähnlich wie bei dem vorherigen Verhalten miaut die Katze, wenn sie noch lebt. Allerdings wird in diesem Fall Gift freigesetzt, und die Katze nimmt ein unglückliches Ende.

  • Öffnen der Schachtel: Wenn wir die Schachtel öffnen, erhalten wir die Katze, entweder lebendig (eine Instanz) oder verstorben (null).

Um die Box in Aktion zu sehen, schauen wir uns etwas Code an:

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

Sie werden feststellen, dass wir mit der Box arbeiten können, ohne den Zustand der Katze überprüfen zu müssen; selbst das Schütteln der Box, wenn die Katze tot ist, hat keine Folgen.

Nun, das ist alles schön und gut, aber in seiner jetzigen Form bietet dieses Beispiel nicht viel Nutzen. Wenn Sie diese Box in Ihre Codebasis integrieren, werden Sie keine wirklichen Probleme lösen.

Das heißt aber nicht, dass wir sie ganz wegwerfen sollten. Derzeit hält unsere Box eine Katze, aber Cat ist nur eine bestimmte Struktur. Mit der Macht der Generika könnten wir sie gegen jeden Typ austauschen!

Eine Kiste mit T

An image illustrating a box of tea

Wenn es darum geht, Cat durch einen generischen Typ zu ersetzen, gibt es einen wichtigen Aspekt zu bedenken: Das Schütteln der Box macht keinen Sinn mehr. Was wäre der Zweck einer solchen Aktion?

In Zukunft werden wir jeden Wert in unserer Box zulassen - aber er muss universell hilfreich sein. Wir müssen Funktionalitäten wie die Mutation eines Wertes oder die Umwandlung des Wertes in einen anderen Typ anbieten.

Hier kommt .Map<TResult>(Func<T, TResult>) ins Spiel kommt!

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

Die Map stellt einen bedeutenden Fortschritt dar, denn sie ermöglicht es uns, den zugrunde liegenden Wert zu "verändern". Nun, "verändern" ist vielleicht nicht der richtige Ausdruck, da wir den Wert nicht direkt verändern. Stattdessen verwenden wir ihn, um einen neuen Wert zu erzeugen, der in einem anderen Container abgelegt wird.

Man kann sich diesen Vorgang ähnlich vorstellen wie .Select(Func<TSource, TResult>) in 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();

Dieses Konzept steht im Einklang mit der vorherigen .Shake() Methode:

Wenn das Feld keinen Wert enthält, wird keine Aktion durchgeführt.

An image illustrating the None status

Wenn das Feld jedoch einen Wert enthält, wendet es die Funktion auf diesen Wert an und gibt das Ergebnis in einem neuen Feld aus.

An image illustrating the Some status

Quelle: Aditya Bhargava's Blog - die wahrscheinlich besten Zeichnungen, die ich zur Veranschaulichung dieses Mechanismus gesehen habe

Obwohl wir nur eine einfache Transformationsoperation behandelt haben, haben wir es geschafft, unsere Box in einen Functor zu verwandeln! Erinnern Sie sich an unsere frühere Erklärung?

"Stellen Sie sich einen Funktor als eine Box vor, auf die Sie eine Funktion abbilden können, was bedeutet, dass Sie eine Funktion auf das anwenden können, was sich innerhalb der Box befindet, ohne die Box selbst zu verändern.

Was fehlt also noch, damit sich unsere Box zu einer Monade entwickeln kann?

Monadische Bindung

Sie haben vielleicht bemerkt, dass bei der Verwendung von .Map<TResult>(Func<T, TResult>)der interne Zustand unserer Box unverändert bleibt. Ob in einer Some oder None Zustand befindet, bleibt dieser Zustand durch die Zuordnung erhalten und kann nicht verändert werden.

Doch genau hier steht der Bind Mechanismus ins Rampenlicht! Im Gegensatz zu Mapdie eine Funktion annimmt, die einen Wert zurückgibt, nimmt die Bind Methode eine Funktion vorweg eine Funktion, die eine neue Box zurückgibtund erwartet den folgenden Parameter Func<T, SchrodingerBox<TResult>>.

Mal sehen, wie es sich gegen 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();

Mit Bindkönnen wir Verhaltensweisen implementieren, die den Zustand der Box auf der Grundlage einer bestimmten Geschäftslogik ändern. Sie werden vielleicht denken: "Die ursprüngliche Box, die die Katze enthielt, tat dies bereits, indem sie von lebendig zu verstorben überging" - und Sie haben Recht. Es gibt jedoch einen bedeutenden Unterschied: vorher, trug die Box die Verantwortung für diese Änderung, da sie die Logik enthielt (.ShakeTooHard()). Jetzt, liegt die Verantwortung bei der Funktion. Wir haben diese Aufgabe erfolgreich an den Aufrufer delegiert und damit die Möglichkeiten unserer Box erweitert.

Im folgenden Beispiel führen wir eine neue Methode ein, .Increment(int)ein, die den Wert erhöht, solange er unter drei bleibt. Ist der Wert jedoch gleich oder größer als drei, gibt sie ein leeres Feld zurück.

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

Unsere Box ist endlich eine Monade! Sie enthält nun sowohl Map und Bind Funktionalitäten, die es uns ermöglichen, einen verpackten Wert zu manipulieren, ohne seinen Anfangszustand zu kennen.

Es gibt jedoch noch einen weiteren Aspekt, den wir in Angriff nehmen müssen.

Das Öffnen der Box

An image illustrating a gift box

Unsere Monade, die oft als Optional (Option oder Maybe, je nach Sprachpräferenz) bezeichnet wird, kann sich in einem von zwei Zuständen befinden: Sie kann das Vorhandensein eines Wertes (Some) oder das Nichtvorhandensein eines Wertes (None) darstellen.

Wenn wir das Kästchen öffnen, kann es derzeit null zurückgeben, wenn sich kein Wert darin befindet. Dies entspricht jedoch nicht der Absicht unserer Monade, denn das Fehlen eines Wertes unterscheidet sich von einem Nullwert.

Dies wirft eine interessante Frage auf: Wie stellt man das Fehlen eines Wertes dar? Nun, man tut es nicht - stattdessen bietet man ein Ausweichverhalten an.

Und genau hier .Match<TResponse>(Func<T, TResponse> some, Func<TResponse> none) ins Spiel kommt!

// 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) bewertet den Zustand der Monade und ruft die entsprechende Funktion auf.

  • Wenn der Zustand Some ist, wird der Aufruf some mit dem inneren Wert auf, um ein Ergebnis zu erzeugen.

  • Wenn der Status None ist, wird noneauf und verlässt sich auf einen Fallback-Mechanismus, um ein Ergebnis zu erzeugen.

Verwenden wir nun Match anstelle von Openboxaus dem vorigen Beispiel:

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

Ursprünglich enthielt unsere Box eine int. Beim Extrahieren des Wertes aus der Box haben wir uns auf das "große Ganze" konzentriert und eine Meldung für den Endbenutzer erstellt. Ein anderer Ansatz hätte darin bestehen können, einen Standardwert bereitzustellen int Wert zu liefern, wenn der Status "Kein" ist, in der Annahme, dass dies sinnvoll ist.

Das Tüpfelchen auf dem i: Wir haben uns erfolgreich von der Nullbarkeit befreit - das ist immer ein Gewinn!

Es ist erwähnenswert, dass der gesamte Arbeitsablauf konsistent bleibt, egal ob ein Wert vorhanden ist oder nicht. Das ist kein Zufall, und ich werde die Gründe dafür später in dieser Serie untersuchen.

Diese Verschiebung erfordert jedoch eine umfassendere Betrachtung des Zwecks des Wertes - was sind die Auswirkungen des Fehlens eines Wertes, wenn die Nullbarkeit nicht mehr möglich ist?

Was bedeutet das im Zusammenhang mit einer durchgehenden Operation?

Wann sollten wir die Box öffnen?

An image illustrating a family opening christmas gifts

Dies ist oft der Punkt, an dem der Einzelne vor Herausforderungen steht - zumindest war das bei mir der Fall -, weil wir dazu neigen, den Anfängerfehler zu begehen, den Wert zu früh herauszuziehen.

Die Beibehaltung des Wertes innerhalb der Box ist sinnvoll, solange beide Zustände zu unterschiedliche Nebeneffekte.

So wird Ihre Monade eine relativ kurze Lebensdauer haben, wenn Sie einen Wert recht früh erzeugen können, wie im .Match(some, none) Beispiel. Wenn hingegen Ihr gesamter Ablauf vom Vorhandensein eines Wertes abhängt, wie z. B. bei der Aktualisierung eines Benutzers, müssen Sie ihn bis zum Ende durchhalten.

Ich empfehle, Ihre Monade so lange wie möglich aktiv zu halten und den Wert zum spätesten Zeitpunkt zu extrahieren.

Es geht um Staaten

An image illustrating traffic lights

Bei Monaden geht es um das Konzept der Verwaltung und Manipulation von Zuständen.

Unsere Monade ist natürlich nur ein Beispiel unter vielen anderen, die jeweils für verschiedene Zustände ausgelegt sind. Hier sind ein paar andere, zusammen mit den verschiedenen Zuständen, die sie behandeln:

  • Ergebnis (Erfolg|Fehlschlag)

  • Entweder (Links|Rechts)

  • Validierung (Erfolg|ValidierungFehler)

  • usw.

Für diese Präsentation haben wir unsere eigene Box entwickelt, aber es ist wichtig zu wissen, dass Sie nicht dasselbe tun müssen!

Es gibt zahlreiche Bibliotheken, die vorgefertigte Monads anbieten. Hier sind ein paar Empfehlungen:

Einpacken

Ich hoffe, Sie hatten Spaß beim Lesen dieses Artikels, und ich habe versucht, Ihnen die Angst vor Monaden zu nehmen. Sie können schwierig sein, wenn man sich zum ersten Mal mit ihnen beschäftigt, aber glauben Sie mir, es lohnt sich. Sie haben meine Herangehensweise an Software verändert, und ich hoffe, dass sie das auch für Sie tun werden.

Erinnern Sie sich, was Douglas Crockford einmal sagte: "[...] Monaden sind auch verflucht, und der Fluch der Monade ist, dass [...] wenn man es einmal verstanden hat, man die Fähigkeit verliert, es jemandem zu erklären"

Wie ich bereits erwähnt habe, war dieser Blogbeitrag nur eine Aufwärmphase. Das Thema ist so umfangreich, dass es nicht in einen einzigen Beitrag passt, und wir werden im nächsten Beitrag einige coole neue Dinge erkunden. Also, bleiben Sie dran.

Wenn Sie Fragen haben oder einfach nur plaudern möchten, können Sie mich gerne auf meinem LinkedIn oder folgen Sie uns auf dem Vonage Entwickler Slack.

Viel Spaß beim Programmieren, und bis später!

Teilen Sie:

https://a.storyblok.com/f/270183/384x384/fdffb72c8b/guillaume-faas.png
Guillaume FaasSenior .Net Entwickler Advocate

Guillaume ist ein Senior .Net Developer Advocate bei Vonage. Er arbeitet seit fast 15 Jahren in .Net und hat sich in den letzten Jahren auf die Förderung von Software Craftsmanship konzentriert. Zu seinen Lieblingsthemen gehören Codequalität, Testautomatisierung, Mobbing und Code Katas. Außerhalb der Arbeit verbringt er gerne Zeit mit seiner Frau und seiner Tochter, treibt Sport oder spielt Spiele.