Home
探索 Uedu
學生控制台
註冊會員/登入
研究知情同意中心
教師控制台
課程設定
支援與訊息
Uptime 數據

UeduGPTs

--

Jupyters

4

UG26 CISOSE26
臺北 AQI 46 · 臺中 AQI 28 · 臺南 AQI 24 · 高雄 AQI 33

AI 回覆桌面通知

AI 助教回覆完成時顯示桌面通知

聊天訊息通知

同學在討論區發送訊息時通知

聲音通知

每當有新通知時播放提示音

流程控制

當迴圈不只是「重複」:流程控制背後的狀態機與協定

從 iterator protocol、生成器惰性求值,到 for...else、遞迴極限與迴圈不變式——讀懂 Python 流程控制的底層真相

當迴圈不只是「重複」:你的 for 其實是一台狀態機

入門篇我們學會了用 if 替程式做決定、用 for / while 讓程式重複做事。那是「能寫出來」的階段。但當你開始讀別人的程式碼、開始追求效率,你會遇到一些更尖銳的問題:

  • 為什麼 Python 的 for 迴圈跑得比 C 慢,卻又有人說「用內建函式比手寫迴圈快」?
  • 一個函式裡 5 層巢狀的 if,怎麼變成一行 return
  • for...else 這個怪語法到底什麼時候有用,為什麼大家都不敢用?
  • 為什麼遞迴(recursion)有時優雅、有時直接讓程式爆掉(RecursionError)?

這篇進階文章不會再講「if 怎麼寫」。我們要把流程控制拆開來看它底層的真相:Python 的流程控制本質上是在驅動一台狀態機(state machine),而 for 迴圈的背後藏著一個叫 iterator protocol 的協定。 理解這層,你寫出來的程式碼會從「能跑」升級到「優雅且快」。

流程控制進階概念示意圖

for 迴圈的真相:iterator protocol

入門時我們說 for x in [1, 2, 3] 是「把清單裡的每個元素拿出來」。這個說法夠用,但不精確。真相是:Python 的 for 迴圈完全不認識 list,它只認識兩個 dunder method(雙底線方法):__iter____next__

當你寫:

for x in [10, 20, 30]:
    print(x)

Python 直譯器實際上做的事情,等價於這段「去糖」(desugar)後的程式碼:

it = iter([10, 20, 30])   # 呼叫 list.__iter__(),拿到一個 iterator
while True:
    try:
        x = next(it)       # 呼叫 it.__next__(),拿下一個值
    except StopIteration:  # iterator 喊「沒了」
        break
    print(x)

看清楚了嗎?for 迴圈其實是一個包了 try/exceptwhile 迴圈。它靠 StopIteration 這個例外(exception)來知道什麼時候該停。流程控制(迴圈的終止)和例外處理(exception handling)在這裡是同一件事。 這是很多人從沒意識到的設計。

這也解釋了一個現象:任何物件只要實作了這兩個方法,就能被 for 迭代。我們可以自己造一個:

class CountDown:
    def __init__(self, start):
        self.current = start

    def __iter__(self):
        return self          # 自己就是 iterator

    def __next__(self):
        if self.current <= 0:
            raise StopIteration
        self.current -= 1
        return self.current + 1

for n in CountDown(3):
    print(n)   # 印出 3, 2, 1

CountDown 不是 list、不是 range,但 for 照吃不誤。因為 for 從來不在乎你是什麼型別,只在乎你會不會回應 __next__

生成器:用 yield 暫停一個迴圈

手寫 __iter__ / __next__ 很囉嗦。Python 給了一個糖:生成器(generator)。只要函式裡出現 yield,它就不再是普通函式,而變成一台會「暫停」的狀態機。

def count_down(start):
    while start > 0:
        yield start       # 在這裡暫停,把 start 交出去
        start -= 1

for n in count_down(3):
    print(n)   # 3, 2, 1

關鍵在 yield 的語意:它不是 returnreturn 一旦執行,函式就結束、區域變數全部丟掉。yield 則是把函式凍結在原地——區域變數、執行到哪一行,全部保留。下次 next() 被呼叫時,函式從 yield 的下一行繼續跑。

這帶來一個流程控制上的根本差異,我們用 lazy evaluation(惰性求值)來理解。比較下面兩種寫法:

# 寫法 A:一次算完,全部塞進記憶體
def squares_eager(n):
    result = []
    for i in range(n):
        result.append(i * i)
    return result

# 寫法 B:要一個才算一個
def squares_lazy(n):
    for i in range(n):
        yield i * i

squares_eager(10**8) 會試圖在記憶體裡放一億個數字,你的程式大概會直接被作業系統殺掉。而 squares_lazy(10**8) 不論 n 多大,記憶體佔用都是常數 $O(1)$,因為它一次只持有「當下這一個值」。流程控制從「先做完再用」變成「邊做邊用」。

看一個例子:用生成器串接資料處理流水線

生成器最漂亮的地方是可以串接,形成一條惰性的處理流水線(pipeline)。假設我們要處理一個超大的 log 檔,找出所有錯誤訊息並轉成大寫:

def read_lines(path):
    with open(path, encoding='utf-8') as f:
        for line in f:
            yield line.rstrip('\n')

def only_errors(lines):
    for line in lines:
        if 'ERROR' in line:
            yield line

def to_upper(lines):
    for line in lines:
        yield line.upper()

# 串接:每一層都是惰性的
pipeline = to_upper(only_errors(read_lines('server.log')))

for line in pipeline:
    print(line)

注意這條 pipeline 在 for 真正開始跑之前,一行檔案都還沒讀。當你跑迴圈、要第一個結果時,控制流會「往回穿透」三層生成器:to_upperonly_errors 要一行、only_errorsread_lines 要一行、read_lines 才真的去讀檔案的一行。處理一個 10 GB 的檔案,記憶體佔用依然是常數。這種「需求驅動」(demand-driven)的控制流,是純迴圈寫法很難優雅達成的。

把巢狀 if 攤平:guard clause 與短路求值

進階程式設計很大一部分是「降低控制流的複雜度」。最常見的壞味道是巢狀 if 金字塔:

def get_discount(user):
    if user is not None:
        if user.is_active:
            if user.is_member:
                if user.years >= 3:
                    return 0.2
                else:
                    return 0.1
                ...

這種「向右漂移」(arrow anti-pattern)的程式碼很難讀,因為你得在腦中同時記住四個條件。解法是衛述句(guard clause):把「不符合就提早離開」的情況先處理掉,用 early return 把巢狀攤平:

def get_discount(user):
    if user is None:
        return 0.0
    if not user.is_active:
        return 0.0
    if not user.is_member:
        return 0.0
    return 0.2 if user.years >= 3 else 0.1

邏輯完全相同,但每一行都在「縮小可能性」,讀者的心智負擔線性下降,而不是指數累積。控制流變得扁平,這在認知上是巨大的差別。

另一個攤平條件的利器是短路求值(short-circuit evaluation)。Python 的 and / or 不是回傳布林值,而是回傳「決定結果的那個運算元」,並且一旦能確定結果就停止計算:

# x and y:x 為假就回傳 x,否則回傳 y
# x or y :x 為真就回傳 x,否則回傳 y

name = user_input or '訪客'        # user_input 是空字串時,給預設值
config.get('timeout') and connect()  # 只有 timeout 有設才連線

短路不只是省一點計算,它本身就是一種控制流。a and b() 等價於「只在 a 為真時才執行 b()」,把一個 if 壓進一行運算式。前提是你要清楚它回傳的是運算元本身,這也是 or 設預設值這個慣用法的原理。

for...else 與 while...else:被誤解的語法

Python 有一個讓初學者困惑、連老手都常寫錯的語法:迴圈後面接 else

for x in data:
    if x == target:
        print('找到了')
        break
else:
    print('整個跑完都沒找到')

關鍵迷思要先破除:這個 else 不是配對 if,而是配對 for。語意是:「如果迴圈是自然跑完(沒有被 break 中斷)的,就執行 else。」一旦 break 觸發,else 就被跳過。

很多人覺得這語法名字取壞了——確實,把它讀成 for...nobreak 會直覺很多。但理解之後它在「搜尋」場景非常乾淨。對比沒有 else 的傳統寫法:

found = False
for x in data:
    if x == target:
        found = True
        break
if not found:
    print('沒找到')

for...else 省掉了那個 found 旗標變數(flag variable)。旗標變數是控制流的雜訊,能消掉就消掉。判斷它有沒有用的口訣是:只要你的迴圈是在「找東西」,而且「找不到」需要特別處理,就考慮 for...else

動手算一下:質數判斷的 for...else

質數(prime)判斷是 for...else 的教科書範例。一個數 n 是質數,當且僅當它不能被 2 到 $\sqrt{n}$ 之間任何整數整除:

def is_prime(n):
    if n < 2:
        return False
    for i in range(2, int(n**0.5) + 1):
        if n % i == 0:
            break          # 找到因數,不是質數
    else:
        return True        # 迴圈跑完都沒 break,是質數
    return False

print([n for n in range(2, 20) if is_prime(n)])
# [2, 3, 5, 7, 11, 13, 17, 19]

讀讀這個控制流:break 代表「找到因數,提早離場」,else 代表「沒有任何因數能整除,確認是質數」。注意檢查到 $\sqrt{n}$ 就夠了——因為若 $n = a \times b$ 且 $a \le b$,那必有 $a \le \sqrt{n}$。這把時間複雜度從 $O(n)$ 降到 $O(\sqrt{n})$,是流程控制與數學洞察結合的好例子。

遞迴:另一種控制流,與它的天花板

迴圈不是重複的唯一手段。遞迴(recursion)讓函式呼叫自己,用「問題拆成更小的同類問題」來表達重複。

def factorial(n):
    if n <= 1:        # base case:遞迴的出口
        return 1
    return n * factorial(n - 1)   # recursive case

每個遞迴都必須有兩部分:基本情況(base case)讓它停下來,遞迴情況(recursive case)讓它縮小問題。少了 base case,就是無窮遞迴。

但遞迴在 Python 有個現實天花板。每次函式呼叫,直譯器都會在呼叫堆疊(call stack)上壓一個 stack frame,存區域變數和返回位址。Python 預設限制大約 1000 層,超過就:

import sys
print(sys.getrecursionlimit())   # 通常 1000

def deep(n):
    return deep(n + 1)
deep(0)   # RecursionError: maximum recursion depth exceeded

這跟 C 或 Scheme 不同。許多語言會做尾呼叫優化(tail call optimization, TCO):當遞迴呼叫是函式的最後一個動作時,編譯器可以重用同一個 stack frame,把遞迴變成迴圈,深度無限。但 Python 刻意不做 TCO——Guido van Rossum(Python 創造者)的理由是:TCO 會破壞 traceback(錯誤回溯),讓除錯困難,而可讀的錯誤訊息比這點效能更重要。

所以在 Python,深層遞迴的正解通常是改寫成顯式迴圈,或自己維護一個堆疊:

def factorial_iter(n):
    result = 1
    for i in range(2, n + 1):
        result *= i
    return result

選遞迴還是迴圈,不是品味問題,而是工程權衡:遞迴對「樹狀」「分治」問題(走訪檔案目錄、解析 JSON)表達力極強;但對「線性深度很大」的問題,迴圈才安全。

重點回顧

  1. for 迴圈的本質是 iterator protocol:它只認 __iter__ / __next__,靠捕捉 StopIteration 例外來終止——流程控制與例外處理在底層是同一機制。
  2. 生成器(yield)是會暫停的狀態機,帶來惰性求值。處理巨量資料時記憶體可維持 $O(1)$,並能串接成需求驅動的 pipeline。
  3. 衛述句(guard clause)+ 短路求值能把巢狀 if 金字塔攤平成扁平、線性可讀的控制流,消掉旗標變數這類雜訊。
  4. for...elseelse 配對的是迴圈而非 if:迴圈自然跑完(沒 break)才執行,是「搜尋失敗」場景的乾淨寫法。
  5. 遞迴受限於 call stack(約 1000 層),且 Python 刻意不做尾呼叫優化;深層線性問題該用迴圈,分治樹狀問題才發揮遞迴的優勢。

深入探討(研究所視角)

把流程控制推到理論層次,有三個值得深挖的方向。

第一,控制流即 continuation。 在程式語言理論中,「接下來要執行什麼」可以被具體化(reify)成一個一級物件,稱為 continuation。yield 其實是 continuation 的受限形式:生成器暫停時,保存的就是「剩下要跑的計算」。完整版是 call/cc(call-with-current-continuation,Scheme 的招牌),它能捕捉整個控制流並在任意時刻重啟,足以從零實作出例外、協程、回溯。Python 的 async/await 與生成器同源——asyncio 的事件迴圈本質上就是在一堆暫停的 continuation 之間調度。理解這點,你會看穿「迴圈、例外、協程、async」表面不同,底層都是「保存與恢復控制狀態」的同一主題。

第二,結構化程式設計的數學基礎。 1966 年 Böhm–Jacopini 定理證明了:任何可計算的流程圖,都能只用「順序、選擇(if)、迭代(while)」三種結構表達,完全不需要 goto。這是「結構化程式設計」的理論基石,也是為什麼現代語言敢拿掉 goto。但定理只保證「能做到」,不保證「不膨脹」——有時消除 goto 需要引入額外的旗標變數或重複程式碼。Python 的 break / continue / for...else 正是在「純結構化」與「實務可讀性」之間的工程妥協:它們是受控的局部跳轉,避免了 goto 的混亂,又保留了提早離場的便利。

第三,迴圈不變式(loop invariant)與正確性證明。 嚴謹地證明一個迴圈正確,靠的是迴圈不變式:一個在每次迭代前後都成立的命題。配合 Hoare logic 的三段論——初始化讓不變式成立、每次迭代保持不變式、終止時不變式加上終止條件推出目標——就能形式化證明程式正確。以二分搜尋為例,不變式是「目標若存在,必落在 [lo, hi] 區間內」;這個不變式在每次 lo/hi 更新後都得維持,才能保證搜尋正確。當你寫複雜迴圈卻不確定對不對時,先寫下「每一輪我希望什麼永遠為真」,往往比盯著程式碼 debug 更快找到錯誤。這也是把「直覺寫迴圈」升級成「論證寫迴圈」的關鍵思維轉變。

AI 共讀助教正在陪你讀:當迴圈不只是「重複」:流程控制背後的狀態機與協定
嗨!我是這篇文章的共讀助教,只根據〈當迴圈不只是「重複」:流程控制背後的狀態機與協定〉的內容回答。可以問我「解釋某段」「舉個例子」「出題考我」,或反白文中段落後點下方「解釋選取段落」。