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":
- Najpierw przejdź po kolekcji i zbierz wszystkie elementy do usunięcia do osobnej listy.
- 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.
GO TO FULL VERSION