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ącainti zwracającabool.Action<T1, T2, ...>- funkcja bez zwracanej wartości (void). Przykład:Action<string>przyjmujestringi 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:
- Źródło danych - kolekcja, plik, baza.
- Definicja zapytania - łańcuch operatorów (
Where,OrderBy,Select…). - 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)- zwracatrue, jeśli co najmniej jeden element spełnia warunek.All(predykat)- zwracatrue, 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 #
| Metoda | Co zwraca | Co przy braku/wielu |
|---|---|---|
First() | pierwszy element | rzuca InvalidOperationException, jeśli pusta |
FirstOrDefault() | pierwszy element lub wartość domyślna | zwraca default(T) (czyli null dla klas) |
Single() | dokładnie jeden element | rzuca wyjątek, jeśli zero lub więcej niż jeden |
SingleOrDefault() | jeden albo wartość domyślna | rzuca tylko przy wielu, dla zera zwraca default |
ElementAt(n) | element o indeksie n | rzuca 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:
| Metoda | Zwraca |
|---|---|
.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), typyFunciAction, 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 -
Wherefiltruje,OrderBy/OrderByDescendingsortują,Selectprzekształ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
Generyki i kolekcje w C# - List, Dictionary, ograniczenia typów
Dlaczego powstały generyki, jak działają List i Dictionary od podszewki oraz jak pisać własne bezpieczne kontenery z ograniczeniami where. Trzecia część kursu C#.
Programowanie obiektowe w C# - klasy, dziedziczenie, polimorfizm
Klasy i obiekty, hermetyzacja przez właściwości, dziedziczenie z virtual/override, polimorfizm w praktyce i interfejsy jako kontrakty. Druga część kursu C#.
Składnia i system typów C#
Średniki, klamry, stos vs sterta i parsowanie. Pierwszy krok w C# z interaktywnym quizem i mini-zadaniem na koniec.
🔗 Linkują tu