Wszystkie wpisy

Pętle to mycie talerzy

Mycie talerzy - powtarzasz ten sam ruch, dopóki zlew nie jest pusty. Tyle.


Zlew pełen talerzy #

Wyobraź sobie zlew pełen brudnych talerzy po niedzielnym obiedzie u babci. Ktoś musi to umyć. Co robisz?

  1. Bierzesz jeden talerz.
  2. Myjesz go.
  3. Odkładasz na suszarkę.
  4. Sprawdzasz: czy w zlewie coś jeszcze zostało?
  5. Jeśli tak - wracasz do kroku 1.

To jest pętla. Tyle. Nie ma w tym żadnej magii.

Trzy rzeczy, które musisz wiedzieć #

Każda pętla - nieważne w jakim języku - potrzebuje trzech rzeczy, żeby nie oszaleć:

  • Punkt startu - „weź pierwszy talerz”. Inaczej nie wiesz, skąd zacząć.
  • Warunek przerwania - „myj, dopóki zlew nie jest pusty”. Bez tego myjesz w nieskończoność.
  • Ruch do przodu - „po umyciu talerza, sięgnij po następny”. Bez tego myjesz w kółko ten sam talerz.

Zapomnij chociaż jedno, a masz pętlę nieskończoną - komputer próbuje umyć jeden talerz przez wieczność, aż karta graficzna się rozpłacze.

W kodzie #

const talerze = ['obiadowy', 'deserowy', 'filiżanka'];

for (const talerz of talerze) {
  console.log(`Myję: ${talerz}`);
}

Pamiętaj: Pętla to nie jest „zaawansowana technika”. To mycie talerzy. Jak umiesz umyć zlew po obiedzie, umiesz napisać pętlę.

Kiedy uważać #

Jeśli w pracy napiszesz pętlę, która ma milion talerzy i każdy myjesz przez sekundę - przeglądarka zamrozi się na 17 minut. Zanim napiszesz pętlę, zadaj pytanie: ile talerzy naprawdę będzie w zlewie?

Cztery konstrukcje, jeden cel #

Pętla to sposób na wykonanie tego samego kodu wiele razy. W C# masz kilka wariantów, różnią się nie tyle co robią, co jak wygodnie i szybko:

  • for - indeksowana iteracja, znasz start, koniec i krok.
  • foreach - iteracja po IEnumerable<T>, nie musisz znać rozmiaru.
  • while / do-while - iteracja do spełnienia warunku, bez z góry znanej liczby kroków.
  • LINQ / metody wyższego rzędu - Select, Where, Aggregate - deklaratywne, pod spodem i tak iterują.

Anatomia pętli - trzy elementy #

Każda pętla (nawet ta opakowana w LINQ) musi mieć te trzy rzeczy, inaczej się rozsypuje:

  1. Stan początkowy - np. indeks i = 0, iterator IEnumerator<T>.
  2. Warunek kontynuacji - i < list.Count, enumerator.MoveNext().
  3. Aktualizację stanu - i++, enumerator przesuwa się do następnego elementu.

Pominięcie aktualizacji = nieskończona pętla. while (true) bez break to legalne użycie (event loop, serwer), ale musisz mieć inną drogę wyjścia.

for vs foreach - co kosztuje #

// title: LoopBench.cs
var list = Enumerable.Range(0, 1_000_000).ToList();

// for - indeksowany dostęp, best dla List<T>
long sum = 0;
for (int i = 0; i < list.Count; i++)
    sum += list[i];

// foreach - przez IEnumerator<T>, alokuje enumerator (struct w List<T>, klasa w IEnumerable<T>)
long sum2 = 0;
foreach (var x in list)
    sum2 += x;

Dla List<T> foreach jest zoptymalizowany (struct enumerator, nie alokuje na stercie). Dla IEnumerable<T> już tak - dostajesz boxing i GC pressure przy tight loopach.

LINQ - wygoda kosztem alokacji #

// Deklaratywnie
var sum = list.Where(x => x % 2 == 0).Select(x => x * 2).Sum();

Pod spodem: każdy operator to nowy iterator (klasa), każde lambda to delegate. Dla 1000 elementów niezauważalne. Dla 10M w hot path - mierzalne. BenchmarkDotNet + MemoryDiagnoser pokaże.

Pętle równoległe #

Gdy operacja na elemencie jest niezależna od innych, można rozbić iterację na wątki:

// PLINQ
var sum = list.AsParallel().Sum();

// Parallel.ForEach - bardziej kontroli
Parallel.ForEach(list, item => ProcessItem(item));

Zasady: mierz przed. Parallel ma narzut (task scheduling, partitioning). Dla taniej operacji (< ~1 us / element) sekwencyjne jest szybsze. Dla I/O nie używaj Parallel.ForEach - to biblioteka dla CPU-bound. Przy I/O - await Task.WhenAll.

Złożoność - licz zanim napiszesz #

Pętla w pętli = O(n²). Dla n = 10_000: sto milionów operacji. Przy nanosekundzie na operację - 100 ms. Przy milisekundzie (np. zapytanie do bazy w środku) - 100 sekund.

Najczęstszy bug produkcyjny początkującego: N+1 queries.

// title: N+1 - zły wzorzec
foreach (var user in users)                    // 1 query: SELECT * FROM users
{
    var orders = db.Orders.Where(o => o.UserId == user.Id).ToList();  // N queries
    // ...
}

Fix: JOIN lub Include / projekcja:

var result = db.Users
    .Include(u => u.Orders)
    .ToList();  // 1 query z JOIN

Wczesne wyjście - break, return, continue #

  • break - przerywa całą pętlę.
  • continue - pomija resztę iteracji, przechodzi do warunku.
  • return - wychodzi z metody, też z pętli.

W LINQ odpowiednikami są First, Any, TakeWhile - zatrzymują iterację gdy warunek spełniony (są lazy).

Wzorce, które warto znać #

  • Sliding window - dwie zmienne (left, right), obie przesuwają się warunkowo. Linear scan zamiast zagnieżdżonego for.
  • Two pointers - dla posortowanych danych, od końca i początku.
  • State machine - switch + zmienna stanu wewnątrz while.
  • Enumerator dla strumieni - IAsyncEnumerable<T> (od C# 8) dla asynchronicznej iteracji (np. streaming z bazy, SignalR).

Skrótowo #

Pętla to kontrakt: warunek początkowy + warunek stopu + postęp. Zmień dowolną z tych rzeczy na podejrzaną, a dostaniesz buga. Dla większości pracy z kolekcjami w C# używaj foreach (czytelność), LINQ tam gdzie agregujesz / filtrujesz, for gdy potrzebujesz indeksu lub performance, while gdy nie znasz liczby iteracji z góry.

Podobne wpisy

🔗 Linkują tu