Kapitel 4. Intelligent arbeiten, nicht hartmit funktionalem Code

Diese Arbeit wurde mithilfe von KI übersetzt. Wir freuen uns über dein Feedback und deine Kommentare: translation-feedback@oreilly.com

Alles, was ich bisher behandelt habe, ist FP, so wie es das C#-Team von Microsoft vorgesehen hat. Du findest diese Funktionen zusammen mit Beispielen auf der Microsoft-Website. In diesem Kapitel möchte ich jedoch ein bisschen kreativer mit C# werden.

Ich weiß nicht, wie es dir geht, aber ich bin gerne faul, oder zumindest mag ich es nicht, meine Zeit mit langweiligem Boilerplate-Code zu verschwenden. Eines der vielen wunderbaren Dinge an FP ist seine Prägnanz, verglichen mit imperativem Code.

In diesem Kapitel zeige ich dir, wie du die funktionalen Möglichkeiten noch weiter ausreizen kannst, als es die Standardversion von C# erlaubt. Außerdem lernst du, wie du einige der neueren funktionalen Funktionen von C# in älteren Versionen der Sprache implementieren kannst, damit du hoffentlich viel schneller mit deiner Arbeit weitermachen kannst.

In diesem Kapitel werden einige Kategorien von funktionalen Konzepten untersucht:

Funcs in Aufzählungen

Func Delegates werden scheinbar nicht so oft verwendet, aber sie sind unglaublich leistungsstarke Funktionen von C#. Ich zeige dir ein paar Möglichkeiten, wie du sie nutzen kannst, um die Möglichkeiten von C# zu erweitern. In diesem Fall fügen wir sie zu Enumerables hinzu und bearbeiten sie mit LINQ-Ausdrücken.

Funcs als Filter

Du kannst auch Func Delegierte als Filter verwenden - etwas, das sich zwischen dich und den eigentlichen Wert setzt, den du erreichen willst. Wenn du diese Prinzipien anwendest, kannst du ein paar nette Dinge im Code schreiben.

Benutzerdefinierte Aufzählungszeichen

Ich habe bereits über die Schnittstelle IEnumerable gesprochen und wie cool sie ist. Aber wusstest du, dass du sie aufbrechen und dein eigenes, individuelles Verhalten implementieren kannst? Ich zeige dir, wie das geht.

All das und noch viele andere Konzepte!

Es ist Zeit, func-y zu werden

Die Func Delegatentypen sind Funktionen, die als Variablen gespeichert werden. Du legst fest, welche Parameter sie annehmen und was sie zurückgeben, und rufst sie wie jede andere Funktion auf. Hier ist ein kurzes Beispiel:

private readonly Func<Person, DateTime, string> SayHello =
    (Person p, DateTime today) => today + " : " + "Hello " + p.Name;

Der letzte generische Typ in der Liste zwischen den beiden spitzen Klammern ist der Rückgabewert; alle vorherigen Typen sind die Parameter. Dieses Beispiel nimmt zwei String-Parameter entgegen und gibt einen String zurück.

Du wirst von nun an sehr viele Func Delegierte sehen, also vergewissere dich bitte, dass du dich mit ihnen wohlfühlst, bevor du weiterliest.

Funktionen in Enumerables

Ich habe schon viele Beispiele für Funcals Parameter für Funktionen gesehen, aber ich bin mir nicht sicher, ob viele Entwickler wissen, dass man sie in eine Aufzählung packen und damit interessante Verhaltensweisen erzeugen kann.

Die erste ist die offensichtliche: Setze sie in ein Array, um dieselben Daten mehrmals zu bearbeiten:

private IEnumerable<Func<Employee, string>> descriptors = new []
{
    x => "First Name = " + x.firstName,
    x => "Last Name = " + x.lastName,
    x => "MiddleNames = string.Join(" ", x.MiddleNames)
}

public string DescribeEmployee(Employee emp) =>
   string.Join(Environment.NewLine, descriptors.Select(x => x(emp)));

Mit dieser Technik können wir eine einzige ursprüngliche Datenquelle (hier ein Employee Objekt) verwenden und daraus mehrere Datensätze desselben Typs generieren. In diesem Fall aggregieren wir mit der eingebauten .NET-Methode string.Join, um dem Endnutzer eine einzige, einheitliche Zeichenfolge zu präsentieren.

Dieser Ansatz hat einige Vorteile gegenüber einer einfachen StringBuilder. Erstens kann das Array dynamisch zusammengesetzt werden. Wir könnten mehrere Regeln für jede Eigenschaft und deren Darstellung haben, die je nach benutzerdefinierter Logik aus einer Reihe lokaler Variablen ausgewählt werden können.

Zweitens handelt es sich um eine Aufzählungsdatei. Indem wir sie auf diese Weise definieren, nutzen wir eine Funktion von Aufzählungsdateien, die "Lazy Evaluation" genannt wird (eingeführt in Kapitel 2). Das Besondere an Enumerables ist, dass sie keine Arrays sind; sie sind nicht einmal Daten. Sie sind nur Zeiger auf etwas, das uns sagt, wie wir die Daten extrahieren können. Es kann gut sein - und das ist in der Regel auch der Fall -, dass die Quelle hinter der Aufzählung ein einfaches Array ist, aber nicht unbedingt. Eine Enumerable erfordert eine Funktion, die bei jedem Zugriff auf das nächste Element über eine foreach Schleife ausgeführt wird. Enumerables wurden entwickelt, um sich erst im allerletzten Moment in tatsächliche Daten umzuwandeln - typischerweise beim Start einer foreach Schleifeniteration. In den meisten Fällen spielt das keine Rolle, wenn die Aufzählung durch ein Array im Speicher gespeist wird, aber wenn eine teure Funktion oder eine Abfrage in einem externen System die Aufzählung antreibt, kann "Lazy Loading" unglaublich nützlich sein, um unnötige Arbeit zu vermeiden.

Die Elemente einer Aufzählung werden nacheinander ausgewertet und erst dann, wenn sie an der Reihe sind, von dem Prozess, der die Aufzählung durchführt, verwendet zu werden. Wenn wir zum Beispiel die Funktion LINQ Any verwenden, um jedes Element in einer Aufzählung auszuwerten, Any wird die Aufzählung beendet, sobald ein Element gefunden wird, das den angegebenen Kriterien entspricht.

Und schließlich ist diese Technik aus Sicht der Wartung einfacher zu handhaben. Das Hinzufügen einer neuen Zeile zum Endergebnis ist so einfach wie das Hinzufügen eines neuen Elements zum Array. Dieser Ansatz wirkt auch als Hemmschuh für künftige Programmierer, da er es ihnen erschwert, zu viel komplexe Logik dort unterzubringen, wo sie nicht hingehört.

Ein super-einfacher Validator

Stellen wir uns eine schnelle Validierungsfunktion vor, die in der Regel wie folgt aussieht:

public bool IsPasswordValid(string password)
{
    if(password.Length <= 6)
        return false;

    if(password.Length > 20)
        return false;

    if(!password.Any(x => Char.IsLower(x)))
        return false;

    if(!password.Any(x => Char.IsUpper(x)))
        return false;

    if(!password.Any(x => Char.IsSymbol(x)))
        return false;

    if(password.Contains("Justin", StringComparison.OrdinalIgnoreCase)
        && password.Contains("Bieber", StringComparison.OrdinalIgnoreCase))
            return false;

    return true;
}

Nun, zunächst einmal ist das eine Menge Code für ein eigentlich recht einfaches Regelwerk. Der imperative Ansatz zwingt dich dazu, einen ganzen Haufen sich wiederholender Standardformulierungen zu schreiben. Wenn wir noch eine weitere Regel hinzufügen wollen, sind das potenziell vier neue Codezeilen, obwohl eigentlich nur eine für uns interessant ist.

Wenn es doch nur einen Weg gäbe, diesen Code in ein paar einfache Zeilen zusammenzufassen. Nun, da du so nett gefragt hast, hier ist er:

public bool IsPasswordValid(string password) =>
    new Func<string, bool>[]
    {
        x => x.Length > 6,
        x => x.Length <= 20,
        x => x.Any(y => Char.IsLower(y)),
        x => x.Any(y => Char.IsUpper(y)),
        x => x.Any(y => Char.IsSymbol(y)),
        x => !x.Contains("Justin", StringComparison.OrdinalIgnoreCase)
            && !x.Contains("Bieber", StringComparison.OrdinalIgnoreCase)
    }.All(f => f(password));

Jetzt ist es nicht mehr so lange hin, oder? Was haben wir hier gemacht? Wir haben alle Regeln in einem Array von Funczusammengefasst, das aus string eine boolmacht, d.h. eine einzelne Validierungsregel überprüft. Wir verwenden eine LINQ-Anweisung: .All(). Der Zweck dieser Funktion ist es, den Lambda-Ausdruck, den wir ihr geben, gegen alle Elemente des Arrays auszuwerten, an das sie angehängt ist. Wenn ein einziges dieser Elemente false zurückgibt, wird der Prozess vorzeitig beendet und false wird von All() zurückgegeben (wie bereits erwähnt, wird auf die nachfolgenden Werte nicht zugegriffen, so dass wir durch die faule Auswertung Zeit sparen, indem wir sie nicht auswerten). Wenn jedes einzelne der Elemente true zurückgibt, gibt All() auch true zurück.

Wir haben das erste Codebeispiel praktisch neu erstellt, aber der Standardcode, den wir schreiben mussten -if Anweisungen und frühe Rückgaben - ist jetzt in der Struktur enthalten.

Das hat auch den Vorteil, dass die Codestruktur wieder leicht zu pflegen ist. Wenn wir wollten, könnten wir sie sogar zu einer Erweiterungsmethode verallgemeinern. Ich mache das oft:

public static bool IsValid<T>(this T @this, params Func<T,bool>[] rules) =>
    rules.All(x => x(@this));

Das reduziert die Größe des Passwort-Validators noch weiter und gibt uns eine praktische, generische Struktur, die wir an anderer Stelle verwenden können:

public bool IsPasswordValid(string password) =>
    password.IsValid(
        x => x.Length > 6,
        x => x.Length <= 20,
        x => x.Any(y => Char.IsLower(y)),
        x => x.Any(y => Char.IsUpper(y)),
        x => x.Any(y => Char.IsSymbol(y)),
        x => !x.Contains("Justin", StringComparison.OrdinalIgnoreCase)
            && !x.Contains("Bieber", StringComparison.OrdinalIgnoreCase)
    )

An dieser Stelle hoffe ich, dass du es dir noch einmal überlegst, ob du jemals wieder etwas so langes und unhandliches wie das erste Validierungsbeispiel schreibst.

Ich denke, dass eine IsValid Prüfung einfacher zu lesen und zu pflegen ist, aber wenn wir ein Stück Code wollen, das viel mehr mit dem ursprünglichen Codebeispiel übereinstimmt, können wir eine neue Erweiterungsmethode erstellen, indem wir Any() anstelle von All() verwenden:

public static bool IsInvalid<T>(
    this T @this,
    params Func<string,bool>[] rules) =>

Das bedeutet, dass die boolesche Logik jedes Array-Elements umgekehrt werden kann, wie es ursprünglich der Fall war:

public bool IsPasswordValid(string password) =>
    !password.IsInvalid(
        x => x.Length <= 6,
        x => x.Length > 20,
        x => !x.Any(y => Char.IsLower(y)),
        x => !x.Any(y => Char.IsUpper(y)),
        x => !x.Any(y => Char.IsSymbol(y)),
        x => x.Contains("Justin", StringComparison.OrdinalIgnoreCase)
            && x.Contains("Bieber", StringComparison.OrdinalIgnoreCase)
    )

Wenn wir beide Funktionen, IsValid() und IsInvalid(), beibehalten wollen, weil jede ihren Platz in unserer Codebasis hat, lohnt es sich wahrscheinlich, etwas Programmieraufwand zu sparen und eine potenzielle Wartungsaufgabe in der Zukunft zu vermeiden, indem wir einfach eine Funktion in der anderen referenzieren:

public static bool IsValid<T>(this T @this, params Func<T,bool>[] rules) =>
    rules.All(x => x(@this));

public static bool IsInvalid<T>(this T @this, params Func<T,bool>[] rules) =>
    !@this.IsValid(rules);

Nutze sie weise, mein junger, funktionaler Padawan-Schüler.

Pattern Matching für alte Versionen von C#

Der Musterabgleich ist neben den Satztypen eines der besten Features von C# der letzten Jahre, aber er ist nur in den neuesten .NET-Versionen verfügbar. (In Kapitel 3 erfährst du mehr über das native Pattern Matching in C# 7 und höher).

Gibt es eine Möglichkeit, den Musterabgleich zu ermöglichen, ohne dass du auf eine neuere Version von C# upgraden musst? Die gibt es auf jeden Fall. Sie ist bei weitem nicht so elegant wie die native Syntax in C# 8, bietet aber einige der gleichen Vorteile.

In diesem Beispiel berechnen wir den Steuerbetrag, den jemand zahlen sollte, anhand einer stark vereinfachten Version der britischen Einkommenssteuerregeln. Beachte, dass diese wirklich viel einfacher sind als die echten. Ich möchte nicht, dass wir uns zu sehr in der Komplexität der Steuern verzetteln.

Die Regeln für die Anwendung sehen wie folgt aus:

  • Liegt das Jahreseinkommen unter oder bei £12.570, wird keine Steuer erhoben.

  • Liegt das Jahreseinkommen zwischen £12.571 und £50.270, musst du 20% Steuern zahlen.

  • Liegt das Jahreseinkommen zwischen £50.271 und £150.000, musst du 40% Steuern zahlen.

  • Wenn das Jahreseinkommen über 150.000 £ liegt, musst du 45 % Steuern zahlen.

Wenn wir das mit der Hand schreiben wollten (nicht funktional), würde es so aussehen:

decimal ApplyTax(decimal income)
{
    if (income <= 12570)
        return income;
    else if (income <=50270)
        return income * 0.8M;
    else if (income <= 150000)
        return income * 0.6M;
    else
        return income * 0.55M;
}

In C# 8 und höher wird dies mit switch expressions auf ein paar Zeilen komprimiert. Solange wir mindestens mit C# 7 (.NET Framework 4.7) arbeiten, können wir diese Art von Mustervergleichen erstellen:

var inputValue = 25000M;
var updatedValue = inputValue.Match(
    (x => x <= 12570, x => x),
    (x => x <= 50270, x => x * 0.8M),
    (x => x <= 150000, x => x * 0.6M)
).DefaultMatch(x => x * 0.55M);

Wir übergeben ein Array von Tupeln, das zwei Lambda-Ausdrücke enthält. Der erste bestimmt, ob die Eingabe mit dem aktuellen Muster übereinstimmt; der zweite ist die Umwandlung des Werts, die stattfindet, wenn das Muster übereinstimmt. Abschließend wird geprüft, ob das Standardmuster angewendet werden soll, weil keines der anderen Muster übereinstimmt.

Obwohl er nur einen Bruchteil der Länge des ursprünglichen Codebeispiels hat, enthält er die gleiche Funktionalität. Die übereinstimmenden Muster auf der linken Seite des Tupels sind einfach, aber sie können beliebig komplizierte Ausdrücke enthalten und sogar ganze Funktionen aufrufen, die detaillierte Kriterien enthalten.

Wie funktioniert das also? Dies ist eine extrem einfache Version, die die meisten der benötigten Funktionen bietet:

public static class ExtensionMethods
{
    public static TOutput Match<TInput, TOutput>(
        this TInput @this,
        params (Func<TInput, bool> IsMatch,
        Func<TInput, TOutput> Transform)[] matches)
    {
        var match = matches.FirstOrDefault(x => x.IsMatch(@this));
        var returnValue = match?.Transform(@this) ?? default;
        return returnValue;
    }
}

Wir verwenden die LINQ-Methode FirstOrDefault(), um zunächst durch die linken Funktionen zu iterieren, um eine zu finden, die true zurückgibt (d.h. eine mit den richtigen Kriterien), und dann die rechte Umwandlung Func aufzurufen, um den geänderten Wert zu erhalten.

Das ist gut, aber wenn keines der Muster übereinstimmt, sitzen wir in der Klemme. Höchstwahrscheinlich werden wir eine Null-Referenz-Ausnahme haben.

Um dies abzudecken, müssen wir eine Standardübereinstimmung erzwingen (das Äquivalent einer einfachen else Anweisung oder der _ Musterübereinstimmung in switch Ausdrücken). Die Antwort ist, dass die Funktion Match ein Platzhalterobjekt zurückgibt, das entweder einen umgewandelten Wert aus den Match Ausdrücken enthält oder den Default Muster-Lambda-Ausdruck ausführt. Die verbesserte Version sieht wie folgt aus:

public static MatchValueOrDefault<TInput, TOutput> Match<TInput, TOutput>(
  this TInput @this,
  params (Func<TInput, bool>,
  Func<TInput, TOutput>)[] predicates)
{
    var match = predicates.FirstOrDefault(x => x.Item1(@this));
    var returnValue = match?.Item2(@this);
    return new MatchValueOrDefault<TInput, TOutput>(returnValue, @this);
}

public class MatchValueOrDefault<TInput, TOutput>
{
    private readonly TOutput value;
    private readonly TInput originalValue;

    public MatchValueOrDefault(TOutput value, TInput originalValue)
    {
        this.value = value;
        this.originalValue = originalValue;
    }
}

public TOutput DefaultMatch(Func<TInput, TOutput> defaultMatch)
{
    if (EqualityComparer<TOutput>.Default.Equals(default, this.value))
    {
        return defaultMatch(this.originalValue);
    }
    else
    {
        return this.value;
    }
}

Dieser Ansatz ist im Vergleich zu dem, was in den neuesten Versionen von C# erreicht werden kann, stark eingeschränkt. Es findet kein Objekttyp-Matching statt, und die Syntax ist nicht so elegant, aber sie ist immer noch brauchbar und könnte eine Menge Boilerplate einsparen und gute Codestandards fördern.

In noch älteren Versionen von C# und, die keine Tupel enthalten, können wir die Verwendung von KeyValuePair<T,T> in Betracht ziehen, obwohl die Syntax alles andere als attraktiv ist. Was, du willst mir nicht aufs Wort glauben? Okay, dann mal los. Sag nicht, ich hätte dich nicht gewarnt...

Die Methode Extension() selbst ist in etwa gleich und braucht nur eine kleine Änderung, um KeyValuePair anstelle von Tupeln zu verwenden:

public static MatchValueOrDefault<TInput, TOutput> Match<TInput, TOutput>(
  this TInput @this,
  params KeyValuePair<Func<TInput, bool>, Func<TInput, TOutput>>[] predicates)
{
    var match = predicates.FirstOrDefault(x => x.Key(@this));
    var returnValue = match.Value(@this);
    return new MatchValueOrDefault<TInput, TOutput>(returnValue, @this);
}

Und jetzt kommt der hässliche Teil. Die Syntax für die Erstellung von KeyValuePair Objekten ist ziemlich furchtbar:

var inputValue = 25000M;
var updatedValue = inputValue.Match(
    new KeyValuePair<Func<decimal, bool>, Func<decimal, decimal>>(
        x => x <= 12570, x => x),
    new KeyValuePair<Func<decimal, bool>, Func<decimal, decimal>>(
        x => x <= 50270, x => x * 0.8M),
    new KeyValuePair<Func<decimal, bool>, Func<decimal, decimal>>(
        x => x <= 150000, x => x * 0.6M)
).DefaultMatch(x => x * 0.55M);

Wir können also auch in C# 4 eine Form des Pattern Matching haben, aber ich bin mir nicht sicher, wie viel wir dadurch gewinnen. Das musst du vielleicht selbst entscheiden. Wenigstens habe ich dir den Weg gezeigt.

Wörterbücher nützlicher machen

Funktionen müssen nicht nur verwendet werden, um eine Form von Daten in eine andere zu verwandeln. Wir können sie auch als Filter verwenden, als zusätzliche Ebenen, die zwischen dem Entwickler und der ursprünglichen Informationsquelle oder Funktionalität liegen. In diesem Abschnitt wird gezeigt, wie funktionale Filter eingesetzt werden können, um die Nutzung von Wörterbüchern zu verbessern.

Eines meiner absoluten Lieblingsobjekte in C# sind Wörterbücher. Richtig eingesetzt, können sie einen Haufen hässlichen, mit Boilerplates überladenen Code mit ein paar einfachen, eleganten, Array-ähnlichen Lookups reduzieren. Außerdem sind sie effizient, um Daten zu finden, sobald sie erstellt sind.

Wörterbücher haben jedoch ein Problem, das es oft notwendig macht, einen Haufen Kauderwelsch hinzuzufügen, der den ganzen Grund, warum sie so schön zu benutzen sind, zunichte macht. Betrachte das folgende Codebeispiel:

var doctorLookup = new []
{
    ( 1, "William Hartnell" ),
    ( 2, "Patrick Troughton" ),
    ( 3, "Jon Pertwee" ),
    ( 4, "Tom Baker" )
}.ToDictionary(x => x.Item1, x => x.Item2);

var fifthDoctorInfo = $"The 5th Doctor was played by {doctorLookup[5]}";

Was hat es mit diesem Code auf sich? Er verstößt gegen eine Codefunktion von Wörterbüchern, die ich unerklärlich finde: wenn du versuchst, einen Eintrag zu suchen, der nicht existiert,1 wird eine Ausnahme ausgelöst, die behandelt werden muss!

Der einzige sichere Weg, dies zu handhaben, ist eine der verschiedenen Techniken zu verwenden, die in C# verfügbar sind, um die verfügbaren Schlüssel zu überprüfen, bevor der String kompiliert wird, wie zum Beispiel so:

var doctorLookup = new []
{
    ( 1, "William Hartnell" ),
    ( 2, "Patrick Troughton" ),
    ( 3, "Jon Pertwee" ),
    ( 4, "Tom Baker" )
}.ToDictionary(x => x.Item1, x => x.Item2);

var fifthDoctorActor = doctorLookup.ContainsKey(5)
    ? doctorLookup[5]
    : "An Unknown Actor";

var fifthDoctorInfo = $"The 5th Doctor was played by {fifthDoctorActor}";

Alternativ bieten etwas neuere Versionen von C# eine TryGetValue() Funktion, die diesen Code ein wenig vereinfacht:

var fifthDoctorActor = doctorLookup.TryGetValue(5, out string value)
    ? value
    : "An Unknown Actor";

Können wir also FP-Techniken verwenden, um unseren Boilerplate-Code zu reduzieren und uns alle nützlichen Funktionen von Wörterbüchern zu geben, aber ohne die schreckliche Tendenz zu explodieren? Darauf kannst du wetten!

Zuerst brauchen wir eine schnelle Erweiterungsmethode:

public static class ExtensionMethods
{
    public static Func<TKey, TValue> ToLookup<TKey,TValue>(
      this IDictionary<TKey,TValue> @this)
    {
        return x => @this.TryGetValue(x, out TValue? value) ? value : default;
    }

    public static Func<TKey, TValue> ToLookup<TKey,TValue>(
      this IDictionary<TKey,TValue> @this,
      TValue defaultVal)
    {
        return x => @this.ContainsKey(x) ? @this[x] : defaultVal;
    }
}

Das erkläre ich dir gleich, aber zuerst sehen wir uns an, wie wir die Erweiterungsmethoden verwenden:

var doctorLookup = new []
{
    ( 1, "William Hartnell" ),
    ( 2, "Patrick Troughton" ),
    ( 3, "Jon Pertwee" ),
    ( 4, "Tom Baker" )
}.ToDictionary(x => x.Item1, x => x.Item2)
    .ToLookup("An Unknown Actor");

var fifthDoctorInfo = $"The 5th Doctor was played by {doctorLookup(5)}";
// output = "The 5th Doctor was played by An Unknown Actor"

Fällt dir der Unterschied auf? Wenn du genau hinsiehst, verwendet der Code jetzt Klammern anstelle von eckigen Array-/Wörterbuchklammern, um auf Werte aus dem Wörterbuch zuzugreifen. Das liegt daran, dass es sich technisch gesehen nicht mehr um ein Wörterbuch handelt! Es ist eine Funktion.

Wenn du dir die Erweiterungsmethoden ansiehst, geben sie Funktionen zurück, aber es sind Funktionen, die das ursprüngliche Dictionary Objekt so lange im Geltungsbereich behalten, wie sie existieren. Im Grunde sind sie wie eine Filterschicht, die zwischen Dictionary und dem Rest der Codebasis liegt. Die Funktionen entscheiden, ob die Verwendung von Dictionary sicher ist.

Das bedeutet, dass wir Dictionary verwenden können, aber die Ausnahme, die auftritt, wenn ein Schlüssel nicht gefunden wird, wird nicht mehr ausgelöst, und wir können entweder den Standardwert für den Typ (normalerweise null) zurückgeben oder unseren eigenen Standardwert angeben. Einfach.

Der einzige Nachteil dieser Methode ist, dass sie nicht länger eine Dictionary ist. Wir können sie nicht weiter verändern oder LINQ-Operationen mit ihr durchführen. In Situationen, in denen wir sicher sind, dass wir das nicht brauchen, können wir diese Methode verwenden.

Werte parsen

Eine weitere häufige Ursache für unübersichtlichen Code ist das Parsen von Werten aus string in andere Formulare. So etwas könnten wir für das Parsen eines hypothetischen Einstellungsobjekts verwenden, falls wir in .NET Framework arbeiten und die Funktionen von appsettings.json und IOption<T> nicht verfügbar sind:

public Settings GetSettings()
{
    var settings = new Settings();

    var retriesString = ConfigurationManager.AppSettings["NumberOfRetries"];
    var retriesHasValue = int.TryParse(retriesString, out var retriesInt);
    if(retriesHasValue)
        settings.NumberOfRetries = retriesInt;
    else
        settings.NumberOfRetries = 5;

    var pollingHrStr = ConfigurationManager.AppSettings["HourToStartPollingAt"];
    var pollingHourHasValue = int.TryParse(pollingHrStr, out var pollingHourInt);
    if(pollingHourHasValue)
        settings.HourToStartPollingAt = pollingHourInt;
    else
        settings.HourToStartPollingAt = 0;

    var alertEmailStr = ConfigurationManager.AppSettings["AlertEmailAddress"];
    if(string.IsNullOrWhiteSpace(alertEmailStr))
        settings.AlertEmailAddress = "test@thecompany.net";
    else
        settings.AlertEmailAddress = aea.ToString();

    var serverNameString = ConfigurationManager.AppSettings["ServerName"];
    if(string.IsNullOrWhiteSpace(serverNameString))
        settings.ServerName = "TestServer";
    else
        settings.ServerName = sn.ToString();

    return settings;
}

Das ist eine Menge Code, um etwas Einfaches zu tun, stimmt's? Eine Menge Kauderwelsch macht den Sinn des Codes nur für diejenigen sichtbar, die mit dieser Art von Vorgängen vertraut sind. Außerdem würden für jede neue Einstellung fünf oder sechs Zeilen neuer Code benötigt, wenn man sie hinzufügen würde. Das ist eine ziemliche Verschwendung.

Stattdessen können wir die Dinge etwas funktionaler angehen und die Struktur irgendwo verstecken, so dass nur die Absicht des Codes für uns sichtbar ist.

Wie immer gibt es auch hier eine Erweiterungsmethode, um das Geschäft zu erledigen:

public static class ExtensionMethods
{
    public static int ToIntOrDefault(this object @this, int defaultVal = 0) =>
        int.TryParse(@this?.ToString() ?? string.Empty, out var parsedValue)
            ? parsedValue
            : defaultVal;

    public static string ToStringOrDefault(
        this object @this,
        string defaultVal = "") =>
        string.IsNullOrWhiteSpace(@this?.ToString() ?? string.Empty)
            ? defaultVal
            : @this.ToString();
}

Damit entfällt der sich wiederholende Code aus dem ersten Beispiel und du kannst zu einem besser lesbaren, ergebnisorientierten Codebeispiel wie diesem übergehen:

public Settings GetSettings() =>
    new Settings
    {
        NumberOfRetries = ConfigurationManager.AppSettings["NumberOfRetries"]
            .ToIntOrDefault(5),
        HourToStartPollingAt =
            ConfigurationManager.AppSettings["HourToStartPollingAt"]
            .ToIntOrDefault(0),
        AlertEmailAddress = ConfigurationManager.AppSettings["AlertEmailAddress"]
             .ToStringOrDefault("test@thecompany.net"),
        ServerName = ConfigurationManager.AppSettings["ServerName"]
            .ToStringOrDefault("TestServer"),

    };

Jetzt ist es einfach, auf einen Blick zu sehen, was der Code macht, was die Standardwerte sind und wie wir mit einer einzigen Codezeile weitere Einstellungen hinzufügen können. Für alle anderen Einstellungswerte außer int und string müsste eine zusätzliche Erweiterungsmethode erstellt werden, aber das ist kein großes Problem.

Benutzerdefinierte Aufzählungen

Die meisten von uns haben beim Programmieren wahrscheinlich schon Enumerables verwendet, aber wusstest du, dass es unter der Oberfläche eine Engine gibt, auf die wir zugreifen und die wir nutzen können, um alle Arten von interessanten benutzerdefinierten Verhaltensweisen zu erstellen? Mit einem benutzerdefinierten Iterator können wir die Anzahl der Codezeilen, die für komplizierteres Verhalten benötigt werden, drastisch reduzieren, wenn wir Daten in Schleifen durchlaufen.

Zuerst müssen wir jedoch verstehen, wie eine Aufzählung unter der Oberfläche funktioniert. Unter der Oberfläche der Aufzählung befindet sich eine Klasse, der Motor, der die Aufzählung antreibt, und diese Klasse ermöglicht es uns, foreach zu verwenden, um durch die Werte zu laufen. Sie wird Enumerator-Klasse genannt.

Der Enumerator hat zwei Funktionen:

Current

Damit wird das aktuelle Element aus der Aufzählung geholt. Diese Funktion kann so oft aufgerufen werden, wie wir wollen, solange wir nicht versuchen, zum nächsten Element zu wechseln. Wenn wir versuchen, den Wert Current abzurufen, bevor wir MoveNext() aufgerufen haben, wird eine Ausnahme ausgelöst.

MoveNext()

Geht vom aktuellen Element aus und versucht herauszufinden, ob es ein weiteres gibt, das ausgewählt werden kann. Gibt true zurück, wenn ein weiterer Wert gefunden wird, oder false, wenn wir das Ende der Aufzählung erreicht haben oder es überhaupt keine Elemente gab. Wenn MoveNext() zum ersten Mal aufgerufen wird, zeigt der Enumerator auf das erste Element in der Enumerable.

Angrenzende Elemente abfragen

Beginnen wir mit einem relativ einfachen Beispiel. Stell dir vor, wir wollen eine Aufzählung ganzer Zahlen durchgehen, um zu sehen, ob sie aufeinanderfolgende Zahlen enthält. Eine imperative Lösung würde wahrscheinlich wie folgt aussehen:

public IEnumerable<int> GenerateRandomNumbers()
{
    var rnd = new Random();
    var returnValue = new List<int>();
    for (var i = 0; i < 100; i++)
    {
        returnValue.Add(rnd.Next(1, 100));
    }
    return returnValue;
}

public bool ContainsConsecutiveNumbers(IEnumerable<int> data)
{
    // OK, you caught me out: OrderBy isn't strictly imperative, but
    // there's no way I'm going to write out a sorting algorithm out
    // here just to prove a point!
    var sortedData = data.OrderBy(x => x).ToArray();

    for (var i = 0; i < sortedData.Length - 1; i++)
    {
        if ((sortedData[i] + 1) == sortedData[i + 1])
            return true;
    }

    return false;
}

var result = ContainsConsecutiveNumbers(GenerateRandomNumbers());
Console.WriteLine(result);

Um diesen Code funktional zu machen, brauchen wir, wie so oft, eine Erweiterungsmethode. Diese würde das Enumerable nehmen, seinen Enumerator extrahieren und das angepasste Verhalten steuern.

Um die Verwendung einer Schleife im imperativen Stil zu vermeiden, verwenden wir hier die Rekursion. Rekursion (eingeführt in den Kapiteln 1 und 2) ist eine Möglichkeit, eine unendliche Schleife zu implementieren, indem eine Funktion sich selbst wiederholt aufruft.2

Auf das Konzept der Rekursion werde ich in Kapitel 9 zurückkommen. Für den Moment wollen wir die einfache Standardversion der Rekursion verwenden:

public static bool Any<T>(this IEnumerable<T> @this, Func<T, T, bool> evaluator)
{
    using var enumerator = @this.GetEnumerator();
    var hasElements = enumerator.MoveNext();
    return hasElements && Any(enumerator, evaluator, enumerator.Current);
}

private static bool Any<T>(IEnumerator<T> enumerator,
        Func<T, T, bool> evaluator,
        T previousElement)
{
    var moreItems = enumerator.MoveNext();
    return moreItems && (evaluator(previousElement, enumerator.Current)
        ? true
        : Any(enumerator, evaluator, enumerator.Current));

}

Also, was passiert hier? Dieser Ansatz ist in gewisser Weise wie Jonglieren. Wir beginnen damit, den Enumerator zu extrahieren und gehen zum ersten Element.

In der privaten Funktion akzeptieren wir den Enumerator (der jetzt auf das erste Element verweist), die "Sind wir fertig"-Auswertungsfunktion und eine Kopie desselben ersten Elements.

Dann gehen wir sofort zum nächsten Punkt über und führen die Auswertefunktion aus, wobei wir den ersten Punkt und den neuen Current übergeben, damit sie verglichen werden können.

An diesem Punkt stellen wir entweder fest, dass wir keine Items mehr haben, oder der Evaluator gibt true zurück. In diesem Fall können wir die Iteration beenden. Wenn MoveNext() true zurückgibt, prüfen wir, ob previousValue und Current unseren Anforderungen entsprechen (wie in evaluator angegeben). Wenn ja, beenden wir und geben true zurück; andernfalls machen wir einen rekursiven Aufruf, um den Rest der Werte zu überprüfen.

Dies ist die aktualisierte Version des Codes, um fortlaufende Zahlen zu finden:

public IEnumerable<int> GenerateRandomNumbers()
{
    var rnd = new Random();

    var returnValue = Enumerable.Repeat(0, 100)
        .Select(x => rnd.Next(1, 100));
    return returnValue;
}

public bool ContainsConsecutiveNumbers(IEnumerable<int> data)
{
    var sortedData = data.OrderBy(x => x).ToArray();
    var result = sortedData.Any((prev, curr) => cur == prev + 1);
    return result;
}

Es wäre auch einfach, eine All() Methode zu erstellen, die auf der gleichen Logik basiert,etwa so:

public static bool All<T>(
    this IEnumerator<T> enumerator,
    Func<T,T,bool> evaluator,
    T previousElement)
{
    var moreItems = enumerator.MoveNext();
    return moreItems
        ? evaluator(previousElement, enumerator.Current)
            ? All(enumerator, evaluator, enumerator.Current)
            : false
        : true;
}

public static bool All<T>(this IEnumerable<T> @this, Func<T,T,bool> evaluator)
{
    using var enumerator = @this.GetEnumerator();
    var hasElements = enumerator.MoveNext();
    return hasElements
        ? All(enumerator, evaluator, enumerator.Current)
        : true;
}

Die einzigen Unterschiede zwischen All() und Any() sind die Bedingungen, unter denen entschieden wird, ob die Schleife fortgesetzt werden soll und ob du vorzeitig zurückkehren musst. Bei All() geht es darum, jedes Wertepaar zu prüfen und nur dann vorzeitig aus der Schleife zurückzukehren, wenn einer der Werte die Kriterien nicht erfüllt.

Iterieren, bis eine Bedingung erfüllt ist

Die in diesem Abschnitt beschriebene Technik ist im Grunde ein Ersatz für eine while Schleife, also gibt es eine weitere Anweisung, die wir nicht unbedingt brauchen.

Für dieses Beispiel stellen wir uns vor, wie das Zugsystem für ein textbasiertes Abenteuerspiel aussehen könnte. Für die jüngeren Leserinnen und Leser: Das war früher so, als es noch keine Grafiken gab. Du musstest aufschreiben, was du tun wolltest, und das Spiel schrieb, was passierte - ähnlich wie ein Buch, nur dass du selbst schriebst, was passierte.

Hinweis

Schau dir das epische Abenteuerspiel Zork an, wenn du das mit eigenen Augen sehen willst. Versuche, nicht von einem Grue gefressen zu werden!

Die Grundstruktur eines dieser Spiele war ungefähr so:

  1. Schreibe eine Beschreibung des aktuellen Standorts.

  2. Nehme Benutzereingaben entgegen.

  3. Führe den angeforderten Befehl aus.

Hier siehst du, wie imperativer Code mit dieser Situation umgehen könnte:

var gameState = new State
{
    IsAlive = true,
    HitPoints = 100
};

while(gameState.IsAlive)
{
    var message = this.ComposeMessageToUser(gameState);
    var userInput = this.InteractWithUser(message);
    this.UpdateState(gameState, userInput);

    if(gameState.HitPoints <= 0)
        gameState.IsAlive = false;
}

Im Prinzip wollen wir eine Funktion im Stil von LINQ Aggregate(), aber eine, die nicht in einer Schleife alle Elemente eines Arrays durchläuft und dann endet. Stattdessen soll die Funktion so lange in einer Schleife laufen, bis unsere Endbedingung erfüllt ist (der Spieler ist tot). Ich vereinfache hier ein wenig (natürlich könnte unser Spieler in einem richtigen Spiel auch gewinnen ). Aber mein Beispielspiel ist wie das Leben, und das Leben ist nicht fair!

Die Erweiterungsmethode ist ein weiterer Punkt, der von optimierten Aufrufen mit Tail-Rekursion profitieren würde, und ich werde in Kapitel 9 Optionen dafür vorstellen. Für den Moment werden wir jedoch nur eine einfache Rekursion verwenden (was zu einem Problem werden kann, wenn das Spiel viele Runden hat), um nicht zu viele Ideen zu früh einzuführen:

public static class ExtensionMethods
{
    public static T AggregateUntil<T>(
      this T @this,
      Func<T,bool> endCondition,
      Func<T,T> update) =>
        endCondition(@this)
             ? @this
             : AggregateUntil(update(@this), endCondition, update);
}

Auf diese Weise können wir die while Schleife ganz abschaffen und die gesamte Abbiegefolge in eine einzige Funktion umwandeln, etwa so:

var gameState = new State
{
    IsAlive = true,
    HitPoints = 100
};

var endState = gameState.AggregateUntil(
    x => x.HitPoints <= 0,
    x => {
        var message = this.ComposeMessageToUser(x);
        var userInput = this.InteractWithUser(message);
        return this.UpdateState(x, userInput);
    });

Das ist nicht perfekt, aber es funktioniert jetzt. Es gibt weitaus bessere Möglichkeiten, die verschiedenen Schritte zur Aktualisierung des Spielzustands zu handhaben, und auch die Frage, wie man die Benutzerinteraktion auf funktionale Weise handhabt, bleibt bestehen. In Kapitel 13 werden diese Themen behandelt.

Zusammenfassung

In diesem Kapitel haben wir uns angeschaut, wie man Func Delegates, Enumerables und Erweiterungsmethoden nutzen kann, um C# zu erweitern, damit es einfacher wird, funktionalen Code zu schreiben und einige bestehende Einschränkungen der Sprache zu umgehen. Ich bin mir sicher, dass ich mit diesen Techniken nur an der Oberfläche kratze und dass es noch viele weitere gibt, die entdeckt und genutzt werden wollen.

Das nächste Kapitel befasst sich mit Funktionen höherer Ordnung und einigen Strukturen, mit denen man sie nutzen kann, um noch mehr nützliche Funktionen zu schaffen.

1 Übrigens war es Peter Davison.

2 Eine unendliche Schleife, aber hoffentlich nicht unendlich!

Get Funktionale Programmierung mit C# now with the O’Reilly learning platform.

O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.