C#-Funktionsprogrammierung im Detail (9) Funktionskomposition und -verkettung

C#-Funktionsprogrammierung im Detail (9) Funktionskomposition und -verkettung

[LINQ via C#-Reihe]

[Eingehende Serie zur funktionalen Programmierung in C#]

Neueste Version:https://weblogs.asp.net/dixin/functional-csharp-function-composition-and-method-chaining

Bei der objektorientierten Programmierung können Objekte zusammengesetzt werden, um komplexere Objekte zu erstellen. Ebenso in der funktionalen Programmierung. Funktionen können zusammengesetzt werden, um komplexere Funktionen zu erstellen.

Vorwärts- und Rückwärtskomposition

Es ist sehr üblich, die Ausgabe einer Funktion als Eingabe an eine andere Funktion zu übergeben:

internal static void OutputAsInput()
{
    string input = "-2.0";
    int output1 = int.Parse(input); // string -> int
    int output2 = Math.Abs(output1); // int -> int
    double output3 = Convert.ToDouble(output2); // int -> double
    double output4 = Math.Sqrt(output3); // double -> double
}

Die obige Abs-Funktion und die Sqrt-Funktion können also kombiniert werden:

// string -> double
internal static double Composition(string input) => 
    Math.Sqrt(Convert.ToDouble(Math.Abs(int.Parse(input))));

Die obige Funktion ist die Zusammensetzung von int.Parse, Math.Abs ​​Convert.ToDouble und Math.Sqrt. Sein Rückgabewert ist der letzte Rückgabewert der Funktion Math.Sqrt. Allgemein kann ein Vorwärtskompositionsoperator und ein Rückwärtskompositionsoperator als Erweiterungsmethode definiert werden:

public static partial class FuncExtensions
{
    public static Func<T, TResult2> After<T, TResult1, TResult2>(
        this Func<TResult1, TResult2> function2, Func<T, TResult1> function1) =>
            value => function2(function1(value));

    public static Func<T, TResult2> Then<T, TResult1, TResult2>( // Before.
        this Func<T, TResult1> function1, Func<TResult1, TResult2> function2) =>
            value => function2(function1(value));
}

Die obigen Funktionen können zusammengesetzt werden, indem entweder After oder Then aufgerufen wird:

internal static void Compose()
{
    Func<string, int> parse = int.Parse; // string -> int
    Func<int, int> abs = Math.Abs; // int -> int
    Func<int, double> convert = Convert.ToDouble; // int -> double
    Func<double, double> sqrt = Math.Sqrt; // double -> double

    // string -> double
    Func<string, double> composition1 = sqrt.After(convert).After(abs).After(parse);
    composition1("-2.0").WriteLine(); // 1.4142135623731

    // string -> double
    Func<string, double> composition2 = parse.Then(abs).Then(convert).Then(sqrt);
    composition2("-2.0").WriteLine(); // 1.4142135623731
}

Die LINQ-Abfragemethoden wie Where, Skip, Take können nicht direkt so zusammengesetzt werden:

namespace System.Linq
{
    public static class Enumerable
    {
        // (IEnumerable<TSource>, TSource -> bool) -> IEnumerable<TSource>
        public static IEnumerable<TSource> Where<TSource>(
            this IEnumerable<TSource> source, Func<TSource, bool> predicate);

        // (IEnumerable<TSource>, int) -> IEnumerable<TSource>
        public static IEnumerable<TSource> Skip<TSource>(
            this IEnumerable<TSource> source, int count);

        // (IEnumerable<TSource>, int) -> IEnumerable<TSource>
        public static IEnumerable<TSource> Take<TSource>(
            this IEnumerable<TSource> source, int count);

        // Other members.
    }
}

Sie alle geben IEnumerable zurück, aber sie sind alle 2-stellig, sodass eine Funktion nicht direkt mit der Ausgabe einer anderen Funktion aufgerufen werden kann. Um diese Funktionen zu erstellen, müssen sie teilweise mit einem anderen Parameter als IEnumerable angewendet (aufgerufen) werden, sodass sie zu Funktionen mit 1-Stellung werden, die erstellt werden können. Erstellen Sie dazu die folgenden Hilfsfunktionen:

// Func<TSource, bool> -> IEnumerable<TSource> -> IEnumerable<TSource>
internal static Func<IEnumerable<TSource>, IEnumerable<TSource>> Where<TSource>(
    Func<TSource, bool> predicate) => source => Enumerable.Where(source, predicate);

// int -> IEnumerable<TSource> -> IEnumerable<TSource>
internal static Func<IEnumerable<TSource>, IEnumerable<TSource>> Skip<TSource>(
    int count) => source => Enumerable.Skip(source, count);

// int -> IEnumerable<TSource> -> IEnumerable<TSource>
internal static Func<IEnumerable<TSource>, IEnumerable<TSource>> Take<TSource>(
    int count) => source => Enumerable.Take(source, count);

Sie werden von den ursprünglichen Abfragemethoden übernommen, wobei der erste Parameter und der zweite Parameter vertauscht sind. Nachdem sie mit einem Argument aufgerufen wurden, geben sie IEnumerable –> IEnumerable-Funktionen zurück:

internal static void LinqWithPartialApplication()
{
    // IEnumerable<TSource> -> IEnumerable<TSource>
    Func<IEnumerable<int>, IEnumerable<int>> where = Where<int>(int32 => int32 > 0);
    Func<IEnumerable<int>, IEnumerable<int>> skip = Skip<int>(1);
    Func<IEnumerable<int>, IEnumerable<int>> take = Take<int>(2);

    IEnumerable<int> query = take(skip(where(new int[] { 4, 3, 2, 1, 0, -1 })));
    foreach (int result in query) // Execute query.
    {
        result.WriteLine();
    }
}

Diese LINQ-Abfragemethoden können also durch die Curry-Hilfsfunktionen zusammengestellt werden:

internal static void ComposeLinqWithPartialApplication()
{
    Func<IEnumerable<int>, IEnumerable<int>> composition =
        Where<int>(int32 => int32 > 0)
        .Then(Skip<int>(1))
        .Then(Take<int>(2));

    IEnumerable<int> query = composition(new int[] { 4, 3, 2, 1, 0, -1 });
    foreach (int result in query) // Execute query.
    {
        result.WriteLine();
    }
}

Weiterleitungspipeline

Der Forward-Pipe-Operator, der das Argument an die Aufruffunktion weiterleitet, kann auch bei der Funktionskomposition helfen. Sie kann auch als Erweiterungsmethode definiert werden:

public static partial class FuncExtensions
{
    public static TResult Forward<T, TResult>(this T value, Func<T, TResult> function) =>
        function(value);
}

public static partial class ActionExtensions
{
    public static void Forward<T>(this T value, Action<T> function) =>
        function(value);
}

Das folgende Beispiel demonstriert die Verwendung:

internal static void Forward()
{
    "-2"
        .Forward(int.Parse) // string -> int
        .Forward(Math.Abs) // int -> int
        .Forward(Convert.ToDouble) // int -> double
        .Forward(Math.Sqrt) // double -> double
        .Forward(Console.WriteLine); // double -> void

    // Equivalent to:
    Console.WriteLine(Math.Sqrt(Convert.ToDouble(Math.Abs(int.Parse("-2")))));
}

Die Forward-Erweiterungsmethode kann mit dem Null-Bedingungsoperator nützlich sein, um den Code zu vereinfachen, zum Beispiel:

internal static void ForwardAndNullConditional(IDictionary<string, object> dictionary, string key)
{
    object value = dictionary[key];
    DateTime? dateTime1;
    if (value != null)
    {
        dateTime1 = Convert.ToDateTime(value);
    }
    else
    {
        dateTime1 = null;
    }

    // Equivalent to:
    DateTime? dateTime2 = dictionary[key]?.Forward(Convert.ToDateTime);
}

Dieser Operator kann auch beim Erstellen von LINQ-Abfragemethoden helfen:

internal static void ForwardLinqWithPartialApplication()
{
    IEnumerable<int> source = new int[] { 4, 3, 2, 1, 0, -1 };
    IEnumerable<int> query = source
        .Forward(Where<int>(int32 => int32 > 0))
        .Forward(Skip<int>(1))
        .Forward(Take<int>(2));
    foreach (int result in query) // Execute query.
    {
        result.WriteLine();
    }
}

Fließende Methodenverkettung

Im Gegensatz zu statischen Methoden können Instanzmethoden einfach zusammengesetzt werden, indem die Aufrufe einfach verkettet werden, zum Beispiel:

internal static void InstanceMethodChaining(string @string)
{
    string result = @string.TrimStart().Substring(1, 10).Replace("a", "b").ToUpperInvariant();
}

Die obigen Funktionen sind fließend zusammengesetzt, da jede von ihnen eine Instanz dieses Typs zurückgibt, sodass eine andere Instanzmethode fließend aufgerufen werden kann. Leider sind viele APIs nicht nach diesem Muster konzipiert. Nehmen Sie List als Beispiel, hier sind einige seiner Methoden:

namespace System.Collections.Generic
{
    public class List<T> : IList<T>, IList, IReadOnlyList<T>
    {
        public void Add(T item);

        public void Clear();

        public void ForEach(Action<T> action);

        public void Insert(int index, T item);

        public void RemoveAt(int index);

        public void Reverse();

        // Other members.
    }
}

Diese Methoden geben void zurück, sodass sie nicht durch Verkettung zusammengesetzt werden können. Diese vorhandenen APIs können nicht geändert werden, aber die Erweiterungsmethode syntaktischer Zucker ermöglicht das virtuelle Hinzufügen neuer Methoden zu einem vorhandenen Typ. Fluent-Methoden können also zu List „hinzugefügt“ werden, indem Erweiterungsmethoden definiert werden:

public static class ListExtensions
{
    public static List<T> FluentAdd<T>(this List<T> list, T item)
    {
        list.Add(item);
        return list;
    }

    public static List<T> FluentClear<T>(this List<T> list)
    {
        list.Clear();
        return list;
    }

    public static List<T> FluentForEach<T>(this List<T> list, Action<T> action)
    {
        list.ForEach(action);
        return list;
    }

    public static List<T> FluentInsert<T>(this List<T> list, int index, T item)
    {
        list.Insert(index, item);
        return list;
    }

    public static List<T> FluentRemoveAt<T>(this List<T> list, int index)
    {
        list.RemoveAt(index);
        return list;
    }

    public static List<T> FluentReverse<T>(this List<T> list)
    {
        list.Reverse();
        return list;
    }
}

Indem immer der erste Parameter zurückgegeben wird, können diese Erweiterungsmethoden durch fließende Verkettung zusammengesetzt werden, als ob sie Instanzmethoden wären:

internal static void ListFluentExtensions()
{
    List<int> list = new List<int>() { 1, 2, 3, 4, 5 }
        .FluentAdd(1)
        .FluentInsert(0, 0)
        .FluentRemoveAt(1)
        .FluentReverse()
        .FluentForEach(value => value.WriteLine())
        .FluentClear();
}

Wie bereits erwähnt, werden diese Erweiterungsmethodenaufrufe zu normalen statischen Methodenaufrufen kompiliert:

public static void CompiledListExtensions()
{
    List<int> list = 
        ListExtensions.FluentClear(
            ListExtensions.FluentForEach(
                ListExtensions.FluentReverse(
                    ListExtensions.FluentRemoveAt(
                        ListExtensions.FluentInsert(
                            ListExtensions.FluentAdd(
                                new List<int>() { 1, 2, 3, 4, 5 }, 1), 
                            0, 0), 
                        1)
                    ), 
                value => value).WriteLine()
            );
}

Zusammensetzung der LINQ-Abfragemethoden

In C# werden LINQ-Abfragemethoden mit diesem fließenden Methodenverkettungsansatz besser zusammengesetzt. IEnumerable wird von .NET Framework 2.0 bereitgestellt, um eine Folge von Werten darzustellen. Es hat nur eine GetEnumerator-Methode und eine andere Version der GetEnumerator-Methode, die von IEnumerable geerbt wurde:

namespace System.Collections
{
    public interface IEnumerable
    {
        IEnumerator GetEnumerator();
    }
}

namespace System.Collections.Generic
{
    public interface IEnumerable<out T> : IEnumerable
    {
        IEnumerator<T> GetEnumerator();
    }
}

Wenn .NET Framework 3.5 LINQ einführt, wird IEnumerable verwendet, um die lokale LINQ-Datenquelle und -Abfrage darzustellen. Alle Abfragemethoden außer Empty, Range, Repeat sind als Erweiterungsmethoden im System.Linq.Enumerable-Typ definiert. Viele Abfragemethoden, wie die oben erwähnten Where, Skip, Take, Select, geben IEnumerable zurück, sodass die Abfragemethoden durch fließende Verkettung zusammengesetzt werden können.

Die oben erwähnte OrderBy-Methode ist etwas anders. Es akzeptiert IEnumerable, gibt aber IOrderedEnumerable zurück. Es gibt 4 für IOrderedEnumerable relevante Sortierabfragemethoden:

namespace System.Linq
{
    public interface IOrderedEnumerable<TElement> : IEnumerable<TElement>, IEnumerable
    {
        IOrderedEnumerable<TElement> CreateOrderedEnumerable<TKey>(
            Func<TElement, TKey> keySelector, IComparer<TKey> comparer, bool descending);
    }

    public static class Enumerable
    {
        public static IOrderedEnumerable<TSource> OrderBy<TSource, TKey>(
            this IEnumerable<TSource> source, Func<TSource, TKey> keySelector);

        public static IOrderedEnumerable<TSource> OrderByDescending<TSource, TKey>(
            this IEnumerable<TSource> source, Func<TSource, TKey> keySelector);

        public static IOrderedEnumerable<TSource> ThenBy<TSource, TKey>(
            this IOrderedEnumerable<TSource> source, Func<TSource, TKey> keySelector);

        public static IOrderedEnumerable<TSource> ThenByDescending<TSource, TKey>(
            this IOrderedEnumerable<TSource> source, Func<TSource, TKey> keySelector);
    }
}

IOrderedEnumerable ist von IEnumerable abgeleitet, daher können ThenBy und ThenByDescending nur nach OrderBy und OrderByDescending zusammengesetzt werden, was logisch sinnvoll ist.

Es gibt auch einige Methoden, die anstelle von IEnumerable einen einzelnen Wert zurückgeben, wie First, Last usw.:

public static class Enumerable
{
    public static TSource First<TSource>(this IEnumerable<TSource> source);

    public static TSource Last<TSource>(this IEnumerable<TSource> source);
}

Normalerweise beenden sie die LINQ-Abfrage, da andere Abfragemethoden nicht nach diesen Methoden zusammengesetzt werden können, es sei denn, der zurückgegebene Einzelwert ist immer noch eine IEnumerable-Instanz.

Es gibt andere Paritäten der LINQ to Objects-Abfrage, dargestellt durch IEnumerable, wie die parallele LINQ to Objects-Abfrage, dargestellt durch ParallelQuery, die Remote-LINQ-Abfrage, dargestellt durch IQueryable, ihre Abfragemethoden folgen alle diesem Muster:

namespace System.Linq
{
    public static class ParallelEnumerable
    {
        public static ParallelQuery<TSource> Where<TSource>(
            this ParallelQuery<TSource> source, Func<TSource, bool> predicate);

        public static OrderedParallelQuery<TSource> OrderBy<TSource, TKey>(
            this ParallelQuery<TSource> source, Func<TSource, TKey> keySelector);

        public static ParallelQuery<TResult> Select<TSource, TResult>(
            this ParallelQuery<TSource> source, Func<TSource, TResult> selector);

        // Other members.
    }

    public static class Queryable
    {
        public static IQueryable<TSource> Where<TSource>(
            this IQueryable<TSource> source, Func<TSource, bool> predicate);

        public static IOrderedQueryable<TSource> OrderBy<TSource, TKey>(
            this IQueryable<TSource> source, Func<TSource, TKey> keySelector);

        public static IQueryable<TResult> Select<TSource, TResult>(
            this IQueryable<TSource> source, Func<TSource, TResult> selector);

        // Other members.
    }
}

Die Details von IEnumerable-Abfragen werden im Kapitel LINQ to Objects behandelt, ParallelQuery-Abfragen werden im Kapitel Parallel LINQ behandelt und IQueryable-Abfragen werden im Kapitel LINQ to Entities behandelt.