CodeGym /課程 /C# SELF /在 foreach 迴圈中刪除集合元...

在 foreach 迴圈中刪除集合元素

C# SELF
等級 29, 課堂 4
開放

1. 前言

幾乎每個剛開始寫 C# 的人,遲早都會遇到同一個問題:有個集合(像是物件的 list),你想根據某個條件把不需要的元素刪掉。聽起來很簡單,手就會自動去寫熟悉又方便的 foreach 迴圈,畢竟這是最「安全」又「友善」的遍歷方式。但突然間,在你最意想不到的時候,出現了一個神祕的 runtime error,簡單範例都沒事,結果程式就這樣停住了。

我們來搞懂為什麼會這樣,集合和 iterator「底層」到底怎麼運作,以及怎麼正確刪除元素,才不會踩雷出 bug。

為什麼 foreach 跟刪除元素不合

想像一下,集合就像一排人在排隊。你一個個問:「要留下還是劃掉?」如果你在走訪時直接把某個人劃掉,整個隊伍就會往前移動,大家的位置都變了,你原本「下一個人就是下一個」的計畫馬上就亂掉了。可能有人沒被問到,或有人被問兩次。

C# 範例:


List<string> names = new List<string> { "安東", "鮑里斯", "維卡", "格里沙" };

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

當程式跑到 "維卡" 決定要刪掉時,iterator 會「失去現實感」——你會看到這個訊息:
InvalidOperationException: Collection was modified; enumeration operation may not execute.

這不是 C# 在耍任性——這是為了保護你不會遇到難抓的 bug 或資料結構壞掉。

2. 為什麼這麼簡單的程式碼不行?

底層到底怎麼回事?

當你寫 foreach 迴圈時,compiler 會產生一個特殊物件——iterator(IEnumerator),它會記住當下在集合裡的位置。這個物件會記得一開始有幾個元素,現在「活躍」的是哪一個,而且會嚴格檢查遍歷時集合有沒有被改動。

只要你在 foreach 裡刪除或新增元素,就破壞了這個契約。為什麼?因為刪除後 index 會移動,iterator 就沒辦法正確跳到下一個元素。有人會被跳過,有人會被重複——結果就是一團亂。所以只要你一改集合,.NET 就會直接丟出錯誤。

「硬刪」會發生什麼事

假設你寫了這樣的程式:


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

看起來很合理:遍歷所有數字,刪掉偶數。但第二圈就會噴錯——「集合在列舉時被改動」。

有時候你會想「硬上」試試看。但就算沒噴錯,根據集合的結構,結果也會很難預測。你可能會「跳過」某些元素,或沒刪乾淨。

3. 那到底怎麼做才對?

技巧一:反向 for 迴圈

重點是,刪除時元素會「往左移」,如果你從頭開始,index 很容易亂掉、漏掉元素。最安全的方式是從尾巴往前走。


List<string> names = new List<string> { "安東", "鮑里斯", "維卡", "格里沙" };

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

這樣每次刪除後,後面的元素會移動,但還沒處理的 index 不會受影響。這樣就不會漏掉任何東西。

技巧二:過濾、建立新 list

有時候更簡單(而且通常更快)的做法,是遍歷一次,把要留下的元素收集起來,然後用新 list 取代原本的。


var names = new List<string> { "安東", "鮑里斯", "維卡", "格里沙" };
names = names.Where(name => !name.StartsWith("維")).ToList();
// 最後只剩下 "安東" 和 "格里沙"

這種方式適合集合不大,或你不在意原本物件的 reference。

技巧三:用集合的特殊方法

如果你用的是 List<T>,有個很方便的條件刪除方法:


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

這樣內部會正確處理,你的程式碼又短又清楚。

技巧四:先收集要刪的

有些集合不能「邊走邊改」(像 Dictionary、HashSet,或你自訂的 class)。這時候可以用「先標記再刪」的做法:

  1. 先遍歷集合,把要刪的元素收集到另一個 list。
  2. 再遍歷這個新 list,從原本的集合刪掉。

Dictionary<int, string> dict = new Dictionary<int, string> { [1] = "one", [2] = "two", [3] = "three" };
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. 實用小細節

新手常見錯誤與迷思

最常見的錯誤之一,就是以為遍歷時刪元素「應該可以吧」,畢竟有些語言(像 Python)真的可以。但在 C#,這是 嚴格禁止 的,這是為了你的安全:寧可直接噴錯,也不要出現那種超難 debug 的 bug。

另一個常見錯誤,是用 for 迴圈從頭加 index。這樣刪掉元素後,後面的都會往前移,結果會漏掉一些。只要你要用 index 刪,記得一定要從尾巴往前。

這故事的啟示

「根據條件從集合刪元素」這個需求,幾乎每兩個 C# 程式就有一個會遇到,但千萬不要在 foreach 迴圈裡直接刪——這是語言設計的規則,為了保護你的資料完整性和避免奇怪的錯誤。
記住這個原則,你就不用半夜抱著 debugger 抓 bug 了。

正確做法總整理

  • 絕對不要在 foreach 迴圈裡直接刪集合元素。這會噴 runtime error。
  • 對於 List<T> 和 array,請用從尾巴往前的 for 迴圈,或用 RemoveAll、LINQ 過濾。
  • 對於 dictionary、set 或其他複雜集合——先收集要刪的元素,再遍歷這個 list 去原集合刪。
  • 如果不確定——想想:刪除時集合會怎麼變?iterator 會怎麼走?只要有一點點懷疑,代表你該換方法了。
2
任務
C# SELF, 等級 29, 課堂 4
上鎖
刪除以指定字元開頭的字串
刪除以指定字元開頭的字串
1
問卷/小測驗
篩選元素,等級 29,課堂 4
未開放
篩選元素
操作集合
留言
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION