CodeGym /Kursy /C# SELF /Usuwanie elementu kolekcji w pętli foreach

Usuwanie elementu kolekcji w pętli foreach

C# SELF
Poziom 29, Lekcja 4
Dostępny

1. Wprowadzenie

Prawie każdy, kto zaczyna programować w C#, prędzej czy później trafia na ten sam problem: jest kolekcja (np. lista obiektów) i trzeba z niej usunąć niepotrzebne elementy według jakiegoś warunku. Brzmi prosto i ręka sama sięga po znajomą, wygodną pętlę foreach, bo to przecież najbardziej “bezpieczny” i “przyjazny” sposób iteracji. Ale nagle, w najmniej oczekiwanym momencie, pojawia się tajemniczy błąd w czasie wykonania, którego nie było na prostych przykładach — i program zatrzymuje się bez ostrzeżenia.

Zobaczmy, dlaczego tak się dzieje, co się dzieje “pod maską” kolekcji i iteratorów oraz jak usuwać elementy z głową, żeby uniknąć niespodzianek i bugów.

Dlaczego foreach nie lubi usuwania elementów

Żeby poczuć, o co chodzi, wyobraź sobie kolejkę ludzi (to nasza kolekcja). Idziesz po kolejce i pytasz każdego po kolei: "Zostawić cię czy wykreślić?" Jeśli zaczniesz kogoś wykreślać w trakcie przechodzenia, cała kolejka się przesuwa, ludzie zmieniają miejsca i Twój plan “następna osoba — następna na liście” od razu się sypie. Może się zdarzyć, że kogoś pominiesz albo kogoś zapytasz dwa razy.

Przykład w C#:


List<string> names = new List<string> { "Anton", "Borys", "Wika", "Grisza" };

foreach (string name in names)
{
    if (name.StartsWith("W"))
        names.Remove(name); // Bum! InvalidOperationException
}

Kiedy program dojdzie do "Wika" i zdecyduje ją usunąć, wewnętrzny iterator straci “kontakt z rzeczywistością” — i dostaniesz komunikat:
InvalidOperationException: Collection was modified; enumeration operation may not execute.

To nie jest tylko fanaberia — tak C# chroni Cię przed trudnymi do wykrycia bugami i uszkodzeniem struktury danych.

2. Dlaczego taki prosty kod nie działa?

Jak to wszystko działa w środku?

Kiedy piszesz pętlę foreach, kompilator generuje specjalny obiekt — iterator (IEnumerator), który śledzi aktualną pozycję w kolekcji. Ten obiekt pamięta, ile elementów było na starcie, który element jest teraz “aktywny” i pilnuje, żeby kolekcja nie zmieniała się podczas przechodzenia.

Każda próba usunięcia lub dodania elementu w środku foreach łamie tę umowę. Dlaczego? Jeśli po Twoim usunięciu elementów indeksy się przesuną, iterator nie będzie już w stanie poprawnie przejść do następnego elementu. Kogoś możesz pominąć, kogoś policzyć dwa razy — w efekcie może być totalny chaos. Dlatego przy pierwszej zmianie kolekcji .NET uczciwie i jasno rzuca wyjątek.

Do czego prowadzi usuwanie "na pałę"

Wyobraź sobie, że napisałeś taki program:


List<int> numbers = new List<int> { 1, 2, 3, 4, 5, 6 };
foreach (int x in numbers)
{
    if (x % 2 == 0)
        numbers.Remove(x);
}

Niby wszystko logiczne: przejść po wszystkich liczbach, usunąć parzyste. Ale przy drugim przejściu program rzuca błąd — “kolekcja zmieniona podczas enumeracji”.

Czasem kusi, żeby obejść to ostrzeżenie i spróbować “na własne ryzyko”. Ale nawet gdyby nie było błędu, w zależności od struktury kolekcji wynik byłby nieprzewidywalny. Możesz przypadkiem “przeskoczyć” przez niektóre elementy albo nie usunąć wszystkich, które trzeba.

3. Jak to zrobić dobrze?

Technika nr 1: Odwrotna pętla for

Chodzi o to, że przy usuwaniu element “przesuwa” kolejne elementy w lewo i jeśli idziesz od początku listy, łatwo pomylić indeksy i pominąć elementy. Żeby tego uniknąć, rozsądnie jest iść od końca.


List<string> names = new List<string> { "Anton", "Borys", "Wika", "Grisza" };

for (int i = names.Count - 1; i >= 0; i--)
{
    if (names[i].StartsWith("W"))
        names.RemoveAt(i);
}

W tym przykładzie po każdym usunięciu wszystkie elementy stojące za usuniętym się przesuwają, ale indeksy, których jeszcze nie ruszyliśmy, są bezpieczne. W efekcie nic nie zostanie pominięte.

Technika nr 2: Filtrowanie i tworzenie nowej listy

Czasem prościej (i często szybciej) przejść po kolekcji, zebrać tylko te elementy, które mają zostać, i podmienić oryginalną listę na nową.


var names = new List<string> { "Anton", "Borys", "Wika", "Grisza" };
names = names.Where(name => !name.StartsWith("W")).ToList();
// W efekcie zostaną "Anton" i "Grisza"

To podejście jest dobre, gdy kolekcja nie jest zbyt duża albo nie zależy Ci na zachowaniu oryginalnego obiektu.

Technika nr 3: Używanie specjalnych metod kolekcji

Jeśli pracujesz z klasycznym List<T>, do usuwania według warunku jest wygodna metoda:


names.RemoveAll(name => name.StartsWith("W"));

Cały proces w środku będzie zrobiony poprawnie, a Ty dostaniesz zwięzły i czytelny kod.

Technika nr 4: Zbierz do usunięcia

Są kolekcje, których nie można zmieniać "w locie" (np. Dictionary, HashSet albo nawet własna klasa). W takich przypadkach stosuje się podejście "oznacz do usunięcia":

  1. Najpierw przejdź po kolekcji i zbierz wszystkie elementy do usunięcia do osobnej listy.
  2. Potem przejdź po tej nowej liście i usuń odpowiednie elementy z oryginalnej kolekcji.

Dictionary<int, string> dict = new Dictionary<int, string> { [1] = "jeden", [2] = "dwa", [3] = "trzy" };
var toDelete = new List<int>();
foreach (var kvp in dict)
{
    if (kvp.Key % 2 == 0)
        toDelete.Add(kvp.Key);
}
foreach (var key in toDelete)
    dict.Remove(key);

4. Przydatne niuanse

Błędy i mity początkujących

Jeden z najczęstszych błędów – oczekiwanie, że usuwanie elementu z kolekcji podczas iteracji “jakoś” zadziała, bo w niektórych innych językach (np. Python) to często możliwe. Ale w C# to jest twardo zabronione właśnie dla Twojego bezpieczeństwa: lepiej dostać wyraźny błąd niż cichy i podstępny bug, którego potem nikt nie odtworzy.

Kolejny częsty błąd — używanie pętli for z inkrementacją indeksu, a nie z dekrementacją. To prowadzi do tego, że po usunięciu elementu wszystkie kolejne się przesuwają i część elementów zostanie pominięta. Zawsze idź od końca do początku, jeśli usuwasz po indeksie.

Morał historii

Zadanie “usuń elementy z kolekcji według warunku” pojawia się w co drugim programie w C#, ale robić to bezpośrednio w pętli foreach nie wolno — taka jest architektura języka, podyktowana troską o spójność Twoich danych i brak niespodziewanych błędów.
Zapamiętaj tę zasadę, a oszczędzisz sobie nieprzespanych nocy z debugerem.

Jak robić to zawsze dobrze

  • Nigdy nie usuwaj elementów kolekcji bezpośrednio w pętli foreach. To wywoła błąd w czasie wykonania.
  • Dla list (List<T>) i tablic używaj albo pętli for od końca, albo metod RemoveAll i filtrowania przez LINQ.
  • Dla słowników, zbiorów i innych złożonych kolekcji — najpierw zbierz elementy do usunięcia, potem przejdź po tej liście i usuń z oryginalnej kolekcji.
  • Jeśli nie jesteś pewien — pomyśl: jak zmienia się kolekcja przy usuwaniu? Jak zachowa się iterator? Jeśli masz choć cień wątpliwości, to znaczy, że wybrałeś zły sposób.
2
Zadanie
C# SELF, poziom 29, lekcja 4
Niedostępne
Usuwanie stringów zaczynających się od podanego znaku
Usuwanie stringów zaczynających się od podanego znaku
1
Ankieta/quiz
Filtrowanie elementów, poziom 29, lekcja 4
Niedostępny
Filtrowanie elementów
Praca z kolekcjami
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION