• C# i .NET
  • LINQ i foreach w C# - zasady, pułapki i dobre praktyki

LINQ i foreach w C# - zasady, pułapki i dobre praktyki

Radosław Krajewski 23 lipca 2026
Konwersja pętli `foreach` na zapytanie LINQ w C#. Kod filtruje `greetings` według długości.

Spis treści

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

  • Where filtruje elementy przed rozpoczęciem pętli.
  • Select zmienia 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.
  • List.ForEach nie jest operatorem LINQ i najlepiej stosować go tylko do prostych akcji.

Diagram przedstawia .NET LINQ, umożliwiający zapytania do obiektów, baz danych i XML. C# i inne języki używają LINQ do przetwarzania danych, podobnie jak pętla 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 wykorzystywaniu Select 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.ForEach. 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.ForEach 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.

FAQ - Najczęstsze pytania

LINQ warto wykorzystać do filtrowania, sortowania i projekcji danych, na przykład przez Where, OrderBy i Select. foreach sprawdza się przy efektach ubocznych, kilku instrukcjach, obsłudze wyjątków oraz potrzebie użycia break lub continue.

Wiele zapytań operujących na IEnumerable<T> korzysta z opóźnionego wykonania. Filtr jest stosowany dopiero podczas enumeracji, na przykład przez foreach, ToList(), Count() albo First(). Jeśli źródłowa kolekcja zmieni się wcześniej, wynik zapytania również może się zmienić.

ToList() wymusza natychmiastowe wykonanie zapytania i zapisuje wynik w nowej liście. Warto go użyć, gdy dane mają zostać ustalone, przejrzane wielokrotnie lub gdy chcesz uniknąć ponownego wykonania kosztownego zapytania. Przy dużych kolekcjach trzeba uwzględnić dodatkowe zużycie pamięci.

foreach działa na obiektach implementujących IEnumerable<T> i pozwala stosować rozbudowaną logikę, obsługę wyjątków, break oraz continue. List<T>.ForEach działa na liście i pasuje głównie do jednej krótkiej akcji dla każdego elementu, ponieważ nie zapewnia wygodnej kontroli przepływu.

Oceń artykuł

Ocena: 0.00 Liczba głosów: 0

Tagi

linq
foreach
iqueryable
opóźnione wykonanie
Autor Radosław Krajewski
Radosław Krajewski
Nazywam się Radosław Krajewski i od 6 lat zgłębiam tajniki programowania .NET, chmury Azure oraz sztucznej inteligencji. Moja przygoda z tymi technologiami zaczęła się od fascynacji tym, jak złożone problemy można rozwiązywać za pomocą kodu i innowacyjnych narzędzi. Staram się przekazywać tę wiedzę w sposób zrozumiały, dzieląc się swoimi doświadczeniami i spostrzeżeniami na kursdotnet.pl. W moich artykułach skupiam się na praktycznych aspektach, porównuję różne rozwiązania i analizuję najnowsze trendy, aby dostarczyć Wam rzetelne i aktualne informacje, które pomogą Wam rozwijać się w tej dynamicznie zmieniającej się dziedzinie.

Udostępnij artykuł

Napisz komentarz