In der Diskussion um Null als Rückgabewert gab es ein Codeschnippsel, mit dem Thomas Bandt zeigen wollte, dass der TryGetUserById(…)-Vorschlag nicht zwangsläufig zu intuitivem Code führt. Ich formuliere das Beispiel hier mal etwas knapper:
User tmpUser;
bool isAuth = repo.TryGetUserById("987", out tmpUser) &&
sec.CheckUser("somepassword", tmpUser);
Als Problem sieht Thomas – durchaus zurecht – die Abhängigkeit von CheckUser() von einem Seiteneffekt. Die globale Variable tmpUser muss vor Aufruf gesetzt sein, was innerhalb des logischen Ausdrucks nicht ganz so offensichtlich ist.
Etwas deutlicher wäre die Abhängigkeit der Reihenfolge beider Methodenaufrufe, wenn der Ausdruck traditioneller als Schachtelung formuliert wäre:
bool isAuth;
User tmpUser;
if (repo.TryGetUserById("987", out tmpUser))
isAuth = sec.CheckUser("somepassword", tmpUser);
else
isAuth = false;
Doch eigentlich sind Schachtelungen ja “böse” ;-) Sie erhöhen die (zyklomatische) Komplexität des Codes. Also vielleicht lieber so:
User tmpUser;
bool isAuth = repo.TryGetUserById("987", out tmpUser);
isAuth = isAuth && sec.CheckUser("somepassword", tmpUser);
Das mag besser lesbar sein – doch das Grundproblem ist eigentlich nicht gelöst. Das besteht in der Abhängigkeit selbst, also letztlich in der zu beiden Methoden globalen Variablen tmpUser. Die ist nötig, weil Thomas keine Exception werfen möchte, falls es den angeforderten User nicht gibt. Wäre das anders, könnte er nämlich auch ohne diese Variable formulieren:
bool isAuth = sec.CheckUser(“somepassword”,
repo.GetUserById(“987”));
Das ist zwar wieder geschachtelter Code, doch irgendwie sind wir sowas ja gewohnt ;-)
Get into the flow
So richtig cool finde ich das aber alles noch nicht. Deshalb schlage ich mal eine weitere Alternative vor. Wäre es nicht am allerschönsten, wenn wir es so schreiben könnten:
var isAuth = repo.GetUserById(“987”) |> sec.CheckUser(“somepassword”);
Das ist natürlich Pseudocode. Aber schön wärs, oder? GetUserById() würde immer noch keine Exception werfen und trotzdem (!) würde das Ganze immer funktionieren.
In F# geht das. Da gibt es so einen Pipe-Operator |> und noch etwas anderes, das nötig ist, einen Option-Typ. Aber in C# sitzen wir erstmal auf dem Trockenen :-(
Zum Glück können wir daran etwas tun. Wir können mit etwas Vorarbeit auch in C# so einen “Flow” von Anweisungen schreiben. Hier meine C#-Version:
bool isAuth = repo.GetUserById("987")
.Continue(user => sec.CheckUser("somepassword", user))
.WithDefault(false);
Liest sich doch gar nicht schlecht, oder? Es soll ein User geladen werden und danach soll geprüft werden, ob der User ein bestimmtes Passwort hat. Ist das so, dann ist das Ergebnis true, ansonsten false.
Die Sequenz der Verarbeitungsschritte in unserem Kopf findet sich im Code wieder – ohne dass wir eine temporäre Variable bräuchten. Naja, nicht ganz: denn der user-Parameter der Lambda-Funktion ist eine solche temporäre Variable. Doch die ist nicht global, sondern lokal. Es gibt also keine schwer zu überblickenden Abhängigkeiten.
Optionen als Zwischenwerte
Möglich ist so eine Formulierung, wenn als Resultat von GetUserById() kein User (oder gar Null), sondern ein sog. Option-Wert zurückgeliefert wird. Den gibt es in F# gratis; für C# können wir ihn so bauen:
public class Option<T>
{
public Option()
{
this.IsSome = false;
}
public Option(T some)
{
this.Some = some;
this.IsSome = true;
}
public T Some { get; private set; }
public bool IsSome { get; private set; }
public bool IsNone { get { return !IsSome; } }
…
}
Der Trick an Optionswerten ist, dass es immer eine Instanz gibt. Es geht also nicht um Objekt oder Null, sondern um Options-Objekt gefüllt oder nicht gefüllt. Wenn es gefüllt ist, dann liefert IsSome true, wenn nicht, dann liefert IsSome false bzw. IsNone true. Zugriff auf den Wert gibt die Some-Property.
var optInt = new Option<int>(42);
Console.WriteLine(“{0}, {1}”, optInt.IsSome, optInt.Some);
gibt aus:
true, 42
Mit so einem Typen kann GetUserById() nun umformuliert werden zu:
Option<User> GetUserById(string id) {…}
Die Methode liefert immer ein Option-Objekt zurück und die Umgebung muss prüfen, ob etwas drin steckt oder nicht:
var optUser = repo.GetUserById(“987”);
if (optUser.IsSome)
…
Das ist fast so wie eine Prüfung auf Null – aber besser. Denn mit einem Option-Objekt kann man immer weiterarbeiten.
Logik verstecken in einer Monade
Das Ziel ist immer noch der “Flow”, d.h. globale Variablen und Prüflogik auf unerwünschte Ergebnisse zu verstecken. Weil nun immer ein Option-Objekt vorliegt, können wir darauf immer eine Methode aufrufen
public class Option<T>
{
…
public IEnumerable<Option<TOutput>> Continue<TOutput>(Func<T, TOutput> processor)
{
return new[] {this}.Continue(processor);
}
}
und auch noch Erweiterungsmethoden in Anschlag bringen
public static class OptionExtensions
{
public static IEnumerable<Option<TOutput>> Continue<TInput, TOutput>(
this IEnumerable<Option<TInput>> values,
Func<TInput, TOutput> processor)
{
return values.Where(v => v.IsSome)
.Select(v => new Option<TOutput>(processor(v.Some)));
}
public static TInput WithDefault<TInput>(
this IEnumerable<Option<TInput>> values,
TInput defaultInCaseOfNone)
{
return (values.FirstOrDefault() ??
new Option<TInput>(defaultInCaseOfNone)).Some;
}
}
Ich denke, damit habe ich eine Monade definiert, d.h. ein “Dings”, mit dem sich quasi unsichtbar ein Kontext um mehrere Anweisungen wickeln lassen kann.
Dass ich dafür auf IEnumerable<> zurückgegriffen habe, ist lediglich meiner Faulheit geschuldet ;-) (Mit IEnumerable<> kriegen ist nämlich die Verkettung geschenkt, weil der Typ selbst schon eine Monade ist.) Ebenso die Methode Continue() der Option-Klasse. Hätte ich mir mehr Mühe gegeben, hätte ich statt IEnumerable<> eine spezielle Monadenklasse gebastelt. Das wäre sauberer gewesen.
Für den Punkt, den ich hier rüberbringen wollte, geht es aber auch so, würd ich sagen. Ich möchte einfach einen weiteren Weg aufzeigen, ohne Null leben zu können – und dadurch lesbaren Code zu bekommen. Die Verkettung könnte noch weiter gehen, falls mehr “Transformationsschritte” nötig wären, die immer wieder Output erzeugen, der Input für den nächsten sind. Die Leistung der Monade besteht darin, die Schritte abzubrechen, falls kein Ergebnis geliefert wird (der frühere Null-Fall). Die verbirgt die Logik der Prüfung, ob ein Zwischenergebnis vorliegt und es noch weitergehen soll.
Die C#-Mittel bleiben dabei immer etwas hinter denen von F# zurück. Doch mir scheint “das Denken in Option-Werten” durchaus den einen oder anderen Versuch wert. Wie gesagt, mit etwas mehr einmalig aufgewandter Zeit, ist das auch noch eleganter ;-)