Approfondimenti sulla programmazione funzionale in C# (9) Composizione e concatenamento di funzioni

Approfondimenti sulla programmazione funzionale in C# (9) Composizione e concatenamento di funzioni

[LINQ tramite serie C#]

[Serie di approfondimento programmazione funzionale C#]

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

Nella programmazione orientata agli oggetti, gli oggetti possono essere composti per costruire oggetti più complessi. Allo stesso modo, nella programmazione funzionale. le funzioni possono essere composte per costruire funzioni più complesse.

Composizione avanti e indietro

È molto comune passare l'output di una funzione a un'altra funzione come input:

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
}

Quindi sopra la funzione Abs e la funzione Sqrt possono essere combinate:

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

La funzione precedente è la composizione di int.Parse, Math.Abs ​​Convert.ToDouble e Math.Sqrt. Il suo valore di ritorno è il valore di ritorno dell'ultima funzione di Math.Sqrt. Generalmente, un operatore di composizione in avanti e un operatore di composizione all'indietro possono essere definiti come metodo di estensione:

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));
}

Le funzioni di cui sopra possono essere composte chiamando After o Then:

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
}

I metodi di query LINQ, come Where, Skip, Take, non possono essere composti direttamente in questo modo:

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.
    }
}

Restituiscono tutti IEnumerable, ma sono tutti 2-arity, quindi una funzione non può essere chiamata direttamente con l'output di un'altra funzione. Per comporre queste funzioni, devono essere parzialmente applicate (chiamate) con il parametro diverso da IEnumerable, in modo che diventino funzioni di 1 unità, che possono essere composte. Per fare ciò, crea le seguenti funzioni di supporto:

// 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);

Vengono recuperati dai metodi di query originali, con il primo parametro e il secondo parametro scambiati. Dopo essere stati chiamati con un argomento, restituiscono IEnumerable –> IEnumerable functions:

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();
    }
}

Quindi questi metodi di query LINQ possono essere composti tramite le funzioni di supporto al curry:

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();
    }
}

Progetto in avanti

L'operatore forward pipe, che inoltra l'argomento per chiamare la funzione, può anche aiutare la composizione della funzione. Può anche essere definito come metodo di estensione:

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);
}

L'esempio seguente mostra come usarlo:

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")))));
}

Il metodo di estensione Forward può essere utile con l'operatore condizionale nullo per semplificare il codice, ad esempio:

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);
}

Questo operatore può anche aiutare a comporre i metodi di query LINQ:

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();
    }
}

Concatenamento di metodi fluente

A differenza del metodo statico, i metodi di istanza possono essere facilmente composti semplicemente concatenando le chiamate, ad esempio:

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

Le funzioni di cui sopra sono composte in modo fluido perché ognuna di esse restituisce un'istanza di quel tipo, in modo che un altro metodo di istanza possa essere chiamato in modo fluido. Sfortunatamente, molte API non sono progettate seguendo questo schema. Prendi List come esempio, ecco alcuni dei suoi metodi:

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.
    }
}

Questi metodi restituiscono void, quindi non possono essere composti mediante concatenamento. Queste API esistenti non possono essere modificate, ma lo zucchero sintattico del metodo di estensione consente di aggiungere virtualmente nuovi metodi a un tipo esistente. Quindi i metodi fluenti possono essere "aggiunti" a List definendo metodi di estensione:

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;
    }
}

Restituendo sempre il primo parametro, questi metodi di estensione possono essere composti da un concatenamento fluido, come se fossero metodi di istanza:

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();
}

Come accennato in precedenza, queste chiamate di metodi di estensione vengono compilate in normali chiamate di metodi statici:

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()
            );
}

Composizione dei metodi di query LINQ

In C#, i metodi di query LINQ sono composti meglio con questo approccio di concatenamento di metodi fluente. IEnumerable è fornito da .NET Framework 2,0 per rappresentare una sequenza di valori. Ha solo un metodo GetEnumerator e un'altra versione del metodo GetEnumerator ereditata da IEnumerable:

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

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

Quando .NET Framework 3,5 introduce LINQ, IEnumerable viene usato per rappresentare l'origine dati LINQ locale e la query. Tutti i metodi di query, ad eccezione di Empty, Range, Repeat, sono definiti come metodi di estensione nel tipo System.Linq.Enumerable. Molti metodi di query, come sopra menzionato Where, Skip, Take, Select, restituisce IEnumerable, in modo che i metodi di query possano essere composti da un concatenamento fluido.

Il metodo OrderBy sopra menzionato è leggermente diverso. Accetta IEnumerable ma restituisce IOrderedEnumerable. Esistono 4 metodi di query di ordinamento rilevanti per IOrderedEnumerable:

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è derivato da IEnumerable, quindi ThenBy e ThenByDescending possono essere composti solo dopo OrderBy e OrderByDescending, il che logicamente ha senso.

Esistono anche alcuni metodi che restituiscono un singolo valore invece di IEnumerable, come First, Last, ecc.:

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

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

Di solito terminano la query LINQ, poiché altri metodi di query non possono essere composti dopo questi metodi, a meno che il valore singolo restituito non sia ancora un'istanza IEnumerable.

Esistono altre parità di query LINQ to Objects rappresentate da IEnumerable, come la query Parallel LINQ to Objects rappresentata da ParallelQuery, la query LINQ remota rappresentata da IQueryable, i loro metodi di query seguono tutti questo schema:

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.
    }
}

I dettagli delle query IEnumerable sono trattati nel capitolo LINQ to Objects, le query ParallelQuery sono trattate nel capitolo LINQ Parallel e le query IQueryable sono trattate nel capitolo LINQ to Entities.