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

Die Invasion der Monaden - Teil 2: Monaden in Aktion!

Zuletzt aktualisiert am December 12, 2023

Lesedauer: 8 Minuten

Hallo Freunde,

In unserem letzten Beitrag "Die Invasion der Monaden - Teil 1: Was ist eine Monade?" haben wir einen pragmatischen Ansatz gewählt, um Monaden einzuführen, indem wir unsere eigenen schrittweise aufgebaut haben. Inzwischen sollten Sie mit dem Konzept vertraut sein und wissen, wie man seinen Wert entweder mit .Map() oder .Bind() umwandelt und wie man ihn mit .Match().

In diesem Artikel wollen wir praktische Anwendungen für verschiedene Monads demonstrieren, und wir werden ein Beispiel aus dem Vonage .NET SDK. Doch zunächst eine kurze Zusammenfassung.

Kurzer Rückblick

Zuvor haben wir die Optional Monade, die in einem von zwei Zuständen existieren kann: Some, was das Vorhandensein eines Wertes anzeigt, oder None, was das Fehlen eines Wertes anzeigt.

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

Unsere Box bietet drei wesentliche Funktionalitäten:

  • .Map(operation) ermöglicht es Ihnen, einen neuen Wert auf der Grundlage eines vorhandenen Wertes zu erzeugen und den neuen Wert in ein neues Feld einzuschließen. Der Vorgang wird nur ausgeführt, wenn die Box im Some Zustand ist.

  • .Bind(operation) ist ein Transformationsmechanismus ähnlich dem .Map(). Er unterscheidet sich jedoch dadurch, dass er die Änderung des Zustands der Box ermöglicht, da die Funktion eine Box anstelle eines Werts zurückgibt. Wie .Map()wird die Operation nur ausgeführt, wenn sich die Box im Some Zustand ist.

  • .Match(some, none) wertet den Zustand der Box aus und ruft die entsprechende Funktion auf.

Die Box ermöglicht es Ihnen, einen Wert zu manipulieren, ohne seinen aktuellen Zustand zu kennen.

Im Wesentlichen wird der Zustand irrelevant, da die Abfolge der Operationen die gleiche bleibt. Wie Sie wahrscheinlich bemerkt haben, enthält der Code keine Verzweigungskonstrukte (if/else).

Wann lohnt es sich, eine Monade zu verwenden?

An image for exposing two different states

Da es sich bei Monaden um gegensätzliche Zustände handelt, sind sie in Szenarien mit Operationen, die ein einziges Ergebnis liefern, irrelevant. Damit Monaden anwendbar sind, muss die Operation mindestens zwei verschiedene mögliche Ergebnisse aufweisen.

Bislang haben wir mit der Optional Monade gearbeitet, aber es gibt noch viele andere. Um Beispiele für andere Monaden zu geben, wollen wir uns ansehen, welche Monaden die Bibliothek Sprache-Ext anbietet, und finden wir ihre nützlichen Zusammenhänge heraus.

Option

In Anbetracht unserer früheren Implementierung sollten Sie mit dieser Methode inzwischen ziemlich vertraut sein. Sie glänzt, wenn sie einen optionalen Wert oder einen Wert, der möglicherweise (oder möglicherweise nicht) existiert, umhüllt.

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

Versuchen Sie

Die Try<T> Monad stellt eine Operation dar, die fehlschlagen kann:

  • Success zeigt an, dass die Operation erfolgreich war und das Ergebnis geliefert wurde <T>.

  • Exception zeigt an, dass die Operation zu einer Ausnahme geführt hat. Die Monade gibt die Ausnahme zurück, anstatt sie zu werfen.

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

Im obigen Beispiel können wir eine potenziell "riskante" Operation innerhalb einer Try.

  • Der Aufruf von Divide(50,2) gibt einen Success Zustand mit dem Wert 25.

  • Der Aufruf von Divide(50,0) gibt einen Exception Zustand mit einer DivideByZeroException.

Entweder

Die Either<L, R> Monade repräsentiert eine Operation, die zwei verschiedene Typen zurückgeben kann, definiert durch Leftoder Right. Diese Monade ist extrem vielseitig, da <L, R> beide generische Typen sind. Dennoch stellt der Left Zustand normalerweise Fehler oder Ausnahmefälle darstellen.

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

In Übereinstimmung mit dem vorherigen Szenario:

  • Der Aufruf von Divide(50,2) gibt einen Right Zustand mit dem Wert 25.

  • Der Aufruf von Divide(50,0) gibt einen Left Status mit einem Error Eintrag zurück, der erklärt, warum er nicht verarbeitet wurde.

Validierung

Die Validation<Fail, Success> Monad stellt eine Validierungsoperation dar, die aus mehreren Gründen fehlschlagen kann.

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

In diesem Beispiel:

  • Der Aufruf von CreateUser("Jane", "Doe", "jane.doe@email.com") gibt eine Success mit einer gültigen User Instanz zurück.

  • Der Aufruf von CreateUser(null, null, null) (oder eines ungültigen Wertes) wird ein Failure mit der Sammlung der aufgetretenen Fehler zurück.

Referenztransparenz

An image to illustrate transparency

Diese Monaden haben alle etwas gemeinsam: Sie sind alle transparent über die möglichen Ergebnisse einer Operation, ob sie fehlschlagen, eine Ausnahme auslösen oder verschiedene Werte zurückgeben kann. Daher muss der Rückgabetyp der Methode das Ergebnis dieser Ergebnisse mitteilen und nicht einen unsichtbaren und unvorhersehbaren Mechanismus (eine Ausnahme).

Hier geht es um Referentielle Transparenz. Sie gilt, wenn wir immer einen Ausdruck durch seinen Wert ersetzen können.

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

Sie haben wahrscheinlich bemerkt, dass .SetName() eine Either<Error, Unit>. Vielleicht sind Sie schon einmal auf Unit in Bibliotheken wie MediatR oder Sprache-Ext. Es ist ein einfaches Konstrukt, das einen Typ mit nur einem möglichen Wert darstellt. Wir verwenden es als Platzhalter für Operationen, die keinen Wert, aber einen anderen Zustand zurückgeben können. In unserem Beispiel, .SetName() ist ein Befehl, der keinen Wert zurückgibt, aber fehlschlagen kann. Daher hat die Monade Either<Error, Unit> zwei mögliche Zustände: Rechts (ohne Wert) oder Links (mit einem Fehler).

Obwohl die referentielle Transparenz keine spezifischen Probleme löst, verbessert sie die Vorhersehbarkeit und Lesbarkeit von Code erheblich. Dadurch, dass alles explizit in der Signatur der Methode steht, wird das Potenzial für Unerwartetes minimiert. Onkel Bob unterstreicht in seinem Buch "Clean Code: A Handbook of Agile Software Craftsmanship", dass "das Verhältnis von Lese- zu Schreibzeit weit über 10 zu 1 liegt. Wir lesen ständig alten Code, um neuen Code zu schreiben... Wenn man also dafür sorgt, dass er leicht zu lesen ist, kann man ihn auch leicht schreiben.". Dies zeigt, wie wichtig Klarheit und Transparenz des Codes sind, um sowohl das Verständnis als auch die effiziente Entwicklung zu erleichtern.

Dies war meine Hauptmotivation für die Erstellung von "Wirf Ausnahmen... aus deiner Codebasis".

Vonage SDK Beispiel

Bislang haben wir uns auf relativ einfache Beispiele konzentriert. Wie sieht es jedoch mit der Verwendung von Monaden in komplexeren Abläufen aus? Schauen wir uns ein Beispiel aus unserem .NET SDK mit unserer Zwei-Faktor-Authentifizierungs-API Verify.

Benutzerdefinierte Monaden

Zuvor haben wir eine Optional und zeigten einen bestehenden Satz auf der Grundlage von Sprache-Extbasiert, aber verschiedene Bibliotheken bieten auch Monad-Implementierungen an. Im Fall unseres SDKhaben wir es absichtlich vermieden, uns auf externe Bibliotheken wie Language-Ext. Monaden sind nämlich Teil der öffentlichen API des SDK, und der Rückgriff auf eine externe Bibliothek würde eine Abhängigkeit schaffen, über die wir nur begrenzt Kontrolle hätten.

Unser Ansatz bestand darin, eigene Monad-Implementierungen zu erstellen, die auf die spezifischen Anforderungen des SDK zugeschnitten waren. Diese Strategie ermöglichte es uns, die Kontrolle über das Design und die Funktionalität dieser Monads zu behalten und gleichzeitig Abhängigkeiten von externen Bibliotheken zu vermeiden.

Darüber hinaus war es unser Ziel, eine leichtgewichtige und benutzerfreundliche Version spezifischer Monads zu präsentieren, um die Akzeptanz bei Entwicklern, die mit unserem SDK arbeiten, zu gewährleisten.

Unser SDK implementiert die folgenden Monaden:

  • Result<T> Monade, ähnlich wie eine Either<IFailure, T> mit einer prägnanteren Syntax - C# ist in Bezug auf Generika sehr ausführlich.

  • Maybe<T> Monade, ähnlich wie ein Option<T>.

Beispiel mit Zwei-Faktoren-Authentifizierung

2FA ist ein zweistufiger Workflow. Zunächst leiten wir einen Authentifizierungsprozess ein, der dazu führt, dass der Kunde einen Validierungscode erhält, der auf dem angegebenen Workflow basiert (SMS, WhatsApp, E-Mail, Voice, SilentAuth). Sobald der Code empfangen wurde, senden wir ihn zur Überprüfung an unsere API.

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
}

Alles, was wir bisher behandelt haben, bleibt anwendbar, da unser Arbeitsablauf das Abrufen von Benutzereingaben, das Parsen von Vorgängen und das Durchführen von API-Aufrufen beinhaltet.

Auch hier haben wir die Verzweigung erfolgreich aus unserem Code entfernt und einen konsistenten Ablauf unabhängig vom Zustand der Monade beibehalten. Es ist erwähnenswert, dass unser Prozess an fünf verschiedenen Stellen fehlschlagen kann:

  • Bei der Erstellung der Authentifizierungsanfrage

  • Bei der Bearbeitung der Authentifizierungsanfrage

  • Bei der Erstellung des Überprüfungsantrags

  • Bei der Bearbeitung des Überprüfungsantrags

  • Beim Aufruf des zweiten Schritts VerifyCodeAsync

Wenn Sie sich erinnern, haben wir in unserem letzten Beitrag darüber gesprochen, eine Monade so lange wie möglich aktiv zu halten. In diesem Szenario bleibt der Wert in unserer Result<T> vom Anfang bis zum Ende des Ablaufs, so dass wir die gesamte Abfolge von Operationen verketten können.

Sein oder nicht sein... Rein?

An image to illustrate purity

In unserem vorherigen Beispiel haben Sie vielleicht bemerkt, dass unsere Monade nicht rein geblieben ist reine während des Arbeitsablaufs. In der Tat haben wir unreine Funktionen für .Bind()zur Verfügung gestellt, wie request => verifyClient.StartVerificationAsync(request) oder request => verifyClient.VerifyCodeAsync(request). Ist dies jedoch problematisch?

Per Definition ist eine reine Funktion bezieht sich nicht auf einen globalen Zustand und sollte keinen Nebeneffekt erzeugen. Sie erzeugt eine konsistente Ausgabe, die ausschließlich von der Eingabe abhängt, so dass für eine bestimmte Eingabe die gleiche Ausgabe gewährleistet ist - mit anderen Worten: eine hohe Vorhersagbarkeit.

Auch wenn das Konzept der "reinen Monade" diskutiert wird, ist es wichtig zu verstehen, dass die Verwendung von Monaden nicht notwendigerweise erfordert, dass die Monade selbst rein ist rein. Stattdessen werden Monaden oft verwendet, um Berechnungen zu strukturieren, die unreinen Operationen zu strukturieren und dabei die Prinzipien der funktionalen Programmierung einzuhalten. Interaktionen mit externen Ressourcen, wie Datenbanken oder APIs, führen nämlich zu Seiteneffekten.

Unreine Monaden... Mit Ausnahmen?

Das Standardverhalten stellt sicher, dass unsere Monaden keine Ausnahme auslösen, selbst wenn die Parameterfunktion innerhalb der Monaden dies tut. Diese Designentscheidung hilft uns, die 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>();
    }
}

Was aber, wenn Sie es vorziehen, bei Ausnahmen zu bleiben, anstatt den Wert mit .Match()? Wir wollten, dass unsere Monaden vielseitig sind, also haben wir eine GetSuccessUnsafe() Funktion eingeführt. Diese Funktion löst eine Ausnahme aus, wenn sich die Monade im Failure Zustand ist.

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

Die Art und die Daten der Ausnahme hängen von dem zugrunde liegenden Fehlerwert ab:

  • ResultFailure wirft eine VonageException

  • ParsingFailure wirft eine VonageException

  • HttpFailure wirft eine VonageHttpRequestException

  • AuthenticationFailure wirft eine VonageAuthenticationException

  • Und so weiter...

Hier sehen Sie, wie Sie Ausnahmen in Ihren monadischen Ablauf einbauen können, indem Sie das gleiche Beispiel wie zuvor verwenden:

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

Unabhängig davon, ob Sie sich für den standardmäßigen monadischen Ansatz oder für Ausnahmen entscheiden, ist es unser Ziel, verschiedene Arten der Fehlerbehandlung zu berücksichtigen und sicherzustellen, dass unsere Monaden mit Ihren Codierungspräferenzen übereinstimmen.

Einpacken

Wir haben den zweiten Beitrag unserer Serie "Die Invasion der Monaden" abgeschlossen. Ziel dieses Artikels war es, verschiedene Monads-Sets zu demonstrieren, einschließlich unserer eigenen Implementierungen, und ihre Verwendung in erweiterten Arbeitsabläufen zu veranschaulichen.

An diesem Punkt sollten Sie erkennen, dass Monads einen alternativen Ansatz für die Fehlerbehandlung in Ihren Arbeitsabläufen bietet - und das ist kein Zufall. In der Tat wird unser nächster Beitrag mehr Licht auf die Methodik hinter der Verkettung von Operationen werfen. Bleiben Sie dran für mehr!

Wenn Sie Fragen haben oder sich mit mir unterhalten wollen, 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.