Monaden in 15 Minuten

Eintrag


Im YOW! 2013 einer der Entwickler der Haskell-Sprache, prof. Philip Wadler zeigte, wie Monaden es reinen Funktionssprachen ermöglichen, im Wesentlichen zwingende Operationen wie Eingabe-Ausgabe und Ausnahmebehandlung auszuführen. Es überrascht nicht, dass das Interesse des Publikums an diesem Thema zu einem explosionsartigen Wachstum der Veröffentlichungen über Monaden im Internet geführt hat. Leider verwenden die meisten dieser Veröffentlichungen Beispiele, die in funktionalen Sprachen geschrieben sind, was bedeutet, dass Neulinge in der funktionalen Programmierung etwas über Monaden lernen möchten. Monaden sind jedoch nicht spezifisch für Haskell oder funktionale Sprachen und können durch Beispiele in imperativen Programmiersprachen veranschaulicht werden. Dies ist der Zweck dieses Handbuchs.

Wie unterscheidet sich dieser Leitfaden von den anderen? Wir werden versuchen, die Monaden in nicht mehr als 15 Minuten zu öffnen, wobei wir nur die Intuition und einige elementare Beispiele für Python-Code verwenden. Daher werden wir nicht anfangen, zu theoretisieren und uns mit Philosophie zu befassen und Burritos , Raumanzüge , Schreibtische und Endofunktoren zu diskutieren.

Motivationsbeispiele


Wir werden drei Fragen im Zusammenhang mit der Zusammensetzung von Funktionen betrachten. Wir werden sie auf zwei Arten lösen: den üblichen Imperativ und die Verwendung von Monaden. Dann vergleichen wir die verschiedenen Ansätze.

1. Protokollierung


Angenommen, wir haben drei unäre Funktionen: f1 , f2 und f3 , die eine Zahl annehmen und diese um 1, 2 bzw. 3 erhöht zurückgeben. Jede Funktion generiert auch eine Nachricht, die einen Bericht über den abgeschlossenen Vorgang darstellt.
 def f1(x): return (x + 1, str(x) + "+1") def f2(x): return (x + 2, str(x) + "+2") def f3(x): return (x + 3, str(x) + "+3") 

Wir möchten sie verketten, um den Parameter x , mit anderen Worten, wir möchten x+1+2+3 berechnen. Darüber hinaus müssen wir eine lesbare Erklärung erhalten, was jede Funktion getan hat.

Wir können das gewünschte Ergebnis auf folgende Weise erzielen:
 log = "Ops:" res, log1 = f1(x) log += log1 + ";" res, log2 = f2(res) log += log2 + ";" res, log3 = f3(res) log += log3 + ";" print(res, log) 

Diese Lösung ist nicht ideal, da sie aus einer großen Anzahl monotoner Middleware besteht. Wenn wir unserer Kette eine neue Funktion hinzufügen möchten, müssen wir diesen Verknüpfungscode wiederholen. Darüber hinaus beeinträchtigen Manipulationen mit den Variablen res und log die Lesbarkeit des Codes, was es schwierig macht, der Hauptlogik des Programms zu folgen.

Idealerweise sollte ein Programm wie eine einfache Funktionskette aussehen, wie f3(f2(f1(x))) . Leider stimmen die von f1 und f2 Datentypen nicht mit den Parametertypen f2 und f3 überein. Aber wir können der Kette neue Funktionen hinzufügen:
 def unit(x): return (x, "Ops:") def bind(t, f): res = f(t[0]) return (res[0], t[1] + res[1] + ";") 

Jetzt können wir das Problem wie folgt lösen:
 print(bind(bind(bind(unit(x), f1), f2), f3)) 

Das folgende Diagramm zeigt den bei x=0 ablaufenden Rechenprozess. Hier sind v1 , v2 und v3 die Werte, die als Ergebnis von Aufrufen von unit und bind .



Die unit konvertiert den Eingabeparameter x in ein Tupel aus einer Zahl und einer Zeichenfolge. Die bind ruft die an sie übergebene Funktion als Parameter auf und akkumuliert das Ergebnis in der Zwischenvariablen t .

Wir konnten vermeiden, Middleware zu wiederholen, indem wir sie in die bind einfügten. Wenn wir nun die Funktion f4 , f4 wir sie einfach in die Kette ein:
 bind(f4, bind(f3, ... )) 

Und wir müssen keine weiteren Änderungen vornehmen.

2. Liste der Zwischenwerte


Wir werden dieses Beispiel auch mit einfachen unären Funktionen beginnen.
 def f1(x): return x + 1 def f2(x): return x + 2 def f3(x): return x + 3 

Wie im vorherigen Beispiel müssen wir diese Funktionen zusammensetzen, um x+1+2+3 zu berechnen. Wir müssen auch eine Liste aller Werte erhalten, die sich aus der Arbeit unserer Funktionen ergeben, dh x , x+1 , x+1+2 und x+1+2+3 .

Im Gegensatz zum vorherigen Beispiel sind unsere Funktionen zusammensetzbar, dh die Typen ihrer Eingabeparameter stimmen mit dem Typ des Ergebnisses überein. Daher gibt eine einfache Kette f3(f2(f1(x))) das Endergebnis zurück. In diesem Fall verlieren wir jedoch die Zwischenwerte.

Lösen wir das Problem "frontal":
 lst = [x] res = f1(x) lst.append(res) res = f2(res) lst.append(res) res = f3(res) lst.append(res) print(res, lst) 

Leider enthält diese Lösung auch viel Middleware. Und wenn wir uns entscheiden, f4 hinzuzufügen, müssen wir diesen Code erneut wiederholen, um die richtige Liste der Zwischenwerte zu erhalten.

Daher fügen wir wie im vorherigen Beispiel zwei zusätzliche Funktionen hinzu:
 def unit(x): return (x, [x]) def bind(t, f): res = f(t[0]) return (res, t[1] + [res]) 

Jetzt schreiben wir das Programm als eine Kette von Aufrufen um:
 print(bind(bind(bind(unit(x), f1), f2), f3)) 

Das folgende Diagramm zeigt den bei x=0 ablaufenden Rechenprozess. Wiederum bezeichnen v1 , v2 und v3 die Werte, die von unit und bind werden.



3. Leere Werte


Versuchen wir, ein interessanteres Beispiel zu geben, diesmal mit Klassen und Objekten. Angenommen, wir haben eine Employee Klasse mit zwei Methoden:
 class Employee: def get_boss(self): # Return the employee's boss def get_wage(self): # Compute the wage 

Jedes Objekt der Employee Klasse verfügt über einen Manager (ein anderes Objekt der Employee Klasse) und ein Gehalt, auf die über geeignete Methoden zugegriffen wird. Beide Methoden können auch None (der Mitarbeiter hat keinen Manager, das Gehalt ist unbekannt).

In diesem Beispiel erstellen wir ein Programm, das das Gehalt des Leiters dieses Mitarbeiters anzeigt. Wenn der Manager nicht gefunden wird oder sein Gehalt nicht ermittelt werden kann, gibt das Programm None .

Im Idealfall müssen wir so etwas schreiben
 print(john.get_boss().get_wage()) 

In diesem Fall wird unser Programm jedoch mit einem Fehler beendet, wenn eine der Methoden None zurückgibt.

Ein offensichtlicher Weg, um mit dieser Situation umzugehen, könnte folgendermaßen aussehen:
 result = None if john is not None and john.get_boss() is not None and john.get_boss().get_wage() is not None: result = john.get_boss().get_wage() print(result) 

In diesem Fall erlauben wir zusätzliche Aufrufe der get_wage get_boss und get_wage . Wenn diese Methoden schwer genug sind (z. B. Zugriff auf die Datenbank), reicht unsere Lösung nicht aus. Deshalb ändern wir es:
 result = None if john is not None: boss = john.get_boss() if boss is not None: wage = boss.get_wage() if wage is not None: result = wage print(result) 

Dieser Code ist hinsichtlich der Berechnung optimal, wird jedoch aufgrund von drei verschachtelten if schlecht gelesen. Daher werden wir versuchen, den gleichen Trick wie in den vorherigen Beispielen zu verwenden. Definieren Sie zwei Funktionen:
 def unit(e): return e def bind(e, f): return None if e is None else f(e) 

Und jetzt können wir die gesamte Lösung in einer Zeile zusammenfassen.
 print(bind(bind(unit(john), Employee.get_boss), Employee.get_wage)) 

Wie Sie wahrscheinlich bereits bemerkt haben, mussten wir in diesem Fall die unit nicht schreiben: Sie gibt einfach den Eingabeparameter zurück. Aber wir werden es so lassen, dass es für uns einfacher ist, unsere Erfahrungen zu verallgemeinern.

Beachten Sie auch, dass wir in Python Methoden als Funktionen verwenden können, mit denen wir Employee.get_boss(john) anstelle von john.get_boss() schreiben konnten.

Das folgende Diagramm zeigt den Berechnungsprozess, wenn John keinen Anführer hat, d. H. john.get_boss() gibt None .



Schlussfolgerungen


Angenommen, wir möchten die Funktionen des gleichen Typs f1 , f2 , , fn kombinieren. Wenn ihre Eingabeparameter mit den Ergebnissen übereinstimmen, können wir eine einfache Kette der Form fn(… f2(f1(x)) …) . Das folgende Diagramm zeigt einen verallgemeinerten Berechnungsprozess mit Zwischenergebnissen, die als v1 , v2 , , vn .



Oft ist dieser Ansatz nicht anwendbar. Die Arten von Eingabewerten und Funktionsergebnissen können wie im ersten Beispiel variieren. Oder Funktionen können zusammensetzbar sein, aber wir möchten zusätzliche Logik zwischen Aufrufen einfügen, da wir in den Beispielen 2 und 3 eine Aggregation von Zwischenwerten bzw. eine Prüfung auf einen leeren Wert eingefügt haben.

1. Imperative Entscheidung


In allen Beispielen haben wir zunächst den einfachsten Ansatz verwendet, der durch das folgende Diagramm dargestellt werden kann:



Bevor wir f1 aufrufen f1 wir einige Initialisierungen durchgeführt. Im ersten Beispiel haben wir eine Variable zum Speichern eines gemeinsamen Protokolls initialisiert, im zweiten für eine Liste von Zwischenwerten. Danach haben wir Funktionsaufrufe mit einem bestimmten Verbindungscode durchsetzt: Wir haben Aggregatwerte berechnet und das Ergebnis auf None überprüft.

2. Monaden


Wie wir in den Beispielen gesehen haben, litten imperative Entscheidungen immer unter Ausführlichkeit, Wiederholung und verwirrender Logik. Um eleganteren Code zu erhalten, haben wir ein bestimmtes Entwurfsmuster verwendet, nach dem wir zwei Funktionen erstellt haben: unit und bind . Diese Vorlage wird als Monade bezeichnet . Die bind enthält Middleware, während das unit die Initialisierung implementiert. Dies ermöglicht es uns, die endgültige Lösung auf eine Zeile zu vereinfachen:
 bind(bind( ... bind(bind(unit(x), f1), f2) ... fn-1), fn) 

Der Berechnungsprozess kann durch ein Diagramm dargestellt werden:



Ein Aufruf von unit(x) erzeugt einen Anfangswert von v1 . Dann erzeugt bind(v1, f1) einen neuen Zwischenwert v2 , der beim nächsten Aufruf zum bind(v2, f2) . Dieser Prozess wird fortgesetzt, bis ein Endergebnis erhalten wird. Durch die Definition verschiedener unit und bind im Rahmen dieser Vorlage können wir verschiedene Funktionen in einer Berechnungskette kombinieren. Monadenbibliotheken ( z. B. PyMonad oder OSlash, ca. Transl. ) Enthalten normalerweise gebrauchsfertige Monaden (Paare von unit und bind ) zum Implementieren bestimmter Funktionszusammensetzungen.

Um Funktionen zu verketten, müssen die von unit und bind zurückgegebenen Werte vom gleichen Typ sein wie die Eingabeparameter von bind . Dieser Typ wird monadisch genannt . In Bezug auf das obige Diagramm muss der Typ der Variablen v1 , v2 , , vn ein monadischer Typ sein.

Überlegen Sie abschließend, wie Sie den mit Monaden geschriebenen Code verbessern können. Offensichtlich wirken wiederholte bind unelegant. Um dies zu vermeiden, definieren Sie eine andere externe Funktion:
 def pipeline(e, *functions): for f in functions: e = bind(e, f) return e 

Jetzt stattdessen
 bind(bind(bind(bind(unit(x), f1), f2), f3), f4) 

Wir können die folgende Abkürzung verwenden:
 pipeline(unit(x), f1, f2, f3, f4) 


Fazit


Monaden sind ein einfaches und leistungsstarkes Entwurfsmuster, mit dem Funktionen erstellt werden. In deklarativen Programmiersprachen hilft es, zwingende Mechanismen wie Protokollierung oder Eingabe / Ausgabe zu implementieren. In imperativen Sprachen
Es hilft, Code zu verallgemeinern und zu verkürzen, der eine Reihe von Aufrufen von Funktionen desselben Typs verknüpft.

Dieser Artikel bietet nur ein oberflächliches, intuitives Verständnis von Monaden. Weitere Informationen erhalten Sie bei folgenden Quellen:

  1. Wikipedia
  2. Monaden in Python (mit schöner Syntax!)
  3. Zeitleiste der Monaden-Tutorials

Source: https://habr.com/ru/post/de445800/


All Articles