Funktionales C# für Fortgeschrittene
Funktionale Programmierung ist heute überall zu finden. Noch vor einigen Jahren war es eine Ausnahmeerscheinung, funktionale Ideen in einer der vielen imperativen, meist objektorientierten Sprachen zu verwenden, die von den meisten Entwicklern etwa zur Erstellung geschäftlicher Anwendungen genutzt wurden. Auch technisch war dies in der Vergangenheit komplizierter, da ein gewisses Minimum an Features in einer Programmiersprache erforderlich ist, um die Syntax bei Verwendung funktionaler Ansätze lesbar und pflegbar zu halten.
In C# stehen gewisse Elemente der funktionalen Programmierung schon immer zur Verfügung. Die CLR kennt Delegates und unterstützt die Erzeugung von Funktionen höherer Ordnung (Higher Order Functions – Funktionen, die andere Funktionen als Parameter entgegennehmen oder sie als Rückgabewerte verwenden). Mit C# 2.0 wurden anonyme Funktionen und Iteratoren eingeführt und in C# 3.0 auch Lambda-Ausdrücke. Gleichzeitig mit C# 3.0 zeigte Microsoft im .NET Framework 3.5 erstmals, wie funktionale Paradigmen in größere API-Pakete verpackt und für .NET-Entwickler interessant gemacht werden konnten. Dies war die Geburtsstunde von LINQ, das sich aus Bibliotheken funktionaler APIs und Compilerfeatures zusammensetzte, wie der LINQ-Syntax selbst und der Expression Trees.
Jenseits dessen, was Microsoft mit LINQ "offiziell" im Sinn hatte, entwickelte sich zu dieser Zeit ein Interesse bei manchen C#-Entwicklern, mit der neuen Syntaxunterstützung mehr zu machen, andere funktionale Ideen in die Tat umzusetzen. Obwohl sich erst mit C# 6.0 noch einmal einige Neuerungen ergaben, die in diesem Bereich relevant sind, haben sich seitdem in vielen Bereichen funktionale Ansätze in C# derart durchgesetzt, dass Algorithmen heute weitgehend anders geschrieben werden, als es vor C# 3.0 der Fall war.
Generell ist zu unterscheiden, dass bestimmte Vorgaben der funktionalen Programmierung eine strukturelle Natur haben und nicht auf bestimmten syntaktischen Mechanismen basieren. Zum Beispiel gibt es die Idee der puren Funktion: sie soll frei von Nebeneffekten sein, also nur auf Basis ihrer Eingaben eine Rückgabe errechnen, ohne dabei außerhalb ihres eigenen Scopes zuzugreifen, erst recht nicht schreibend. Indem eine Programmiererin größere algorithmische Zusammenhänge in puren Funktionen implementiert, kann sie unter anderem erreichen, dass der Code parallelisiert werden kann, sogar vollautomatisch mithilfe von Werkzeugen wie PLINQ, ohne dass Kollisionen beim Datenzugriff zu befürchten sind. Die Umsetzung eines solchen Konzepts benötigt keine besondere Syntax, basiert aber gleichzeitig vollständig auf der Disziplin der Programmiererin und ihres Teams, da der Compiler in C# keinerlei Hilfestellung dazu liefert. Ähnliches gilt auch für unveränderbare Datenstrukturen, die in C# durchaus implementiert, aber nicht konsistent erzwungen werden können.
Funktionskonstruktion
Auf der anderen Seite gibt es funktionale Techniken, die sich stark syntaktisch im Code widerspiegeln. Dazu zählen die Ansätze der Function Construction, mit deren Hilfe auf funktionaler Ebene modularisiert werden kann. In traditionellen funktionalen Sprachen kann damit die Funktion als Baustein zur Erzeugung neuer Funktionen benutzt werden.
Komposition ist eine von zwei wichtigen Techniken in diesem Bereich. Nehmen Sie an, Sie haben zwei Funktionen wie diese:
B CalcBFromA(A) C CalcCFromB(B)
Offensichtlich können Sie beide Funktionen nacheinander aufrufen, wenn Sie aus A direkt C herleiten möchten:
C = CalcCFromB(CalcBFromA(A));
Wenn in Ihrem Code die Notwendigkeit besteht, regelmäßig den direkten Weg von A nach C zu beschreiten, kommen Sie vielleicht darauf, eine Hilfsfunktion zu erzeugen. Rein funktional lässt sich das Problem allerdings auch lösen. Die Hilfsfunktion Compose dient dazu, eine kombinierte neue Funktion zu erzeugen:
static Func<TSource, TEndResult> Compose<TSource, TIntermediateResult, TEndResult>( Func<TSource, TIntermediateResult> func1, Func<TIntermediateResult, TEndResult> func2) { return sourceParam => func2(func1(sourceParam)); }
Natürlich ließe sich diese Implementation etwas kompakter fassen, indem die Namen der generischen Parameter gekürzt würden. Der Klarheit halber sollen hier die langen Namen verwendet werden. Daraus ist ersichtlich, dass die Funktion mit drei unterschiedlichen Typen arbeiten kann. TSource ist der Typ des Quellwertes, also A im vorherigen Beispiel. TIntermediateResult ist der Typ von B, hier als Zwischentyp angesehen, da dieser Typ nur von einer Funktion an die anderen weiter gereicht wird. Schließlich gibt es noch TEndResult, im Beispiel der Typ von C.
Funktionale Komposition
Mit dieser Funktion in der Hinterhand können Sie nun funktionale Komposition verwenden:
Func<int, int> add5 = val => val + 5; Func<int, int> triple = val => val * 3; var add5AndTriple = Compose(add5, triple); var tripleAndAdd5 = Compose(triple, add5); Console.WriteLine(add5AndTriple(10)); Console.WriteLine(tripleAndAdd5(10));
Um es deutlicher zu machen, werden in diesem Code die Funktionen in beiden denkbaren Reihenfolgen "komponiert". Dies ist möglich, da alle verwendeten Datentypen im Beispiel identisch sind, aber natürlich ist das Ergebnis der Gesamtberechnung von der Reihenfolge der Auswertung abhängig.
Die zweite Technik, die zur Konstruktion von Funktionen angewandt werden kann, heißt Partial Application. Die Idee ist, einer Funktion, die mehrere Parameter für ihre Ausführung benötigt, zunächst nur eine unvollständige Parameterliste zu übergeben. Aus diesem Schritt entsteht eine neue Funktion, die gewissermaßen etwas spezifischer ist als die ursprüngliche. Nehmen Sie etwa die folgende Funktion Filter:
public static IEnumerable<T> Filter<T>(Predicate<T> predicate, IEnumerable<T> list) { foreach (T val in list) if (predicate(val)) yield return val; }
Der erste Parameter der Funktion ist ein Delegate, durch das bestimmt wird, welche Elemente aus der Liste entnommen werden, die als zweiter Parameter übergeben wird. Mit Partial Application können Sie eine neue Funktion erzeugen, in der bereits ein bestimmter Filteralgorithmus festgelegt ist, aber die Liste selbst später noch bestimmt werden kann. Auch hier liegt der Vorteil in der Wiederverwendbarkeit, indem Sie diese spezifischere Filterfunktion mehrfach mit unterschiedlichen Listen verwenden können.
Nun ist diese Beschreibung in C# zunächst rein hypothetisch, denn es ist für gewöhnlich unmöglich, eine Funktion aufzurufen, ohne die vollständige Parameterliste zu übergeben. Sie können allerdings eine Funktion in ein bestimmtes Format überführen, in dem sie jeden Parameter einzeln empfängt, so dass Partial Application anwendbar wird. Im Englischen nennt man solche Funktionen "curried functions", nach einem der Erfinder dieser Idee, Haskell Curry.
Hier ist eine Funktion, die zwei Zahlen addiert:
Func<int, int, int> add = delegate (int x, int y) { return x + y; };
Diese Funktion benötigt zur Ausführung zwei Parameter und sie kann nicht mit nur einem Parameter aufgerufen werden. Allerdings kann die Funktion alternativ auch wie folgt geschrieben werden:
Func<int, Func<int, int>> curriedAdd = delegate(int x) { return delegate(int y) { return add(x, y); }; };
Teilweise Anwendung
In dieser Implementation nimmt die äußere Funktion zunächst nur den ersten Parameter entgegen. Sie gibt dann eine neue Funktion zurück. Diese empfängt dann, eventuell auch erst zu einem späteren Zeitpunkt, den zweiten Parameter und ruft schließlich die ursprüngliche Funktion auf, wenn beide Werte vorliegen.
Ein interessanter Aspekt ist, dass der erste übergebene Parameter zwischengespeichert wird, solange der zweite noch nicht vorliegt. Dies geschieht automatisch mithilfe einer Closure. Der Compiler erkennt, dass der Wert x letztlich für die Ausführung der geschachtelten Funktion benötigt wird und speichert ihn deshalb. Technisch geschieht dies durch die Erzeugung einer Klasse, in der ein Feld für x eingebaut wird. In dieser Klasse wird auch die geschachtelte anonyme Methode abgelegt, so dass diese Zugriff auf den Wert x hat.
Ein "normaler" Aufruf an diese Funktion mit beiden Parametern sieht nun in C# etwas ungewöhnlich aus:
Console.WriteLine(curriedAdd(15)(5));
Gleichzeitig steht allerdings mit diesem Format der Funktion auch Partial Application zur Verfügung, um wie beschrieben eine spezifischere Variante der Funktion zu erzeugen.
var add5 = curriedAdd(5); Console.WriteLine(add5(27)); Console.WriteLine(add5(37));
Der Deutlichkeit halber verwendeten die bisherigen Beispiele die Syntax der anonymen Methoden, die bereits seit C# 2.0 verfügbar ist. Mit Lambda-Ausdrücken kann aber dasselbe erreicht werden, wenn auch die Schachtelung und damit die Wirkung der Closure weniger offensichtlich ist.
Func<int, int, int> add = (x, y) => x + y; Console.WriteLine(add(10, 20)); Func<int, Func<int, int>> curriedAdd = x => y => x + y; Console.WriteLine(curriedAdd(15)(5)); var add5 = curriedAdd(5); Console.WriteLine(add5(27)); Console.WriteLine(add5(32));
Auch das Currying einer Funktion lässt sich automatisieren. Hier ist die Hilfsfunktion Curry:
static Func<T1, Func<T2, T3>> Curry<T1, T2, T3>(Func<T1, T2, T3> func) { return par1 => par2 => func(par1, par2); }
Sie erkennen das Format wieder, das der Lambda-Ausdruck annimmt. Analog zur manuellen Vorgehensweise wird automatisch eine neue Funktion erzeugt, die einzelne Parameter nacheinander aufnimmt und letztlich die originale Funktion aufruft. Natürlich ist dies auch mit längeren Parameterlisten möglich:
static Func<T1, Func<T2, Func<T3, Func<T4, T5>>> Curry<T1, T2, T3, T4, T5>(Func<T1, T2, T3, T4, T5> func) { return par1 => par2 => par3 => par4 => func(par1, par2, par3, par4); }
Die Anwendung der Hilfsfunktion ist denkbar einfach:
Func<int, int, int> add = (x, y) => x + y; var addCurriedAuto = Curry(add); var add10 = addCurriedAuto(10); Console.WriteLine(add10(32));
In C# sind die beiden Schritte Currying und Partial Application offensichtlich getrennt. Manche Autoren, die über diese Themen schreiben, gehen in ihren Beschreibungen davon aus, dass Partial Application für sich allein steht, und oft wird Currying gar nicht erwähnt. Dies geht darauf zurück, dass in manchen Programmiersprachen, besonders solchen aus der funktionalen Welt, Funktionen automatisch in einem Format erzeugt werden, das Partial Application zulässt. Dies gilt zum Beispiel sogar für die .NET-Programmiersprache F# und ist somit nicht auf bestimmte Plattformen beschränkt.
Mithilfe einer anders strukturierten Hilfsfunktion ist es auch in C# möglich, Partial Application ohne vorheriges Currying zu betreiben:
public static Func<T2, TR> Apply<T1, T2, TR>( Func<T1, T2, TR> function, T1 arg) { return arg2 => function(arg, arg2); } public static Func<T2, T3, TR> Apply<T1, T2, T3, TR>( Func<T1, T2, T3, TR> function, T1 arg) { return (arg2, arg3) => function(arg, arg2, arg3); } public static Func<T3, TR> Apply<T1, T2, T3, TR>( Func<T1, T2, T3, TR> function, T1 arg, T2 arg2) { return arg3 => function(arg, arg2, arg3); } ...
Diese Funktion Apply nimmt eine Funktion und eine unvollständige Parameterliste entgegen und erzeugt wiederum eine neue Funktion. Sie kann wie folgt verwendet werden:
var add5 = Functional.Apply(add, 5);
Zu dem Beispiel der Funktion Filter sei noch eine Besonderheit erklärt, die zuvor erwähnt wurde: Dabei handelte es sich um eine generische Funktion und die Verwendung von Partial Application ist damit noch einen Schritt komplizierter. Es läge nahe, einen spezifischen Filter etwa so zu erzeugen:
var curriedFilter = Curry(Filter); var someFilter = curriedFilter(x => x > 5); var otherFilter = Apply(Filter, x => x > 10);
Der Compiler lässt dies allerdings nicht zu, da die Typherleitung in C# nicht aus der Verwendung im Lambda-Ausdruck allein versteht, dass der grundlegende Datentyp hier ein int ist. Filter ist eine generische Funktion und kann mit verschiedenen Typen verwendet werden. Auch mit dieser Syntax ist der Compiler leider nicht zufrieden:
var curriedFilter = Curry(Filter<int>);
Das Problem lässt sich in diesem Fall nur umgehen, indem eine weitere Hilfsfunktion erzeugt wird, die eine typisierte Variante der Funktion Filter abruft.
public static Func<Predicate<T>, IEnumerable<T>, IEnumerable<T>> FilterDelegate<T>( ) { return Filter<T>; } ... var curriedFilter = Curry(FilterDelegate<int>()); var someFilter = curriedFilter(x => x > 5); var otherFilter = Apply(FilterDelegate<int>(), x => x > 10);
Sie sehen also, dass die Mechanismen zur funktionalen Funktionskonstruktion durchaus in C# verwendbar sind, dass aber gleichzeitig ein gewisser syntaktischer Aufwand betrieben werden muss, um den Compiler zufriedenzustellen. Diese Form der Modularisierung auf der Funktionsebene ist mit anderen Mitteln in C# allerdings nicht möglich und Refaktorisierungen bringen entsprechend meist Änderungen auf der Klassenebene mit sich. Trotz der relativ komplexen Syntax bietet der funktionale Ansatz den Vorteil, direkt innerhalb anderer Funktionen und Methoden nutzbar zu sein.
Monads
Die Idee der Monads ist besonders deutlich in der funktionalen Programmiersprache Haskell zu beobachten. Diese Sprache unterstützt Typklassen, die leider in .NET nicht existieren. Typklassen beschreiben Verhaltensweisen, die Typen gemeinsam haben können. Im Gegensatz zu Interfaces in objektorientierten Sprachen können Typklassen auch Verhalten implementieren, aber sie sind hierarchisch unabhängig und somit auch nicht mit Basisklassen vergleichbar. Hier ist etwa eine Typklasse in Haskell, die Vergleichbarkeit beschreibt:
class MyEq a where isEqual :: a -> a -> Bool isEqual x y = not (isNotEqual x y) isNotEqual :: a -> a -> Bool isNotEqual x y = not (isEqual x y)
Sie sehen, dass diese Typklasse zwei Funktionen isEqual und isNotEqual beschreibt, aber gleichzeitig auch grundlegende Implementationen von beiden bietet, die aufeinander basieren. Wenn ein Typ diese Typklasse implementiert, braucht er nur eine von beiden Funktionen selbst mitzubringen, da die andere bereits automatisch als Kehrwert berechnet wird.
Interessant ist, dass Typklassen in Haskell auch von der Typherleitung des Compilers unterstützt werden. In .NET gibt es das bekannte Problem, dass generische Datentypen nicht einfache arithmetische Operationen wie Addition verwenden können, weil es keine Verallgemeinerung dieser Operationen im Typsystem gibt. Mit anderen Worten: viele verschiedene Typen in .NET könnten die notwendigen Operatoren implementieren, um Addition zu betreiben, aber es gibt keine Möglichkeit, generisch anzugeben, dass der Typ T Addition unterstützen muss. In Haskell gibt es diese Möglichkeit durch Typklassen.
Ein Monad in Haskell ist eine Typklasse. Die genaue Definition soll hier nicht gezeigt werden, sie ist für C# nicht relevant. Es ist aber wichtig zu verstehen, dass Monads in C# schwierig zu definieren sind, weil es eben keine Typklassen in .NET gibt. Manche Programmierer definieren Monads als Interfaces, aber damit fehlt der Mechanismus der Basisimplementation. Andere definieren Monads als Basisklassen, aber damit muss jede Implementation von der Basisklasse abgeleitet werden. Unter Umständen können beide diese Ansätze legitim sein, aber Sie sollten in Erinnerung behalten, dass es sich um Behelfsmechanismen handelt.
Ein Monad beherrscht grundsätzlich zwei Funktionen: er kann einen Wert eines inneren Typs entgegennehmen, und er kann eine "Bindung" erzeugen, durch die mithilfe einer Funktion ein neuer Monad generiert wird. Wenn Sie mögen, können Sie sich folgende generische Klasse vorstellen:
class M<T> { public M(T v) {} M<T> Bind(Func<T, M<T>> g) {} }
Analog zur Typklasse in Haskell legt diese Basis nur fest, was das Minimum an Funktionalität ist, dass ein Typ bieten soll, der als Monad betrachtet werden darf. Darüber hinaus kann beliebig viel zusätzlich geboten werden und die Implementation darf auch semantisch abweichen. Zum Beispiel muss die Funktion, die den Wert des inneren Typs annimmt, kein Klassenkonstruktor sein. Bind muss auch nicht Bind heißen – jeder andere Name funktioniert stattdessen.
Nichts tun mit Monads
Um die Sache etwas praktischer zu gestalten, sehen Sie hier eine vollständige Implementation des NopMonad, eines monadischen Typs, der absolut nichts tut, aber die Regeln der Monads einhält.
public class NopMonad<T> { public T Value { get; } public NopMonad(T value) { this.Value = value; } public NopMonad<T> Bind(Func<T, NopMonad<T>> g) { return g(Value); } }
Obwohl der NopMonad selbst keine Funktion hat, kann er verwendet werden, um eine Kette von Operationen zu kapseln. In diesem Code wird eine Berechnung mit mehreren Schritten durchgeführt:
var m1 = new NopMonad<int>(10); var m2 = m1. Bind(i => new NopMonad<int>(i * 2)). Bind(i => new NopMonad<int>(i + 5)); Console.WriteLine(m2.Value);
In der Berechnung wird jeder Schritt wiederum in einer Instanz des Monad gekapselt. Der Monad selbst führt keine zusätzlichen Operationen aus, aber er könnte dies tun. In der monadischen Kette besteht die Möglichkeit, abseits der eigentlichen Berechnung weitere Nebenbelange zu beachten. Solche Nebenbelange können unterschiedliche Formen annehmen.
Vielleicht geht's weiter, vielleicht auch nicht...
Ein Beispiel für einen Nebenbelang ist eine Kette von Auswertungen, die unter Umständen frühzeitig beendet werden muss, wenn eine Auswertung fehlschlägt. In C# gibt es unglücklicherweise das Konzept von null, um "abwesende" Objektinstanzen darzustellen. Im funktionalen Umfeld gibt es diese null-Idee gewöhnlich nicht, und abwesende Werte werden stattdessen mittels Kapselung dargestellt. Angelehnt an die Benennung dieses Kapselungstyps in Haskell lässt sich in C# folgender Typ Maybe<T> erzeugen:
public class Maybe<T> { public static readonly Maybe<T> Empty = new Maybe<T>( ); private readonly T val; public T Value { get { return val; } } private readonly bool isEmpty; public bool IsEmpty { get { return isEmpty; } } public bool HasValue { get { return !isEmpty; } } public Maybe(T val) { this.val = val; } private Maybe( ) { this.isEmpty = true; } }
Der Typ kann ergänzt werden durch einige Extension Methods, mit deren Hilfe er sich einfach erzeugen und mit Klassentypen verwenden lässt:
public static class MaybeHelpers { public static Maybe<T> ToMaybe<T>(this T val) { return new Maybe<T>(val); } public static Maybe<T> ToNotNullMaybe<T>(this T val) where T : class { return val != null ? val.ToMaybe( ) : Maybe<T>.Empty; } }
Auf dieser Ebene kann nun der Typ Maybe<T> manuell verwendet werden, um einen anderen Wert zu kapseln und festzuhalten, ob ein gekapselter Wert tatsächlich existiert oder nicht. Die Kapselung erinnert sofort an einen Monad, aber was ist mit der Funktion Bind? Hier ist eine mögliche Implementation:
public Maybe<R> Bind<R>(Func<T, Maybe<R>> g) { return IsEmpty ? Maybe<R>.Empty : g(Value); }
Verglichen mit der Bind-Funktion im NopMonad gibt es genau einen Unterschied: falls die aktuelle Instanz empty sein sollte, wird die verkettete Funktion gar nicht aufgerufen.
Betrachten Sie nun die folgende Funktion. Diese erhält als Parameter eine Datenstruktur, die einen Binärbaum darstellt, und hat die fiktive Aufgabe, daraus ein bestimmtes Element zu extrahieren:
static string GetThirdLeftChild(FCSColl::UnbalancedBinaryTree<string> tree) { if (tree != null) { if (tree.Left != null) { if (tree.Left.Left != null) { if (tree.Left.Left.Left != null) { return tree.Left.Left.Left.Value; } else { return "No such child"; } } else { return "No such child"; } } else { return "No such child"; } } else { return "No such child"; } }
Offensichtlich ist diese Implementation wenig elegant und müsste für jeden anderen Anwendungsfall wieder neu strukturiert werden. Mithilfe des Typs Maybe<T> ließe sich jedes return "No such child"; durch ein return Maybe<string>.Empty; ersetzen, aber die Struktur der geschachtelten if-Ausdrücke wäre dieselbe. Auf monadischer Basis kann die Operation nun allerdings auch so geschrieben werden:
static Maybe<FCSColl::UnbalancedBinaryTree<string>> GetMonadicThirdLeftChild(FCSColl::UnbalancedBinaryTree<string> tree) { return tree.ToNotNullMaybe( ). Bind(t => t.Left.ToNotNullMaybe( )). Bind(t => t.Left.ToNotNullMaybe( )). Bind(t => t.Left.ToNotNullMaybe( )); }
Analog zum Beispiel der Berechnung mit dem NopMonad wird auch hier jeder Schritt der Berechnungskette, also die Navigation in den Left-Ast des Baumes, separat an Bind übergeben, und die Ergebnisse wiederum in neue Monad-Instanzen gekapselt. So wird aus der umständlichen, geschachtelten und sehr spezifischen Struktur eine simple Verkettung einzelner Operationen. Dies ist möglich, weil der Nebenbelang vom Monad gehandhabt wird – der Algorithmus selbst muss nun nicht mehr für jeden Schritt einzeln auswerten, ob er durchgeführt werden kann oder nicht.
Zurück zu Nebeneffekten
Wie bereits eingangs einmal erwähnt, sieht die Idee der puren Funktion in der funktionalen Programmierung vor, dass diese ohne Nebeneffekte arbeiten soll. Wenn Sie sich vorstellen, ein Programm komplett nach diesem Paradigma zu implementieren, kommen Sie schnell zu dem Schluss, dass es gewisse Grenzen dafür gibt. Wie sollen Sie jemals etwas auf dem Bildschirm anzeigen oder auf der Festplatte speichern, wenn Sie keine Nebeneffekte auslösen dürfen? Auch in funktionalen Umgebungen muss es immer Nebeneffekte geben, aber diese sollen koordiniert und kontrolliert werden. In Haskell dient zum Beispiel ein bestimmter Monad dazu, Ausgaben (I/O) zu verwalten.
Dies ist der Monad Logger<T>, nebst Hilfsfunktionen, der Textausgaben auf die Konsole als Nebenbelang einer Berechnungskette verarbeiten kann:
public class Logger<T> { private readonly FCSColl::List<string> outputLines; private readonly T val; public T Value { get { return val; } } public Logger(T val, FCSColl::List<string> outputLines) { this.val = val; this.outputLines = outputLines; } public Logger(T val, string message) : this(val, new FCSColl::List<string>(message)) { } public Logger(T val) : this(val, FCSColl::List<string>.Empty) { } public string LogOutput( ) { var builder = new StringBuilder( ); foreach (string outputLine in outputLines) builder.AppendLine(outputLine); return builder.ToString( ); } public Logger<R> Bind<R>(Func<T, Logger<R>> g) { var r = g(val); return new Logger<R>(r.Value, outputLines.Append(r.outputLines)); } } public static class LoggerHelpers { public static Logger<T> ToLogger<T>(this T val) { return new Logger<T>(val); } public static Logger<T> ToLogger<T>(this T val, string message) { return new Logger<T>(val, message); } public static Logger<T> ToLogger<T>(this T val, string format, params object[] args) { return new Logger<T>(val, String.Format(format, args)); } }
In folgendem Beispielprogramm wird der Monad verwendet, um Informationen zu einer mehrstufigen Auswertung über eine Liste zu erzeugen:
class MainClass { public static void Main(string[] args) { var orders = new List<Order> { new Order{ Date =new DateTime(2010,6,3), Value = 29.9m}, new Order{ Date =new DateTime(2010,6,3), Value = 18.6m}, new Order{ Date =new DateTime(2010,6,4), Value = 119.99m}, new Order{ Date =new DateTime(2010,7,1), Value = 3.99m}, new Order{ Date =new DateTime(2010,7,2), Value = 47.62m}, new Order{ Date =new DateTime(2010,7,3), Value = 99.99m} }; var average = orders. ToLogger("Starting with a list of {0} orders", orders.Count()). Bind(l => Functional.Filter( o => o.Date >= new DateTime(2010, 7, 1), l). ToLogger("Got list with {0} items, filtering...", l.Count())). Bind(l => l.Average(o => o.Value). ToLogger("Calculating average for list with remaining {0} items...", l.Count())); Console.WriteLine("Result: " + average.Value); Console.WriteLine("-------- Log Output:"); Console.Write(average.LogOutput( )); } } public class Order { public DateTime Date { get; set; } public decimal Value { get; set; } }
Wie zuvor werden im Code die einzelnen algorithmischen Schritte durch Aufrufe an Bind dargestellt. Bei der Kapselung der Ergebnisse wird allerdings die Funktionalität des Monads verwendet, um zusätzliche Informationen in Form der Log-Ausgaben abzulegen. Der Nebeneffekt der Konsolenausgabe wird somit vom Monad gekapselt und kann koordiniert – in diesem Fall verzögert – werden – ob und wann diese Ausgabe tatsächlich ausgeführt wird, ist vom Berechnungsalgorithmus unabhängig.
Zum Schluss
Die Betrachtungen zu Monads sind damit noch nicht am Ende. Es gibt viele weitere Möglichkeiten, die Patterns einfacher anwendbar zu machen. Als Beispiel sei erwähnt, dass überladene Operatoren verwendet werden können, um die Aufrufe an Bind kompakter zu gestalten:
public static Maybe<T> operator &(Maybe<T> x, Func<T, Maybe<T>> g) { return x.Bind(g); } ... return tree.ToNotNullMaybe( ) & (t => t.Left.ToNotNullMaybe( )) & (t => t.Left.ToNotNullMaybe( )) & (t => t.Left.ToNotNullMaybe( ));
Auch in LINQ finden Sie monadische Elemente, und einer der vorhandenen Overloads von SelectMany sollte Ihnen bekannt vorkommen:
public static IEnumerable<TR> SelectMany<TS, TR>( this IEnumerable<TS> source, Func<TS, IEnumerable<TR>> selector) {}
Es würde den Rahmen dieses Artikels sprengen, weiter ins Detail zu gehen. Wenn Sie das Thema interessiert, gehen Sie diesen Ansätzen selbst weiter nach!