CodeGym /Kursy /C# SELF /Przechwytywanie zmiennych ( Closures)

Przechwytywanie zmiennych ( Closures)

C# SELF
Poziom 49, Lekcja 4
Dostępny

1. Co to jest zamknięcie?

W programowaniu zamknięcie (closure) — to funkcja, która przechwytuje zmienne z zewnętrznego kontekstu. Mówiąc prościej, jeśli wyrażenie lambda lub metoda anonimowa używa zmiennych zadeklarowanych poza swoim ciałem, taka funkcja staje się zamknięciem. „Pamięta”, jakie wartości miały te zmienne w momencie stworzenia.

Analogio z życia:
Wyobraź sobie, że zapisałeś sekretne przepis na kartce i schowałeś ją w kopercie. Nawet jeśli sama kartka potem zaginie lub nie będzie dostępna (zmienna stanie się niedostępna bezpośrednio), ktoś z tą kopertą (lambda) wciąż ma dostęp do przepisu.

Prosty przykład:

int x = 42;
Func<int> getX = () => x;
Console.WriteLine(getX()); // 42

Tutaj getX — to zamknięcie, bo używa zmiennej x zadeklarowanej na zewnątrz.

2. Dlaczego przechwytywanie zmiennych jest ważne?

W C# zamknięcia są używane praktycznie wszędzie:

  • W kolekcjach i LINQ-zapytaniach
  • Do przekazywania parametrów do zdarzeń lub metod asynchronicznych
  • Przy tworzeniu handlerów zdarzeń wewnątrz pętli
  • Do przechowywania „kontekstu” między różnymi wywołaniami

Bez zamknięć wiele standardowych praktyk w C# byłoby niemożliwych lub bardzo niepraktycznych.

Przykład z życia

Wyobraźmy sobie, że tworzymy aplikację przypominającą: użytkownik ustawia serię przypomnień, i kiedyś (za minutę, godzinę, tydzień...) ma się pojawić odpowiednia wiadomość. Łatwo przekazać do handlera lambdę, która „zapamięta”, co dokładnie przypominać. To właśnie przechwytywanie zmiennych — klasyka.

3. Jak C# realizuje przechwytywanie zmiennych

Za kulisami C# robi sprytny trik: kiedy masz wyrażenie lambda używające zmiennych zewnętrznych, kompilator automatycznie tworzy pomocniczą klasę — display class. Wszystkie „przechwycone” zmienne stają się polami tej klasy.

Schemarycznie wygląda to tak:


Zewnętrzna zmienna ──► DisplayClass
                         ▲
                         │
                    Zamknięcie (lambda)

Ilustracja w kodzie

Oto co się dzieje „pod maską”:

int x = 5;
Func<int> f = () => x;
// Tutaj kompilator robi mniej więcej tak:
class DisplayClass
{
    public int x;
    public int Lambda() => x;
}
DisplayClass display = new DisplayClass();
display.x = 5;
Func<int> f = display.Lambda;

To wyjaśnia, dlaczego zamknięcie ciągle widzi aktualną wartość zmiennej, nawet po opuszczeniu bloku, w którym została zadeklarowana.

4. Czy wartość zmiennej jest „zamrożona” czy się zmienia?

W C# zmienne są przechwytywane przez referencję, a nie przez wartość. To znaczy, jeśli lambda używa zmiennej, a ta zmienna zostanie zmieniona gdzie indziej — lambda zobaczy nowe wartość.

Przykład:

int x = 10;
Func<int> getX = () => x;

x = 20;
Console.WriteLine(getX()); // 20, a nie 10!

Studenci często oczekują, że getX() zawsze zwróci 10, bo zmienna została „przechwycona”. Jednak w rzeczywistości lambda odczytuje zmienną, która nadal istnieje i którą można zmieniać.

Kiedy wartość jednak zostaje zafiksowana?

Jeśli zmienna jest zadeklarowana w pętli z nowym zakresem, np. przy użyciu foreach, i dla każdej iteracji tworzona jest nowa zmienna — lambda „zapamięta” bieżącą wartość.

5. Przykłady: zamknięcie w pętli — typowa pułapka

Często spotykany błąd

Chcemy stworzyć tablicę delegatów, z których każdy wypisuje swój numer z pętli:

Action[] actions = new Action[5];
for (int i = 0; i < 5; i++)
{
    actions[i] = () => Console.WriteLine(i);
}
foreach (var action in actions)
    action();

Co wypisze program?

5
5
5
5
5

O kurczę! Dlaczego nie 0,1,2,3,4?

Powód:
Lambda przechwytuje tę samą zmienną i, która nadal się zmienia w trakcie pętli. Kiedy później wywołasz delegaty, i już równa się 5.

Jak poprawnie?

Trzeba stworzyć osobną zmienną w ciele pętli:

Action[] actions = new Action[5];
for (int i = 0; i < 5; i++)
{
    int index = i; // Nowa zmienna dla każdej iteracji!
    actions[i] = () => Console.WriteLine(index);
}
foreach (var action in actions)
    action();

Teraz program wypisze:

0
1
2
3
4

Jak to się wiąże z display class?

W pierwszym wariancie wszystkie delegaty „przyczepiają się” do jednego pola — stąd taki sam wynik. W drugim przypadku tworzona jest nowa lokalna zmienna dla każdej iteracji, więc dla każdego delegata formuje się osobny DisplayClass z unikalną wartością.

6. Praktyczne scenariusze użycia przechwytywania zmiennych

Przykład 1: Obsługa zdarzeń z „kontekstem”

Załóżmy, że w naszej małej aplikacji jest lista zadań i do każdego przypięty jest handler dla przycisku „wykonaj”. Potrzebujemy, żeby lambda wewnątrz handlera „pamiętała”, które zadanie obsłużyć:

foreach (var task in tasks)
{
    button.Click += (sender, e) => CompleteTask(task);
}

Tutaj zmienna task jest przechwytywana w każdej iteracji. Ważne, żeby upewnić się, że jest poprawnie zadeklarowana wewnątrz pętli, żeby nie wpaść w pułapkę jak w przykładzie powyżej.

Przykład 2: Operacje asynchroniczne

Często zamknięcia używa się do przekazania parametrów do logiki asynchronicznej — np. zapisania zmiennej w lokalnym „slocie” przy uruchamianiu zadania asynchronicznego:

for (int i = 0; i < 3; i++)
{
    int index = i; // Koniecznie!
    Task.Run(() => Console.WriteLine($"Task #{index}"));
}

Bez lokalnej zmiennej wszystkie zadania wypiszą ten sam numer, co zwykle nie jest tym, czego chcemy.

Przykład 3: Zapytania LINQ

LINQ do kolekcji często używa zamknięć, aby filtrować lub przekształcać elementy z uwzględnieniem zmiennych z zewnętrznego zakresu. Na przykład:

string prefix = "Task";
var filtered = tasks.Where(t => t.Name.StartsWith(prefix));

Tutaj lambda w Where zapamiętała wartość prefix i wywołuje metodę StartsWith.

7. Cechy, ograniczenia i typowe błędy przy pracy z zamknięciami

Błąd nr 1: używanie jednej zmiennej w pętli dla wszystkich delegatów.
Jeśli w pętli wszystkie delegaty odnoszą się do tej samej zmiennej, wynik będzie nieoczekiwany. Ważne jest tworzenie nowej lokalnej zmiennej wewnątrz pętli dla każdego delegata, żeby uniknąć wspólnej referencji.

Błąd nr 2: zamknięcia na zmienne poza metodą.
Jeśli zamknięcie przechwytuje pole klasy lub zmienną zadeklarowaną poza bieżącą metodą, będzie trzymać referencję do tej zmiennej. To może prowadzić do wycieków pamięci, ponieważ garbage collector nie będzie mógł zwolnić obiektu, dopóki istnieją referencje z zamknięć.

Błąd nr 3: długożywotne delegaty z zamknięciami.
Jeśli delegat z zamknięciem jest przechowywany długo (np. w polu statycznym), zmienne, do których się odnosi, też pozostaną w pamięci dłużej niż się spodziewamy. To często powoduje ukryte wycieki pamięci i problemy z wydajnością.

2
Zadanie
C# SELF, poziom 49, lekcja 4
Niedostępne
Proste domknięcie
Proste domknięcie
1
Ankieta/quiz
Wyrażenia lambda, poziom 49, lekcja 4
Niedostępny
Wyrażenia lambda
Składnia wyrażeń lambda
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION