Wszystkie wpisy

LINQ w C# - lambdy, Where, Select i leniwa ewaluacja

Wyrażenia lambda, operator zapytań LINQ, łączenie metod w łańcuchy oraz pułapka leniwej ewaluacji - jak filtrować, sortować i przekształcać kolekcje jednym, czytelnym łańcuchem wywołań. Czwarta część kursu C#.


W poprzednim wpisie Generyki i kolekcje w C# zobaczyłeś, jak generyki uczyniły kolekcje bezpiecznymi typowo. Teraz nadszedł czas, by tymi kolekcjami zarządzać: filtrować, sortować, przekształcać oraz wyliczać statystyki. Do tego służy LINQ - zbiór operatorów osadzonych wprost w języku C#, działających jednolicie na każdej kolekcji, która implementuje IEnumerable<T>.

W tej części poznasz wyrażenia lambda (klucz do LINQ), trzy najważniejsze operatory (Where, OrderBy, Select), sposób łączenia ich w łańcuchy oraz pułapkę, która podstawia nogę nawet doświadczonym programistom - leniwą ewaluację.

1. Wyrażenia lambda - anonimowe funkcje w jednej linii #

Co to jest: wyrażenie lambda to anonimowa funkcja zapisana w zwięzłej formie. Microsoft nazywa je w dokumentacji lambda expressions, a operator => rozdzielający parametry od ciała - lambda declaration operator (operator deklaracji wyrażenia lambda).

Dlaczego istnieje: często potrzebujemy przekazać “kawałek logiki” jako argument - np. powiedzieć metodzie Where, który element ma przepuścić. Zamiast deklarować osobną metodę, lambda pozwala podać tę logikę w miejscu wywołania:

// Bez lambdy - osobna metoda dla jednorazowego użycia
bool IsAdult(int wiek) => wiek >= 18;
var adults = ages.Where(IsAdult);

// Z lambdą - logika tam, gdzie jest potrzebna
var adults = ages.Where(x => x >= 18);

Operator => czytamy najczęściej jako “przechodzi w” lub “dla którego”: x => x >= 18 to “dla danego x zwróć wartość wyrażenia x >= 18”.

Składnia w trzech wariantach #

// Brak parametrów - wymagane puste nawiasy
Action wypiszPusty = () => Console.WriteLine();

// Jeden parametr - nawiasy opcjonalne
Func<int, int> kwadrat = x => x * x;

// Wiele parametrów - nawiasy obowiązkowe, parametry oddzielone przecinkami
Func<int, int, bool> rowne = (x, y) => x == y;

// Wieloliniowe ciało w klamrach (statement lambda)
Action<string> przywitaj = imie =>
{
    string wiadomosc = $"Witaj, {imie}!";
    Console.WriteLine(wiadomosc);
};

Lambda kontra delegacja #

Pod spodem każda lambda staje się obiektem typu delegacji (delegate). Dwa najpopularniejsze typy ogólne to:

  • Func<T1, T2, ..., TResult> - funkcja zwracająca wartość. Ostatni parametr typu to typ zwracany. Przykład: Func<int, bool> to funkcja przyjmująca int i zwracająca bool.
  • Action<T1, T2, ...> - funkcja bez zwracanej wartości (void). Przykład: Action<string> przyjmuje string i nic nie zwraca.
Func<int, bool> czyDodatnia = n => n > 0;
bool wynik = czyDodatnia(5);              // true

Action<string> krzyknij = s => Console.WriteLine(s.ToUpper());
krzyknij("hej");                          // wypisze HEJ

Niuans: domknięcie (closure) #

Lambda może przechwytywać zmienne z otaczającego ją zakresu - tak zwane outer variables. Nawet jeśli te zmienne wyjdą ze swojego zakresu, lambda nadal je widzi, dopóki sama żyje:

int threshold = 100;
Func<int, bool> belowThreshold = x => x < threshold;   // 'threshold' przechwycone

Console.WriteLine(belowThreshold(50));   // true
threshold = 30;                          // zmiana zmiennej zewnętrznej
Console.WriteLine(belowThreshold(50));   // false - lambda widzi nową wartość!

To potężne, ale trzeba wiedzieć: lambda nie zamraża zmiennej, jedynie ją “widzi”. Jeśli zmienna się zmieni, lambda zobaczy nową wartość.

2. LINQ - wspólny język zapytań #

Co to jest: LINQ (Language Integrated Query) to zbiór operatorów wbudowanych w język C#, które pozwalają odpytywać dowolne źródło danych implementujące IEnumerable<T> - listy, słowniki, zbiory, tablice, pliki XML, bazy danych przez Entity Framework.

Dlaczego istnieje: różne źródła danych mają różne natywne języki zapytań (SQL dla baz, XQuery dla XML, ręczne pętle dla kolekcji w pamięci). LINQ unifikuje to - jedna składnia działa wszędzie. To owoc znajomości z poprzedniego wpisu o generykach: dzięki temu, że List<T>, Dictionary<TKey, TValue> i wszystkie inne kolekcje implementują IEnumerable<T>, automatycznie dostają wszystkie operatory LINQ.

Trzy części zapytania LINQ #

Dokumentacja Microsoftu opisuje każde zapytanie LINQ jako trzy odrębne kroki:

  1. Źródło danych - kolekcja, plik, baza.
  2. Definicja zapytania - łańcuch operatorów (Where, OrderBy, Select…).
  3. Wykonanie zapytania - moment, gdy zapytanie naprawdę przebiega po danych.

Bez świadomości tego rozdzielenia łatwo wpaść w pułapki, do których wrócimy w sekcji 5.

Dwie składnie: zapytaniowa i metodowa #

LINQ ma dwa równorzędne style zapisu:

// Składnia zapytaniowa (query syntax) - podobna do SQL
var rpgi = from g in gameLibrary
           where g.Genre == "RPG"
           orderby g.Price descending
           select g.Title;

// Składnia metodowa (method syntax) - łańcuch metod rozszerzających z lambdami
var rpgi = gameLibrary
    .Where(g => g.Genre == "RPG")
    .OrderByDescending(g => g.Price)
    .Select(g => g.Title);

Obie są dokładnie równoważne - kompilator i tak przekłada składnię zapytaniową na metodową. W praktyce zdecydowana większość kodu używa składni metodowej, ponieważ jest bardziej elastyczna (część operatorów, np. Count, Sum, First, nie ma odpowiednika w składni zapytaniowej). Dalej w tym wpisie pozostajemy przy składni metodowej.

Nasza testowa baza w pamięci #

public class Game
{
    public string Title { get; set; }
    public double Price { get; set; }
    public string Genre { get; set; }
}

List<Game> gameLibrary = new List<Game>
{
    new Game { Title = "Wiedzmin 3",     Price = 150.0,  Genre = "RPG" },
    new Game { Title = "FIFA 23",        Price = 250.0,  Genre = "Sportowa" },
    new Game { Title = "Cyberpunk 2077", Price = 199.99, Genre = "RPG" },
    new Game { Title = "Stardew Valley", Price = 50.0,   Genre = "Symulacja" }
};

3. Wielka trójca: Where, OrderBy, Select #

Where - filtrowanie #

Co robi: zwraca wyłącznie te elementy, dla których lambda-predykat (czyli funkcja zwracająca bool) zwróciła true.

// Same gry z gatunku RPG
var gryRpg = gameLibrary.Where(g => g.Genre == "RPG");

// Same gry tańsze niż 100 zł
var cheap = gameLibrary.Where(g => g.Price < 100);

Predykat może być dowolnie złożony - i jest zwykłym kodem C#: można w nim wywoływać metody, łączyć warunki przez && i ||, sięgać po stałe.

var rpgiPonizej200 = gameLibrary
    .Where(g => g.Genre == "RPG" && g.Price < 200);

OrderBy - sortowanie rosnąco i malejąco #

Co robi: sortuje sekwencję po wybranym kluczu, którym jest wartość zwrócona przez lambdę.

// Alfabetycznie po tytule (rosnąco)
var alfabetycznie = gameLibrary.OrderBy(g => g.Title);

// Od najdroższej do najtańszej (malejąco)
var mostExpensive = gameLibrary.OrderByDescending(g => g.Price);

Gdy dwa elementy mają ten sam klucz pierwszego sortowania, sięgasz po ThenBy/ThenByDescending:

var wgGatunkuPotemCeny = gameLibrary
    .OrderBy(g => g.Genre)
    .ThenByDescending(g => g.Price);

Select - projekcja, czyli przekształcenie #

Co robi: mapuje każdy element wejściowy na element wyjściowy. Typ wyjścia nie musi odpowiadać typowi wejścia - to często najtrudniejsza do oswojenia, ale najpotężniejsza metoda LINQ.

// Z listy obiektów Gra wyciągamy same tytuły - typ wynikowy to IEnumerable<string>
var titlesOnly = gameLibrary.Select(g => g.Title);

Z poziomu LINQ możesz też w locie wyliczać nowe pola lub tworzyć typ anonimowy - lekki obiekt opisany w jednym wyrażeniu, bez deklarowania osobnej klasy:

// Ceny przeliczone na euro - typ anonimowy z dwoma polami
var pricesInEur = gameLibrary.Select(g => new
{
    Name     = g.Title,
    PriceEur = g.Price / 4.30
});

Kompilator generuje pod spodem zwykłą klasę, którą widzi tylko ten fragment kodu. Idealne do przejściowych wyników w obrębie jednej metody.

4. Łączenie metod i pozostałe operatory #

Operatory LINQ są metodami rozszerzającymi dla IEnumerable<T> - każda z nich zwraca nową sekwencję, którą można od razu podać następnej metodzie. To filozofia fluent API: wynik jednej operacji jest wejściem następnej.

// Zadanie: tytuły gier RPG, od najdroższej do najtańszej
var wynik = gameLibrary
    .Where(g => g.Genre == "RPG")        // 1. odfiltruj wszystko poza RPG
    .OrderByDescending(g => g.Price)        // 2. posortuj po cenie malejąco
    .Select(g => g.Title);                 // 3. zostaw same tytuły

foreach (string tytul in wynik)
{
    Console.WriteLine(tytul);
}
// Wypisze:
// Cyberpunk 2077
// Wiedzmin 3

Operatory agregujące - od razu zwracają wartość #

Niektóre operatory nie zwracają sekwencji, lecz pojedynczą wartość - i wykonują zapytanie natychmiast:

int ileRpg     = gameLibrary.Count(g => g.Genre == "RPG");     // 2
double average = gameLibrary.Average(g => g.Price);                // 162.4975
double suma    = gameLibrary.Sum(g => g.Price);                    // 649.99
double najwiek = gameLibrary.Max(g => g.Price);                    // 250
Game cheapest  = gameLibrary.OrderBy(g => g.Price).First();        // Stardew Valley

Operatory kwantyfikatorów: Any i All #

  • Any(predykat) - zwraca true, jeśli co najmniej jeden element spełnia warunek.
  • All(predykat) - zwraca true, jeśli wszystkie elementy spełniają warunek.
bool sąDarmowe   = gameLibrary.Any(g => g.Price == 0);             // false
bool wszystkieRpg = gameLibrary.All(g => g.Genre == "RPG");     // false
bool sąRpgi       = gameLibrary.Any(g => g.Genre == "RPG");     // true

Element po elemencie: First, Single, ElementAt #

MetodaCo zwracaCo przy braku/wielu
First()pierwszy elementrzuca InvalidOperationException, jeśli pusta
FirstOrDefault()pierwszy element lub wartość domyślnazwraca default(T) (czyli null dla klas)
Single()dokładnie jeden elementrzuca wyjątek, jeśli zero lub więcej niż jeden
SingleOrDefault()jeden albo wartość domyślnarzuca tylko przy wielu, dla zera zwraca default
ElementAt(n)element o indeksie nrzuca przy indeksie poza zakresem

Zasada ogólna: jeśli wyobrażasz sobie, że może nie być ani jednego pasującego elementu, sięgaj po wariant ...OrDefault i sprawdzaj wynik.

Partycjonowanie i odróżnianie #

var pierwsze2  = gameLibrary.Take(2);                         // pierwsze dwie
var bezPierwszej = gameLibrary.Skip(1);                       // wszystko prócz pierwszej
var unikalneGatunki = gameLibrary.Select(g => g.Genre).Distinct();

5. Pułapka: leniwa ewaluacja #

To najważniejsza rzecz, jaką musisz zrozumieć o LINQ. Gdy piszesz gameLibrary.Where(...), kod się nie wykonuje. C# zapisuje sobie jedynie przepis - obiekt opisujący, co należałoby zrobić. Faktyczne przejście po danych następuje dopiero w momencie, gdy próbujesz odczytać wynik:

  • w pętli foreach,
  • przez wywołanie metody materializującej (ToList, ToArray, ToDictionary, ToHashSet),
  • przez wywołanie metody zwracającej wartość pojedynczą (Count, Sum, First, Any…).

To zachowanie nazywamy leniwą ewaluacją (deferred execution).

Dlaczego to czasem zaskakuje #

Skoro zapytanie pamięta tylko przepis, każda zmiana w źródle danych przed enumeracją wpłynie na wynik:

List<int> liczby = new List<int> { 1, 2, 3 };
var parzyste = liczby.Where(n => n % 2 == 0);   // zapytanie zdefiniowane

liczby.Add(4);                                   // zmiana ŹRÓDŁA po definicji

foreach (int n in parzyste)
{
    Console.WriteLine(n);   // wypisze 2 i 4 - mimo że 4 dodaliśmy później!
}

Drugi, kosztowny skutek: jeśli enumerujesz to samo zapytanie wielokrotnie, każdorazowo czyta źródło od początku. Dla List<int> w pamięci to drobiazg. Dla zapytania do bazy danych - kilkanaście niepotrzebnych odpytań.

Jak wymusić natychmiastowe wykonanie #

Gdy chcesz zamrozić wynik w danym momencie - albo po prostu z niego korzystać wielokrotnie bez ponownego liczenia - skończ łańcuch jedną z metod materializujących:

MetodaZwraca
.ToList()List<T>
.ToArray()T[]
.ToDictionary(klucz)Dictionary<TKey, TValue>
.ToHashSet()HashSet<T>
// Bezpieczne, "zamrożone" wyniki:
List<Game> rpgi = gameLibrary
    .Where(g => g.Genre == "RPG")
    .ToList();

// Dalsze zmiany w gameLibrary nie wpłyną już na 'rpgi'.

Strumieniowanie kontra zbieranie wszystkiego #

Operatory leniwe dzielą się jeszcze na dwie podgrupy:

  • Strumieniujące (Where, Select, Take, Skip) - przepuszczają elementy jeden po drugim. Nie muszą czytać całego źródła, by zwrócić pierwszy wynik.
  • Niestrumieniujące (OrderBy, OrderByDescending, GroupBy, Reverse) - muszą zebrać wszystkie elementy przed zwróceniem pierwszego. Sortowanie nie da się zrobić “po kawałku”.

Praktyczne znaczenie: zapytanie kończące się na OrderBy na nieskończonym strumieniu zawiesi się, a kończące na samym Where, Select i Take(10) - nie.

Podsumowanie tematu #

W tej części kursu poznałeś:

  • Wyrażenia lambda - anonimowe funkcje z operatorem =>, trzy warianty składni (zero/jeden/wiele parametrów), typy Func i Action, mechanizm domknięcia
  • LINQ - zbiór operatorów na dowolnym IEnumerable<T>, dwie równoważne składnie (zapytaniowa i metodowa), trzy części każdego zapytania
  • Wielka trójca - Where filtruje, OrderBy/OrderByDescending sortują, Select przekształca (z możliwością typów anonimowych)
  • Łączenie metod - fluent API, łańcuchy operatorów oraz pomocnicy: Count, Sum, Average, Any, All, First/FirstOrDefault, Single, Take, Skip, Distinct
  • Leniwa ewaluacja - zapytanie to przepis, nie wynik; wykonuje się przy foreach, materializacji albo agregacji; metoda .ToList() zamraża wynik

W następnym wpisie Entity Framework Core zobaczysz, jak ten sam LINQ z dzisiejszego wpisu zaczyna generować SQL i odpytywać prawdziwą bazę relacyjną zamiast listy w pamięci.

Najpierw quiz i zadanie poniżej. Zadanie uruchomisz na żywo - przycisk Uruchom w modalu kompiluje twój kod na codapi.org.

Podobne wpisy

🔗 Linkują tu