Gdy dane są już zebrane w kolekcji, najczęściej chcemy zrobić dwie rzeczy: wybrać właściwe elementy i przejść po nich w celu wykonania konkretnej operacji. Połączenie LINQ z pętlą foreach pozwala rozdzielić te zadania w czytelny sposób, a przy okazji uniknąć ręcznego filtrowania i wielu warunków zagnieżdżonych w kodzie. Pokażę praktyczne przykłady, wyjaśnię opóźnione wykonanie zapytań oraz porównam foreach z metodą ForEach.
LINQ przygotowuje dane, a foreach wykonuje operację na wynikach
-
Wherefiltruje elementy przed rozpoczęciem pętli. -
Selectzmienia dane w nowy kształt, ale nie służy do wykonywania efektów ubocznych. - Zapytanie LINQ często wykonuje się dopiero podczas enumeracji przez
foreach. -
ToList()wymusza natychmiastowe wykonanie i zapisuje wyniki w pamięci. -
Listnie jest operatorem LINQ i najlepiej stosować go tylko do prostych akcji..ForEach

Co daje połączenie LINQ i foreach
LINQ najlepiej sprawdza się wtedy, gdy chcę opisać jakie elementy są mi potrzebne. Pętla foreach odpowiada za to, co zrobię z każdym elementem po odfiltrowaniu kolekcji. Taki podział zwykle poprawia czytelność, bo logika wyszukiwania nie miesza się z logiką prezentacji, zapisu lub wysyłania danych.
var orders = new List
{
new(1, "Anna", 250m, true),
new(2, "Piotr", 80m, false),
new(3, "Ola", 420m, true)
};
var paidOrders = orders
.Where(order => order.IsPaid)
.OrderByDescending(order => order.Total);
foreach (var order in paidOrders)
{
Console.WriteLine($"{order.Customer}: {order.Total:C}");
}
public record Order(int Id, string Customer, decimal Total, bool IsPaid);
W tym przykładzie LINQ wybiera opłacone zamówienia i sortuje je według wartości. Pętla tylko je odczytuje. To drobna różnica w zapisie, ale w większej aplikacji oznacza mniej warunków, łatwiejsze testowanie i prostsze zmienianie poszczególnych etapów.
Nie traktuję jednak LINQ jako zamiennika każdej pętli. Gdy w środku iteracji pojawia się wiele instrukcji, obsługa błędów albo kilka efektów ubocznych, klasyczne foreach bywa po prostu bardziej zrozumiałe. LINQ powinien upraszczać kod, a nie zamieniać go w trudny do debugowania łańcuch metod.
Najprostszy schemat pracy
W praktyce najczęściej wystarczy zastosować trzy kroki. Najpierw filtruję dane, później opcjonalnie wybieram lub przekształcam potrzebne właściwości, a na końcu przechodzę po wyniku w pętli.
var activeNames = users
.Where(user => user.IsActive)
.Select(user => user.Name);
foreach (var name in activeNames)
{
Console.WriteLine(name);
}
foreach nie potrzebuje listy. Może iterować po dowolnym obiekcie implementującym IEnumerable, dlatego wynik zapytania LINQ można wykorzystać bez wcześniejszego wywoływania ToList().
Jak filtrować i przekształcać dane przed pętlą
Największą zaletą tego podejścia jest możliwość przygotowania dokładnie takiego strumienia danych, jakiego potrzebuje dalsza część programu. Zamiast sprawdzać każdy warunek w pętli, przenoszę selekcję do Where, a wybór właściwości do Select.
var reports = employees
.Where(employee => employee.Department == "IT")
.Where(employee => employee.YearsOfExperience >= 3)
.Select(employee => new
{
employee.Name,
Level = employee.YearsOfExperience >= 8 ? "Senior" : "Regular"
});
foreach (var report in reports)
{
Console.WriteLine($"{report.Name} - {report.Level}");
}
Dwa wywołania Where można połączyć w jeden warunek, ale rozdzielenie ich często poprawia czytelność i ułatwia późniejsze modyfikacje. W kodzie produkcyjnym zwracam uwagę na to, czy każdy etap ma jasny cel. Jeśli łańcuch LINQ zaczyna przypominać osobny język, rozbijam go na kilka nazwanych zmiennych.
Select nie powinien zmieniać obiektów przy okazji
Częsty błąd polega na wykorzystywaniuSelect do wykonywania operacji, zamiast do projekcji. Projekcja oznacza utworzenie nowego kształtu danych, na przykład wybranie nazwy lub zbudowanie obiektu DTO.
// Czytelna projekcja
var names = users
.Where(user => user.IsActive)
.Select(user => user.Name);
// Niepotrzebny efekt uboczny w Select
var ignored = users.Select(user =>
{
Console.WriteLine(user.Name);
return user;
});
Drugi wariant może zadziałać, ale jest mylący. Czytelnik spodziewa się, że Select przekształci dane, a nie wypisze je na ekran. Do efektów ubocznych, takich jak logowanie, aktualizacja stanu lub zapis do pliku, lepiej użyć jawnego foreach.
Filtrowanie po kilku warunkach
Warto pamiętać o zachowaniu operatorów logicznych. Where przyjmuje predykat, czyli funkcję zwracającą true albo false dla każdego elementu.
var importantTasks = tasks
.Where(task =>
!task.IsCompleted &&
task.Priority >= 8 &&
task.DueDate <= DateTime.UtcNow.AddDays(2));
foreach (var task in importantTasks)
{
ProcessTask(task);
}
Tak zapisany warunek jest łatwy do odczytania, ale przy bardziej rozbudowanej logice przeniósłbym ją do osobnej metody. Dzięki temu można ją niezależnie przetestować i wykorzystać w innym miejscu.
Kiedy zapytanie LINQ wykonuje się naprawdę
Najważniejszą cechą wielu zapytań LINQ jest opóźnione wykonanie. Samo utworzenie zmiennej IEnumerable zazwyczaj nie powoduje jeszcze przejścia po kolekcji. Elementy są pobierane dopiero wtedy, gdy wynik zostanie wyliczony, na przykład przez foreach, ToList(), Count() albo First().
var query = products
.Where(product => product.Price > 100);
Console.WriteLine("Zapytanie utworzone");
foreach (var product in query)
{
Console.WriteLine(product.Name);
}
W tym przypadku filtr zostanie zastosowany podczas pętli. Jeśli źródłowa kolekcja zmieni się między utworzeniem zapytania a jego enumeracją, wynik może również się zmienić. To bywa wygodne, ale czasem prowadzi do zaskoczeń.
ToList() jako punkt kontrolny
Jeśli chcę pobrać dane tylko raz i pracować na stałym zestawie, materializuję wynik. ToList() wykonuje zapytanie natychmiast i zapisuje elementy w nowej liście.
var visibleProducts = products
.Where(product => product.IsVisible)
.ToList();
products.Add(new Product("Nowy produkt", true));
foreach (var product in visibleProducts)
{
Console.WriteLine(product.Name);
}
Nowy produkt nie pojawi się w visibleProducts, ponieważ lista została już utworzona. Nie dodaję jednak ToList() automatycznie. Przy dużych kolekcjach oznacza dodatkową pamięć i natychmiastowe wykonanie całego zapytania.
Jeżeli wynik ma być przejrzany tylko raz, bez potrzeby przechowywania go w pamięci, zwykłe foreach po IEnumerable może być lepszym wyborem. Z kolei gdy wynik będzie używany kilkukrotnie, materializacja często chroni przed wielokrotnym wykonywaniem tych samych operacji.
IEnumerable a IQueryable
W przypadku kolekcji w pamięci LINQ działa jako LINQ to Objects. Gdy źródłem jest na przykład Entity Framework Core, możemy pracować na IQueryable. Wtedy dostawca może przetłumaczyć część zapytania na SQL i wykonać filtrowanie po stronie bazy danych.
var customers = dbContext.Customers
.Where(customer => customer.IsActive)
.Select(customer => new
{
customer.Id,
customer.Name
});
foreach (var customer in customers)
{
Console.WriteLine(customer.Name);
}
To ważne dla wydajności, bo baza może zwrócić tylko potrzebne rekordy i kolumny. Nie każda metoda C# da się jednak przetłumaczyć na SQL. Dlatego przy IQueryable sprawdzam, co rzeczywiście wykonuje dostawca, zamiast zakładać, że cała logika działa identycznie jak dla listy w pamięci.
foreach czy ForEach i kiedy wybrać zwykłą pętlę
W kodzie C# spotkasz również metodę List. Jej zapis jest krótszy, ale nie jest to operator LINQ i ma inne ograniczenia niż klasyczna pętla.
| Rozwiązanie | Najlepsze zastosowanie | Ograniczenia |
|---|---|---|
foreach |
Rozbudowana logika, warunki, break, continue i obsługa wyjątków |
Więcej kodu niż przy krótkiej lambdzie |
List |
Jedna prosta akcja dla każdego elementu listy | Działa na liście, nie daje wygodnego break ani continue
|
LINQ plus foreach
|
Filtrowanie, sortowanie i czytelne przetwarzanie wyników | Trzeba uważać na opóźnione wykonanie i efekty uboczne |
var activeUsers = users
.Where(user => user.IsActive)
.ToList();
activeUsers.ForEach(user => SendNotification(user));
Taki zapis jest akceptowalny, gdy akcja jest krótka i oczywista. Sam częściej wybieram foreach, gdy operacja może się rozrosnąć albo wymaga logowania i obsługi wyjątków.
foreach (var user in activeUsers)
{
try
{
SendNotification(user);
}
catch (NotificationException exception)
{
logger.LogError(exception, "Nie udało się wysłać powiadomienia");
}
}
Nie próbowałbym też zastępować pętli wywołaniem ForEach tylko po to, żeby zmieścić kod w jednej linii. Krótkość zapisu nie jest równoznaczna z czytelnością, szczególnie gdy później trzeba dodać warunek zakończenia lub reakcję na błąd.
Jeżeli pracujesz z własnymi typami, generyki pomagają zachować bezpieczeństwo typów w kolekcjach i wynikach LINQ. W praktyce przydatne będzie szersze omówienie, czym są generyki w C# i jak wpływają na jakość kodu.
Błędy, które psują kod i wydajność
Zmiana kolekcji podczas iteracji
Nie usuwam ani nie dodaję elementów do kolekcji, po której aktualnie przechodzę. W typowym przypadku kończy się to wyjątkiem InvalidOperationException.
// Błąd
foreach (var item in items)
{
if (item.IsInvalid)
{
items.Remove(item);
}
}
Bezpieczniej utworzyć osobną listę elementów do usunięcia albo użyć RemoveAll dla List. Można też iterować po kopii, ale trzeba pamiętać o dodatkowym zużyciu pamięci.
items.RemoveAll(item => item.IsInvalid);
Wielokrotne wykonywanie tego samego zapytania
Zapytanie z opóźnionym wykonaniem może zostać wykonane za każdym razem, gdy przechodzę po nim w pętli. To szczególnie istotne przy źródłach bazodanowych i kosztownych obliczeniach.
var query = dbContext.Products
.Where(product => product.IsAvailable);
foreach (var product in query)
{
LogProduct(product);
}
foreach (var product in query)
{
CacheProduct(product);
}
Jeżeli potrzebuję dwóch przejść po tych samych danych, zwykle materializuję wynik raz. Przy połączeniu z bazą ogranicza to liczbę zapytań i sprawia, że dalsza część kodu pracuje na ustalonym zestawie rekordów.
var availableProducts = await dbContext.Products
.Where(product => product.IsAvailable)
.ToListAsync();
Brak kontroli nad null
LINQ nie rozwiązuje automatycznie problemu pustych referencji. Jeśli kolekcja może być równa null, sama pętla nie wystarczy. W zależności od sytuacji używam walidacji, operatora ?? albo gwarantuję utworzenie pustej kolekcji już w modelu.
var safeUsers = users ?? Enumerable.Empty();
foreach (var user in safeUsers)
{
Console.WriteLine(user.Name);
}
Pusta kolekcja jest zwykle lepsza niż null, bo pozwala bezpiecznie wykonać zapytanie i pętlę bez dodatkowych warunków. Przy projektowaniu własnych klas pomaga tu konsekwentne używanie właściwości kolekcji inicjalizowanych pustą listą.
Przeczytaj również: Długość listy w C# - Count, Length czy Capacity?
Wkładanie całej logiki domenowej do lambdy
Jeśli predykat w Where ma kilkanaście warunków, a ciało foreach wykonuje kilka niezależnych operacji, kod szybko traci przejrzystość. W takim przypadku wydzielam metody o nazwach opisujących intencję, na przykład IsEligibleForDiscount albo ArchiveOrder.
To także dobrze współgra z programowaniem obiektowym, gdzie obiekt powinien odpowiadać za własne reguły i zachowanie. Przydatne podstawy tego podejścia znajdziesz w materiale o programowaniu obiektowym w C#.
Jak podejść do tematu w codziennym kodzie .NET
Najpraktyczniejsza zasada jest prosta. LINQ służy mi do opisania wyboru danych, a foreach do wykonania jawnej operacji na każdym wyniku. Dzięki temu od razu widać, gdzie znajduje się filtr, gdzie projekcja, a gdzie efekt działania programu.
- Użyj
Where, gdy chcesz ograniczyć kolekcję. - Użyj
Select, gdy chcesz zmienić kształt danych. - Użyj
OrderBy, gdy kolejność wyników ma znaczenie. - Użyj
ToList(), gdy chcesz wymusić wykonanie i zachować wyniki. - Użyj
foreach, gdy wykonujesz operację, która ma efekt uboczny lub wymaga kilku instrukcji.
W prostych przypadkach możesz połączyć LINQ i foreach w kilku liniach. W bardziej wymagających fragmentach zadbaj o nazwy zmiennych, rozdzielenie etapów i świadome miejsce wykonania zapytania. To właśnie te decyzje, a nie samo użycie konkretnego operatora, najczęściej robią różnicę w jakości kodu.
Gdy nauczysz się rozróżniać przygotowanie danych od ich przetwarzania, pętla foreach przestaje konkurować z LINQ. Obie konstrukcje zaczynają pełnić różne role, a kod C# staje się krótszy, bardziej przewidywalny i łatwiejszy do rozwijania.
