迴圈(loop)是任何會讓程式重複執行某段程式碼的迭代控制結構,包括 forwhiledo-while 等。迴圈是程式設計中最複雜的面向之一,正確選擇與控制迴圈是建構高品質軟體的關鍵。

16.1 選擇迴圈的種類#

迴圈可依彈性測試位置分為四類:

種類英文名說明
計數迴圈Counted Loop執行固定次數,例如為每位員工執行一次
持續求值迴圈Continuously Evaluated Loop事先不知執行次數,每次迭代都檢查是否完成
無限迴圈Endless Loop啟動後永遠執行,常見於嵌入式系統(如心律調節器、微波爐)
迭代器迴圈Iterator Loop對容器中每個元素各執行一次操作

測試位置決定迴圈體是否至少執行一次:開頭測試的迴圈體不一定執行;結尾測試的至少執行一次;中間測試的則是測試前的部分至少執行一次。

flowchart TD
    Q1{"知道執行次數?"}
    Q2{"遍歷容器?"}
    Q3{"需要至少執行一次?"}
    Q4{"退出條件在中間?"}

    R1["使用 for"]
    R2["使用 foreach"]
    R3["使用 do-while"]
    R4["使用 while(true) + break"]
    R5["使用 while"]

    Q1 -- "是" --> R1
    Q1 -- "否" --> Q2
    Q2 -- "是" --> R2
    Q2 -- "否" --> Q3
    Q3 -- "是" --> R3
    Q3 -- "否" --> Q4
    Q4 -- "是" --> R4
    Q4 -- "否" --> R5

何時使用 while 迴圈#

若事先不知道迭代次數,使用 while 迴圈。主要的設計決策是測試放開頭還是結尾:

  • 開頭測試while — 迴圈體可能不執行。
  • 結尾測試do-while — 迴圈體至少執行一次。

何時使用 loop-with-exit 迴圈#

loop-with-exit 是退出條件在迴圈中間的結構。典型用途是避免「迴圈半圈」(loop-and-a-half)問題——即必須在迴圈前後重複同一段程式碼。

// 不佳:重複程式碼,維護時容易遺漏
score = 0;
GetNextRating( &ratingIncrement );
rating = rating + ratingIncrement;
while ( ( score < targetScore ) && ( ratingIncrement != 0 ) ) {
   GetNextScore( &scoreIncrement );
   score = score + scoreIncrement;
   GetNextRating( &ratingIncrement );
   rating = rating + ratingIncrement;
}
// 較佳:用 while(true)-break 模擬 loop-with-exit
score = 0;
while ( true ) {
   GetNextRating( &ratingIncrement );
   rating = rating + ratingIncrement;
   if ( !( ( score < targetScore ) && ( ratingIncrement != 0 ) ) ) {
      break;
   }
   GetNextScore( &scoreIncrement );
   score = score + scoreIncrement;
}

研究顯示,loop-with-exit 結構更接近人類對迭代控制的思維模式,學生在理解度測試中的分數提高了 25%。使用時應將所有退出條件集中在同一處,並用註解清楚說明。

何時使用 for 迴圈#

for 迴圈適合簡單、固定次數的迭代。它把控制碼集中在迴圈頂端,設定後就不需要在內部控制。

  • 不要在 for 迴圈內部改變索引來強制終止——改用 while
  • 不要把不相關的程式碼塞進 for 的標頭。

while 迴圈的邏輯硬擠進 for 標頭是常見的濫用。for 標頭只應包含初始化、終止條件、和推進迴圈的述句。

何時使用 foreach 迴圈#

foreach(C#)、For-Each(VB)、for-in(Python)適合對容器每個成員執行操作。它的優勢是消除迴圈管理算術,從根本上避免索引錯誤。

16.2 迴圈控制#

迴圈常見的問題包括:初始化不正確或遺漏、累加器未初始化、巢狀不當、終止條件錯誤、索引遞增不正確、陣列索引偏移錯誤等。兩個核心原則:

  1. 簡化——減少影響迴圈的因素。
  2. 把迴圈內部當成子程式——控制邏輯盡量放在外部,讓迴圈成為黑盒子。

進入迴圈#

  • 只從一個位置進入:從頂端進入即可,不需要多重入口。
  • 初始化程式碼緊鄰迴圈:依循鄰近原則(Principle of Proximity),避免初始化散落在其他位置。
  • 無限迴圈使用 while(true):不要用 for i = 1 to 99999 之類的假無限迴圈,因為具體數字會混淆意圖。
  • 適當時優先用 for 迴圈for 把控制碼集中在一處,修改時較不易出錯。
  • for 標頭只放控制述句:初始化、終止測試、推進迴圈的述句才放在標頭中。

處理迴圈中段#

  • 一律用大括號 {} 包住迴圈體:不影響效能,卻能防止修改時引入錯誤。
  • 避免空迴圈:把工作塞在測試條件裡會讓讀者困惑,應改寫成明確的迴圈體。
  • 管理述句集中放置:像 i++ 這類管理述句統一放在迴圈的開頭或結尾。
  • 每個迴圈只做一件事:和子程式一樣,一個迴圈應只負責一個功能。若擔心效能,先寫成兩個迴圈並加註解,等效能測試確認有問題再合併。

退出迴圈#

  • 確認迴圈一定會結束:用心智模擬跑過正常情況、邊界情況和所有例外情況。
  • 讓終止條件顯而易見:把控制邏輯集中在一處(for 標頭或 while 條件)。
  • 不要擅改 for 迴圈索引來強制終止:這是業餘的做法,應改用 while 迴圈。
// 不佳:竄改索引來終止迴圈
for ( int i = 0; i < 100; i++ ) {
   if ( ... ) {
      i = 100;  // 業餘做法
   }
}
  • 不要依賴迴圈索引的終值:索引在正常與異常終止時的值不同,且因語言和實作而異。應在迴圈內用額外變數保存需要的值。
// 不佳:依賴索引終值
for ( recordCount = 0; recordCount < MAX_RECORDS; recordCount++ ) {
   if ( entry[ recordCount ] == testValue ) {
      break;
   }
}
if ( recordCount < MAX_RECORDS ) { return true; }

// 較佳:用布林變數記錄結果
found = false;
for ( recordCount = 0; recordCount < MAX_RECORDS; recordCount++ ) {
   if ( entry[ recordCount ] == testValue ) {
      found = true;
      break;
   }
}
return found;
  • 考慮使用安全計數器(safety counter):在關鍵迴圈中加入計數器,若超過預期上限就觸發斷言,防止無限迴圈造成災難性錯誤。

提前退出迴圈#

  • break:讓迴圈從正常出口終止,程式從迴圈後第一行繼續執行。
  • continue:跳過本次迭代的剩餘部分,從下一次迭代的開頭繼續。
break 與 continue 使用原則
  • 考慮用 break 取代布林旗標:有時候 break 比層層巢狀的 if 測試更清晰。
  • 警惕散落各處的 break:過多 break 暗示迴圈結構不清楚,可能應拆成多個迴圈。
  • continue 適合放在迴圈頂端:用來篩選不符條件的資料,避免整個迴圈體縮排一層。若 continue 出現在中段或尾端,改用 if 更清楚。
  • 有 labeled break 就用:Java 的 labeled break 可明確指定要退出的層級,避免 break 意外跳出錯誤的結構(如紐約市電話系統 1990 年的 9 小時當機事件)。
  • 謹慎使用break 破壞了將迴圈視為黑盒子的能力。無法為 breakcontinue 辯護時,就不要用。

檢查端點#

為每個迴圈心智模擬三個案例:第一次任意中間一次最後一次。若有特殊情況也要額外檢查。複雜計算時拿出計算機手動驗算。

高效率的程式設計師會做心智模擬和手動計算,因為他們知道這能發現錯誤。低效率的程式設計師傾向隨機嘗試(改 <<=、加減 1),即使碰巧成功也不理解原因。

使用迴圈變數#

  • 用整數或列舉型別:浮點數遞增不精確,可能造成無限迴圈(例如 26,742,897.0 + 1.0 可能仍等於 26,742,897.0)。
  • 巢狀迴圈使用有意義的名稱:避免 ijk 搭配多維陣列。
// 不佳:i, j, k 什麼都看不出來
for ( int i = 0; i < numPayCodes; i++ )
   for ( int j = 0; j < 12; j++ )
      for ( int k = 0; k < numDivisions; k++ )
         sum = sum + transaction[ j ][ i ][ k ];

// 較佳:變數名稱自我說明
for ( int payCodeIdx = 0; payCodeIdx < numPayCodes; payCodeIdx++ )
   for ( int month = 0; month < 12; month++ )
      for ( int divisionIdx = 0; divisionIdx < numDivisions; divisionIdx++ )
         sum = sum + transaction[ month ][ payCodeIdx ][ divisionIdx ];
  • 避免索引交叉干擾(index cross-talk):習慣性使用 i 容易在巢狀中重複使用同一變數,造成外層索引被內層覆蓋。
  • 將迴圈索引的作用域限制在迴圈內:在 for 內宣告索引變數,但注意不同編譯器對作用域的實作可能不一致。

迴圈應多長#

  • 能一眼看完:實務上很少需要超過 15-20 行。
  • 巢狀不超過三層:研究顯示超過三層時理解力會顯著下降。
  • 長迴圈的內容提取成子程式:從迴圈內呼叫即可。
  • 長迴圈須格外清晰:避免 breakcontinue、複雜終止條件等高風險技巧,改用單一出口與明確的退出條件。

16.3 輕鬆建立迴圈——由內而外#

建構複雜迴圈的技巧:從具體的單一案例開始,逐步向外包裹

  1. 先用註解寫出迴圈體需要做的步驟。
  2. 把註解轉成程式碼,先用具體的字面值(literal)。
  3. 加上陣列索引。
  4. 包上迴圈,將字面值替換為迴圈索引或計算式。
  5. 將依賴迴圈索引的變數一般化。
  6. 最後加上所有必要的初始化。
-- Step 1: 寫出動作
rate = table[ ]
totalRate = totalRate + rate

-- Step 2: 加上索引
rate = table[ census.Age ][ census.Gender ]
totalRate = totalRate + rate

-- Step 3: 包上迴圈、一般化
totalRate = 0
For person = firstPerson to lastPerson
   rate = table[ census[ person ].Age, census[ person ].Gender ]
   totalRate = totalRate + rate
End For

每次只擔心一件事,用小而可理解的步驟逐步讓迴圈變得更一般化、更複雜,能最大限度減少出錯機會。

16.4 迴圈和陣列的關係#

迴圈與陣列經常一起出現,迴圈計數器往往與陣列索引一一對應。但這並非天生必然——某些語言(如 APL、Fortran 90)提供強大的陣列運算,完全不需要迴圈:

product <- a x b

同樣的陣列相乘在 Java 中需要巢狀 for 迴圈、17 個運算元,APL 只需要 3 個。這說明了使用的語言會實質影響你的解法,有些程式碼是為了解決問題,有些則是為了配合語言。

要點#

  • 迴圈本質上複雜,保持簡單才能幫助讀者理解程式碼。
  • 簡化迴圈的技巧包括:避免奇特的迴圈種類、減少巢狀層數、讓進出口清晰、將管理碼集中在一處。
  • 迴圈索引容易被誤用——給予清楚的命名,只用於單一用途。
  • 仔細推敲迴圈在每種情況下的行為,確認它在所有可能條件下都能正確終止。