Home
Explore Uedu
Student Console
Register as Member/Login
(2) In future presentations of the research findings, in addition to the course project website and public presentations, your real name and personal information will not appear in this research report. If you are interested in the research results, we can provide you with an executive summary after the study is completed.
問卷中心
Teacher Console
Course Setup
Support & Messages
Uptime Data

UeduGPTs

--

Jupyters

0

Local AI

--

CISOSE26 本地 AI UG26
政治大學 AQI 18 29°C

AI Reply Desktop Notifications

Show a desktop notification when the AI TA finishes replying

Chat Message Notifications

Notify me when classmates post messages in the forum

Sound notification

Play an alert sound whenever there is a new notification

流程控制

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

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