在循環遍歷一個集合(如列表、數組)的過程中,直接對其進行添加或刪除元素的操作,之所以會導致程序出錯或產生非預期的結果,其根本原因在於這種修改行為,直接破壞了循環賴以正常工作的“迭代器”的內部狀態或循環的“邊界條件”。一個循環的執行,如同一個人,在參照一張地圖進行按部就班的徒步旅行。如果在旅行途中,這張地圖本身,被隨意地修改(例如,擦掉了一個即將要訪問的村莊,或在終點後又增加了一個新的村莊),那麼,旅行者(即循環),就必然會“迷路”。
這種“迷路”的具體表現,涵蓋了五大方面:破壞了迭代器內部狀態的一致性、在索引類循環中導致元素“跳過”或“重複”處理、在增強型循環中觸發“併發修改異常”、改變了集合的原始大小導致循環邊界失效、以及這種不確定的行為會產生難以預測的邏輯錯誤。其中,在索引類循環中導致元素被“跳過”處理,是最為常見也最隱蔽的邏輯錯誤。
一、問題的本質:迭代器的“契約”
要深刻理解這個問題的本質,我們必須首先,理解程序是如何進行“遍歷”的。無論是for循環,還是foreach循環,其背後,都有一個名為“迭代器”的對象在工作。
- 迭代器是什麼?
我們可以將“迭代器”,理解為一個智能的、用於在集合上進行導航的“書籤”或“遊標”。當你開始一個循環時,程序會首先,為你要遍歷的那個集合,創建一個專屬的迭代器。這個迭代器,在其內部,維護着一些至關重要的狀態信息,例如:“集合的總大小是多少?”、“我當前訪問到了哪個位置?”以及“下一個應該訪問的元素在哪裏?”。
- 迭代器的“隱性契約”
當你啓動一個循環時,你的代碼,就與這個新創建的迭代器之間,訂立了一份“隱性契約”。這份契約的核心內容是:“在我(迭代器)的這次完整的遍歷旅程結束之前,你(我們的代碼)不應該,通過除我之外的任何其他方式,來擅自修改我們正在遍歷的這個集合的‘結構’。”
“結構性”的修改,主要指那些會改變集合大小、或影響元素順序的操作,即添加和刪除元素。
- 為何會有這個契約?
這個契約的存在,是為了保障遍歷過程的“確定性”和“可預測性”。迭代器在“出發”前,記錄了地圖的全貌(例如,集合的大小)。如果在“旅途”中,地圖本身被隨意篡改,那麼,迭代器基於“舊地圖”所做出的“下一步”決策,就必然會與“新地圖”的現實,產生矛盾。
正如軟件工程領域的巨匠比雅尼·斯特勞斯特魯普所言:“我們最希望代碼所擁有的品質之一,就是它的行為,應該是可預測的。” 在循環中直接修改集合,恰恰是破壞這種“可預測性”的、最經典的反面教材。
二、場景一:在“索引”循環中刪除元素
這是最常見的、也是最能清晰地,揭示問題所在的場景。我們以一個經典的、基於“索引”的for循環為例。
- “跳過”元素的陷阱
場景:假設我們有一個數字列表,目標是刪除其中所有“偶數”的元素。
錯誤的代碼:Java// 這是一個包含6個元素的列表 List<Integer> numbers = new ArrayList<>(Arrays.asList(1, 2, 3, 4, 5, 6)); // 錯誤地,使用“正序”遍歷,並直接刪除 for (int i = 0; i < numbers.size(); i++) { if (numbers.get(i) % 2 == 0) { numbers.remove(i); } } System.out.println(numbers);
預期輸出:[1, 3, 5]
實際輸出:[1, 3, 5, 6] (數字 4 被成功刪除,但 6 卻被“遺漏”了!)
“法醫級”的執行過程分析:
i = 0: numbers.get(0) 是 1,非偶數,跳過。
i = 1: numbers.get(1) 是 2,是偶數。執行 numbers.remove(1)。
關鍵變化:此時,列表的內部結構,發生了“塌陷”。原有的元素3,移動到了索引1的位置;原有的元素4,移動到了索引2的位置;列表的總大小,從6變為5。
列表當前狀態:[1, 3, 4, 5, 6]
i = 2: for循環頭部的i++被執行,i的值變為2。循環,繼續,檢查索引為2的元素。
致命的“跳躍”:此時,列表索引為2的元素,是數字4。而那個剛剛移動到索引1位置的數字3,因為i已經變成了2,而被永久地“跳過”了檢查。
i = 2 (繼續): numbers.get(2) 是 4,是偶數。執行 numbers.remove(2)。
再次塌陷:列表變為 [1, 3, 5, 6]。原有的5移動到索引2,原有的6移動到索引3。
i = 3: i++後,i變為3。循環檢查索引為3的元素,即數字6。剛剛移動到索引2的數字5,又被“跳過”了。
i = 3 (繼續): numbers.get(3) 是6,是偶數。執行numbers.remove(3)。
再次塌陷:列表變為[1, 3, 5]。
i = 4: i++後,i變為4。此時,列表的新大小是3。邊界條件 i < numbers.size() (即 4 < 3) 不再滿足,循環終止。
【解決方案】:
方案一(最佳):倒序遍歷。這是解決“索引類”循環中刪除問題的、最經典、也最優雅的方案。Javafor (int i = numbers.size() - 1; i >= 0; i--) { // 從後往前遍歷 if (numbers.get(i) % 2 == 0) { numbers.remove(i); } } 為何倒序可行?:因為當你,從後往前,刪除一個位於索引i的元素時,它只會影響其後面(即索引大於i)的元素的位置。而你接下來,將要訪問的,是i-1這個更靠前的元素,其索引,完全不受本次刪除的影響。
三、場景二:在“增強型”循環中修改
在Java等語言中,for-each循環(即增強型for循環),為我們提供了更簡潔的遍歷語法。但它背後,隱藏着更嚴格的“契約”。
- 併發修改異常
錯誤的代碼:JavaList<String> fruits = new ArrayList<>(Arrays.asList("蘋果", "香蕉", "橘子")); for (String fruit : fruits) { if ("香蕉".equals(fruit)) { fruits.remove(fruit); // 錯誤! } }
後果:這段代碼,在運行時,會直接拋出一個名為“併發修改異常”的錯誤,導致程序崩潰。
“快速失敗”機制:這是Java集合框架,為了保護開發者,而設計的一種“快速失敗”機制。
當for-each循環開始時,它會創建一個迭代器,並記錄下集合在那一刻的“內部修改次數”(一個內部計數器)。
在循環的每一步,當迭代器,試圖獲取下一個元素時,它都會重新檢查集合的“內部修改次數”,是否與它最初記錄的那個值,保持一致。
當我們,在循環體內,直接調用fruits.remove()時,這個操作,會直接地、在迭代器“不知情”的情況下,去修改集合的內容,並使其“內部修改次數”加一。
在下一次循環時,迭代器,就會發現“內外不一致”——“在我上次檢查之後,有人在我背後,偷偷修改了地圖!” 為了避免後續出現更不可預測的行為(例如,像前一節那樣的“元素跳過”),迭代器,會選擇一種“最安全”的方式,即立即地、響亮地,拋出一個“併發修改異常”來中止程序。
【解決方案】:
方案一(唯一正確):使用迭代器自身的remove方法。JavaIterator<String> iterator = fruits.iterator(); while (iterator.hasNext()) { String fruit = iterator.next(); if ("香蕉".equals(fruit)) { iterator.remove(); // 正確!這是唯一被允許的、在迭代中刪除元素的方式 } } 因為,當你調用迭代器自身的remove方法時,它在刪除元素的同時,也會智能地、同步地,更新其內部的、關於“位置”和“修改次數”的狀態,從而維護了“契約”的一致性。
方案二(普適安全):先收集,再處理。JavaList<String> itemsToRemove = new ArrayList<>(); for (String fruit : fruits) { if (fruit.contains("果")) { // 假設要刪除所有帶“果”字的水果 itemsToRemove.add(fruit); } } fruits.removeAll(itemsToRemove); // 在循環結束後,進行一次性的批量刪除 這個模式,通過完全地,分離“遍歷”和“修改”這兩個操作,從根本上,避免了所有潛在的併發修改問題,是普適性最強、也最推薦的安全實踐。
四、場景三:在循環中“添加”元素
在循環中,添加元素,同樣是極其危險的,它甚至可能導致程序陷入“無限循環”。
錯誤代碼:JavaScriptlet nums = [1, 2, 3]; for (let i = 0; i < nums.length; i++) { console.log(nums[i]); if (nums[i] === 1) { nums.push(i + 10); // 錯誤!在循環中,向尾部添加元素 } }
問題分析:這個循環的終止條件,是i < nums.length。在循環體內,我們,向數組的尾部,添加了新的元素。這導致了nums.length這個值,在持續地、動態地增長。循環變量i,可能永遠也追不上nums.length的增長速度,從而導致循環,永不終止。
【解決方案】: 與刪除操作一樣,“先收集,再處理”的模式,對於添加操作,同樣是最安全、最推薦的。先將所有需要被添加的元素,放入一個臨時的集合,待主循環結束後,再將其,一次性地,全部添加到原始集合中。
五、在流程與規範中“防範”
要系統性地,杜絕這類問題,我們需要在團隊的“流程”和“規範”中,建立起“防禦工事”。
編碼規範中的“禁令”:團隊的《編碼規範》中,必須有一條明確的、高優先級的“禁令”:“嚴禁,在任何‘索引類’或‘增強型’循環的內部,直接地,對被遍歷的集合,進行‘添加’或‘刪除’操作。必須,採用‘倒序遍歷’、‘迭代器’或‘先收集後處理’的規範化模式。”
代碼審查的“火眼金睛”:在進行代碼審查時,任何一個有經驗的開發者,都應對“循環 + remove/add”這種組合,保持最高級別的警惕。這是代碼審查中,一個經典的、必須被仔細審視的“壞味道”。
工具的支撐:在 PingCode 或 Worktile 這樣的協作平台中,團隊,可以創建一份《代碼審查檢查清單》的模板。並將“檢查是否存在不安全的循環內集合修改”這一項,作為模板的必選項。這樣,在每次發起代碼審查的流程時,工具,就能自動地,提醒審查者,去關注這個關鍵的、易錯的檢查點。
常見問答 (FAQ)
Q1: 為什麼倒序遍歷刪除元素是安全的?
A1: 因為,當你從後往前,在索引i處,刪除一個元素時,這個操作,只會影響到,那些你已經訪問過的、索引大於i的元素的位置。而你接下來,將要訪問的,是i-1這個更靠前的元素,其索引,完全不受本次刪除的影響。
Q2: 既然在循環中修改集合如此危險,為什麼語言設計者不直接禁止它呢?
A2: 語言的設計,需要在“靈活性”與“安全性”之間,做出權衡。直接禁止,會使得一些高級的、特定的算法實現,變得不可能。因此,大多數語言,選擇將這份“自由”,連同其所伴隨的“責任”,都交給了開發者。同時,通過像“併發修改異常”這樣的“快速失敗”機制,來儘可能地,提醒開發者,他們正在進行危險的操作。
Q3: “快速失敗”和“安全失敗”的迭代器有什麼區別?
A3: “快速失敗”(例如Java的ArrayList的迭代器),會在檢測到外部修改時,立即拋出異常,中止程序。而“安全失敗”(例如Java的CopyOnWriteArrayList的迭代器),則通常,是在一個原始數據的“快照”上進行遍歷。在遍歷期間,對原始數據的任何修改,都不會影響到這次遍歷,也不會拋出異常,但同樣地,遍歷者,也看不到這些最新的修改。
Q4: 除了添加和刪除,還有哪些修改操作也同樣危險?
A4: 任何能夠“結構性地”改變集合的操作,都是危險的。例如,對一個正在被遍歷的列表,進行“清空”(clear())或“排序”(sort())等操作,同樣,會破壞迭代器的內部狀態,並可能導致不可預測的行為。