CodeGym /Kursy /C# SELF /Funkcje wyższego rzędu

Funkcje wyższego rzędu

C# SELF
Poziom 51 , Lekcja 2
Dostępny

1. Wprowadzenie

Pewnie w szkole słyszeliście: "To funkcja od funkcji". Funkcje wyższego rzędu (Higher-Order Functions, HOF) — to funkcje, które albo przyjmują funkcje jako argumenty, albo zwracają funkcje jako wynik, albo robią i to, i to.

Prościej: jeśli wasza metoda może dostać w parametrze inną funkcję (np. delegat lub lambda), albo zwrócić ją jako wynik — gratulacje, macie funkcję wyższego rzędu!

Funkcja wyższego rzędu to jak klucz uniwersalny, który nie tylko może otworzyć drzwi sam, ale też przekazać wam inny klucz, żebyście otworzyli właściwe drzwi później.

Zastosowanie w praktyce

Brzmi ciekawie i niebanalnie. Ale po co to w codziennej pracy programisty C#? Najkrótsza odpowiedź:
Funkcje wyższego rzędu robią kod elastycznym, wielokrotnego użytku i zwięzłym.
To podstawa takich rzeczy jak LINQ, filtrowanie i sortowanie kolekcji, budowanie pipeline'ów przetwarzania danych, konfigurowanie callbacków, eventów, a nawet dependency injection.

Kilka realnych scenariuszy:

  • Ograniczyć zachowanie metody, przekazując do niej logikę (np. filtrowanie, sortowanie, transformację).
  • Napisz uniwersalny handler danych, któremu "wkręcasz" potrzebną operację przez delegat.
  • Budować łańcuchy przetwarzania ("pipeline"), gdzie każda funkcja modyfikuje dane według własnego przepisu.
  • Tworzyć cross-platformowe abstrakcje: co robić na Windows, a co na Linux? Po prostu przekaż odpowiednią funkcję.

2. Przykłady prostych funkcji wyższego rzędu

Funkcja, przyjmująca inną funkcję

Klasyczny przykład — metoda przyjmująca delegat lub lambdę.


// Funkcja wyższego rzędu: przyjmuje funkcję process jako parametr
void ForEach<T>(IEnumerable<T> collection, Action<T> process)
{
    foreach (var item in collection)
    {
        process(item); // Wywołujemy funkcję-argument
    }
}

// Użycie:
var numbers = new List<int> { 1, 2, 3 };
ForEach(numbers, n => Console.WriteLine($"Element: {n}"));

Co tu się dzieje? Metoda ForEach nie wie, co dokładnie robić z każdym elementem. Wszystko, co robi — wywołuje przekazany processor (process). Ten processor może robić cokolwiek — wypisywać na ekran, zapisywać do bazy, rysować UI itd.

Tak, tak działa metoda ForEach dla kolekcji w C#, i prawie wszystkie metody LINQ — to funkcje wyższego rzędu!

Funkcja, zwracająca funkcję

A teraz przykład nieco mocniejszy — metoda, która nie tylko przyjmuje, ale i zwraca funkcję.


// Funkcja wyższego rzędu: zwraca inną funkcję
Func<int, int> CreateMultiplier(int factor)
{
    // Zwracamy lambdę, używającą zmiennej factor
    return x => x * factor;
}

// Użycie:
var multiplyBy10 = CreateMultiplier(10);
Console.WriteLine(multiplyBy10(7)); // 70

Tu CreateMultiplier zwraca funkcję, która mnoży swój argument przez zadany wcześniej factor. To już nie tylko HOF, to przykład "fabryki funkcji".

Funkcja, przyjmująca i zwracająca funkcję


Func<int, int> Compose(Func<int, int> f, Func<int, int> g)
{
    // Zwróci funkcję, która stosuje g, a potem f: f(g(x))
    return x => f(g(x));
}

// Użycie:
Func<int, int> increment = x => x + 1;
Func<int, int> doubleIt = x => x * 2;

var incrementThenDouble = Compose(doubleIt, increment);

Console.WriteLine(incrementThenDouble(5)); // (5 + 1) * 2 = 12

To właśnie takie kompozycje leżą u podstaw przetwarzania danych strumieniami — np. gdy obrabiacie tablicę metodami Select, Where, OrderBy itp.

3. Jak C# wspiera funkcje wyższego rzędu

W językach funkcyjnych (Haskell, F#) wszystkie funkcje są wyższego rzędu domyślnie. Ale i C# (już od wersji 2.0) daje ten styl dzięki delegatom i lambdom.

  • Delegaty (Func, Action, Predicate) — typy funkcji.
  • Wyrażenia lambda — składnia do tworzenia funkcji w miejscu.
  • Metody mogą przyjmować i zwracać delegaty — więc HOF są wspierane "od razu".

Diagram

flowchart LR
    A[Dane] --> B[Funkcja 1]
    B --> C[Funkcja 2]
    C --> D[Wynik]
    subgraph "Pipeline przetwarzania (funkcje wyższego rzędu)"
        B
        C
    end

4. Rozwijamy naszą aplikację

Rozwińmy nasze proste demo — niech to będzie "Mini-processor stringów".

Dodajmy prostą metodę wyższego rzędu

Wyobraźmy sobie listę nazw użytkowników i że chcemy dowolnie je modyfikować przy pomocy funkcji.


// Metoda, która przyjmuje listę stringów i funkcję do transformacji
List<string> TransformNames(List<string> names, Func<string, string> transformer)
{
    var result = new List<string>();
    foreach (var name in names)
    {
        result.Add(transformer(name));
    }
    return result;
}

Jak użyć tej metody?


var names = new List<string> { "Anna", "Borys", "Sergiej" };

// Zmień na wielkie litery
var upperNames = TransformNames(names, n => n.ToUpper());

// Dodaj "Szanowny(a) " do każdego imienia
var politeNames = TransformNames(names, n => "Szanowny(a) " + n);

foreach (var n in upperNames)
    Console.WriteLine(n); // ANNA, BORYS, SERGIEJ

foreach (var n in politeNames)
    Console.WriteLine(n); // Szanowny(a) Anna, ...

Metoda TransformNames jest uniwersalna: deleguje logikę transformacji do przekazanego parametru (transformer), np. wywołania ToUpper lub dowolnego innego przepisu.

Dostosowanie do typów

Nasz przykład łatwo dostosować do dowolnych typów danych.


// Typowy uniwersalny metod - funkcja wyższego rzędu pracująca dla dowolnego T
List<TResult> Map<T, TResult>(List<T> items, Func<T, TResult> transformer)
{
    var result = new List<TResult>();
    foreach (var item in items)
    {
        result.Add(transformer(item));
    }
    return result;
}

Przykłady użycia:


var numbers = new List<int> { 1, 2, 3 };
var doubled = Map(numbers, x => x * 2); // [2, 4, 6]
var strings = Map(numbers, x => $"Liczba: {x}"); // ["Liczba: 1", ...]

5. Filtrowanie i agregacja przez funkcje wyższego rzędu

Logika filtrowania i wyszukiwania od dawna realizowana jest przez funkcje wyższego rzędu.


// Filtrowanie: funkcja wyższego rzędu
List<T> Filter<T>(List<T> items, Predicate<T> criteria)
{
    var result = new List<T>();
    foreach (var item in items)
    {
        if (criteria(item)) // Wywołujemy funkcję-kryterium
        {
            result.Add(item);
        }
    }
    return result;
}

Jak użyć:


var names = new List<string> { "Anna", "Borys", "Andrzej" };
var aNames = Filter(names, n => n.StartsWith("A"));
// Wynik: "Anna", "Andrzej"

6. Pojęcie kompozycji funkcji (function composition)

Funkcje wyższego rzędu pozwalają nie tylko korzystać z pojedynczych funkcji, ale i składać je w łańcuchy — tzw. kompozycje. W C# można to zrealizować jako funkcję, która przyjmuje dwie funkcje i zwraca nową, ich kombinację.


// Kompozytor funkcji: zwraca funkcję, która aplikuje najpierw g, potem f
Func<T, TResult> Compose<T, TIntermediate, TResult>(
    Func<TIntermediate, TResult> f,
    Func<T, TIntermediate> g)
{
    return x => f(g(x));
}

// Przykład:
Func<int, int> plusOne = n => n + 1;
Func<int, int> timesTwo = n => n * 2;

var plusOneThenDouble = Compose(timesTwo, plusOne);
Console.WriteLine(plusOneThenDouble(3)); // (3 + 1) * 2 = 8

7. Przydatne niuanse

Tło problemu: dlaczego kiedyś wszystko było trudniejsze?

Przed pojawieniem się delegatów i lambd programiści często pisali masę podobnych pętli, kopiowali fragmenty kodu, żeby "odfiltrować", "zmienić" lub "zgrupować" dane. Z nadejściem funkcji wyższego rzędu pojawiła się możliwość wydzielenia zmiennej części zachowania do osobnych funkcji-parametrów — to radykalnie zmniejszyło duplikację i zwiększyło ekspresję kodu.

Trochę cukru składniowego: funkcje jako wyrażenia

Funkcje wyższego rzędu często implementuje się przez expression-bodied methods — zwięzłe, jednowierszowe metody:


List<string> FilterNames(Predicate<string> pred) =>
    Names.Where(name => pred(name)).ToList();

List<TResult> MapNames<TResult>(Func<string, TResult> transformer) =>
    Names.Select(transformer).ToList();

Porównanie z mechanizmami LINQ

Zobaczmy, jak LINQ wykorzystuje funkcje wyższego rzędu:

Metoda LINQ Jaki delegat przyjmuje Zastosowanie
Where
Func<T, bool>
Filtruje elementy
Select
Func<T, TResult>
Transformuje elementy
OrderBy
Func<T, TKey>
Sortuje po kluczu
Aggregate
Func<TAcc, T, TAcc>
Agreguje (składa) kolekcję
Any
Func<T, bool>
Sprawdza, czy istnieje element spełniający warunek

Wszystkie te metody opierają się na idei funkcji wyższego rzędu: piszesz własne reguły działania, a biblioteka standardowa zapewnia "infrastrukturę".

8. Możliwe błędy i pułapki

Zamieszanie z typami delegatów.
Na początku można mieć problemy z rozróżnieniem, kiedy użyć Action, kiedy Func, a kiedy Predicate.
Wskazówka: jeśli funkcja zwraca bool — najpewniej to Predicate. Jeśli zwraca wartość — użyj Func, jeśli nic nie zwraca — Action.

Capture zmiennych (closures).
Jeśli zwracana funkcja używa zmiennych z zewnętrznego zakresu, pilnuj, żeby wartości były aktualne w momencie wywołania. Zmienne nie są kopiowane, są "capture'owane".

Debugowanie złożonych łańcuchów.
Kiedy funkcje składają się w długie pipeline'y, ciężko zorientować się, która warstwa przetworzyła dane nie tak. Dodawaj tymczasowe wypisy i komentarze:


n => {
  Console.WriteLine("Przed operacją:" + n);
  var res = n * 2;
  Console.WriteLine("Po operacji:" + res);
  return res;
}
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION