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

UeduGPTs

--

Jupyters

4

UG26 CISOSE26
臺北 AQI 46 · 臺中 AQI 26 · 臺南 AQI 21 · 高雄 AQI 33

AI 回覆桌面通知

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

聊天訊息通知

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

聲音通知

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

控制流程

控制流程(進階)

當「下一行」不再是下一行:例外回溯、控制流程圖、產生器與協程,到 async/await 背後的狀態機與延續

當「下一行」不再是下一行:被你忽略的控制流程

入門篇把控制流程拆成三塊積木:循序、選擇、重複。如果世界真的這麼簡單就好了。請看這段 Python:

def fetch():
    for url in urls:
        try:
            data = download(url)   # 這裡可能拋出例外
        except TimeoutError:
            continue               # 跳出 try、回到迴圈頂
        yield data                 # 這裡會「暫停」整個函式

短短六行裡,控制流程做了三件入門篇不會告訴你的事:例外(exception)讓控制權從深處一口氣彈回外層;yield 讓函式中途凍結、把控制權交還給呼叫者、之後又能從原地復活;而 continuetry 區塊裡跳轉時,還得先確保某些「善後動作」被執行。這些都不是「循序、選擇、重複」能直接解釋的。

進階篇要談的,正是這些非區域(non-local)、非線性的控制流程:例外與善後、產生器與協程、控制流程圖與基本區塊,以及 async/await 背後那台看不見的狀態機。讀完你會發現,所謂「程式由上往下執行」只是一個對初學者友善的善意謊言。

控制流程進階概念示意圖

例外:受控的「長距離跳躍」

入門篇提過 breakcontinue、提早 return 是「受約束的結構化跳躍」。例外(exception)是其中跳得最遠的一種——它能讓控制權從十層巢狀的函式深處,一路彈回最近一個願意接住它的 except

關鍵問題是:在這趟「長距離跳躍」途中,那些已經開啟的資源(檔案、鎖、網路連線)怎麼辦?答案是 finally堆疊回溯(stack unwinding)。當例外被拋出,執行環境會逐層彈出呼叫堆疊上的堆疊框;每彈出一層,就執行該層登記過的「善後程式碼」。

def write_log(msg):
    f = open("app.log", "a")
    try:
        if msg is None:
            raise ValueError("空訊息")   # 即使這裡跳走
        f.write(msg)
    finally:
        f.close()                        # 這行「保證」會執行

finally 區塊的語意是:無論 try 區塊是正常結束、return、還是拋出例外,它都會執行。這正是堆疊回溯時「每層善後」的具體實現。Python 的 with(context manager)是這個模式的語法糖,C++ 用解構子(destructor)在物件離開作用域時自動善後,這個慣用法叫 RAII(Resource Acquisition Is Initialization)。它們解決的是同一個問題:控制流程可能從任何一點離開,而資源必須被乾淨地釋放

例外不是「比較貴的 if」

一個常見迷思是把例外當成普通分支來用。實際上,例外設計的成本模型是「拋出時昂貴、不拋時幾乎免費」。許多實作採用「零成本例外(zero-cost exceptions)」:在沒有例外發生的正常路徑上,不放任何額外檢查指令,編譯器把「哪裡有 try、要回溯到哪、要執行哪些善後」全部編進一張獨立的查找表(unwind table)。代價是一旦真的拋出,執行環境得去查表、逐層回溯,這條路徑相當慢。

結論很清楚:例外是給「例外狀況」用的,不是給控制流程當分支用的。用例外取代 if 來處理常態邏輯(例如把「找不到」當例外狂拋),在熱路徑上會付出可觀代價。

控制流程圖:編譯器眼中的你的程式

要真正理解控制流程,得學會用編譯器的眼睛看程式。編譯器不看「行」,它把程式拆成基本區塊(basic block):一段只有一個入口、一個出口、中間沒有任何分支的連續指令。控制只會從區塊頂端進入、從底端離開,途中絕不跳走也不被跳入。

把基本區塊當節點、把「可能的跳轉」當有向邊,就得到控制流程圖(Control-Flow Graph, CFG)。這是所有編譯器最佳化與靜態分析的共同地基。

看一個例子:把程式畫成圖

考慮這段求絕對值再累加的程式:

def f(a, b):
    if a < 0:          # B0
        a = -a         # B1
    total = a + b      # B2
    return total       # B2

它的基本區塊與 CFG 是:

B0:  t = (a < 0)
     if t goto B1 else goto B2
        │                  │
        ▼                  │
B1:  a = -a                │
     goto B2               │
        │                  │
        └────────┬─────────┘
                 ▼
B2:  total = a + b
     return total

B0 有兩條出邊(分支),B1 與 B2 各只有一條出邊。注意 B2 同時是 B0 與 B1 的後繼——它是兩條路徑的「匯流點」。一旦程式被表示成這種圖,許多問題就變成圖論問題:

  • 可達性(reachability):從進入點走不到的區塊,就是死碼(dead code),可安全刪除。
  • 支配關係(dominance):若所有從進入點到 X 的路徑都必經 Y,則說 Y 支配(dominates) X。迴圈最佳化、SSA 形式都建立在支配樹上。
  • 迴圈識別:CFG 上一條「回邊(back edge)」——指向某個支配自己的節點——就標誌著一個迴圈。編譯器靠這個自動找出迴圈,再決定要不要把不變的計算搬到迴圈外(loop-invariant code motion)。

入門篇說「用三種結構就能寫出任何程式」(Böhm–Jacopini);CFG 則告訴你,就算原始碼用了三種乾淨結構,編譯器最終仍把它攤平成一張任意跳轉的圖,再在這張圖上推理與最佳化。抽象結構是給人讀的,CFG 是給機器算的。

產生器與協程:能「暫停」的函式

回到開頭那個 yield。一般函式只有兩種命運:跑完回傳,或拋例外離開。產生器(generator) 打破了這個二分法——它能在中途暫停,把一個值交出去,並且保留所有區域變數與執行位置,等下次被喚醒時從中斷處繼續。

def countdown(n):
    while n > 0:
        yield n        # 暫停在這裡,把 n 交出去
        n -= 1         # 下次被喚醒時,從這行繼續

g = countdown(3)
print(next(g))   # 3   (執行到第一個 yield 暫停)
print(next(g))   # 2   (從暫停處復活,跑到下一個 yield)
print(next(g))   # 1

這在控制流程上是革命性的:呼叫者與產生器輪流持有控制權,像兩個人交替發球。傳統函式呼叫是「主從關係」(呼叫者等待、被呼叫者跑完才回來),產生器則是對等(symmetric) 的協作——這種能雙向交還控制權的結構,就是協程(coroutine) 的雛形。

動手算一下:用協程做惰性無窮序列

協程最迷人的應用是表達無窮而仍可計算。下面這個產生器描述「所有自然數」,但它永遠不會把它們全算出來——只在你要的時候算下一個:

def naturals():
    n = 0
    while True:        # 看似無窮迴圈,卻不會卡死
        yield n
        n += 1

def take(gen, k):
    out = []
    for _ in range(k):
        out.append(next(gen))
    return out

print(take(naturals(), 5))   # [0, 1, 2, 3, 4]

while True 在普通函式裡是致命的無窮迴圈,但在產生器裡是完全合法、甚至優雅的——因為控制權在每個 yield 都被交還,迴圈只在被 next() 推動時才前進一步。這就是惰性求值(lazy evaluation) 的控制流程基礎:計算被「按需」驅動,而不是一次算完。資料管線、串流處理、無窮序列,全靠這個機制。

async/await:協程偽裝成的狀態機

現代語言的 async/await 看起來像在寫同步程式,骨子裡卻是上面協程概念的延伸。它要解決的問題是:等待 I/O(網路、磁碟)時,CPU 不該乾等,應該去做別的事。

async def fetch_all(urls):
    results = []
    for url in urls:
        data = await download(url)   # 在這裡「讓出」控制權
        results.append(data)
    return results

await 的語意和 yield 是親戚:當 download 還沒好,這個協程就在 await暫停並交還控制權給事件迴圈(event loop),讓它去推進別的協程;資料就緒後再回到這裡繼續。關鍵在於——

編譯器會把這個 async 函式改寫成一台狀態機(state machine)。 每一個 await 點都是一個狀態。函式的區域變數不再放在呼叫堆疊上(因為函式會反覆暫停、退出、再進入,堆疊框早被彈掉了),而是被搬進一個堆積(heap)上的物件裡保存。下面是這個改寫的概念骨架:

狀態 0:(進入)設好迴圈,發出第一個 download 請求 → 暫停,記住「下次回狀態 1」
狀態 1:(資料就緒)把結果存入 results;還有 url 嗎?
          有 → 發出下一個 download → 暫停,仍回狀態 1
          沒有 → 進入狀態 2
狀態 2:(完成)回傳 results

理解這層改寫,能讓你看穿許多 async 的「怪現象」:為什麼 await 只能在 async 函式裡用(因為只有它會被改寫成狀態機);為什麼忘了 await 一個協程它就不動(你只是建了狀態機物件,沒去驅動它);為什麼 async 函式裡放一個會卡住 CPU 的同步迴圈會凍結整個事件迴圈(狀態機沒機會交還控制權)。async/await 不是魔法,是 yield 加上一個自動排程器,再加上編譯器的狀態機改寫。

重點回顧

  • 例外是受控的長距離跳躍,靠堆疊回溯逐層執行 finally/解構子做善後;它的成本模型是「不拋幾乎免費、拋出昂貴」,不該拿來當普通分支
  • 編譯器把程式拆成基本區塊(單入口單出口),組成控制流程圖(CFG);可達性、支配關係、回邊等圖論性質是死碼消除、迴圈最佳化的基礎。
  • 產生器/協程能在 yield暫停並保留狀態,讓呼叫者與被呼叫者對等地輪流持有控制權,是惰性求值與無窮序列的根基。
  • async/await 是協程加事件迴圈:編譯器把 async 函式改寫成狀態機,區域變數搬到堆積保存,每個 await 是一個可暫停的狀態。
  • 「程式由上往下執行」是入門的善意簡化;真實的控制流程是會回溯、會暫停、會被攤平成圖、會被改寫成狀態機的非線性結構

深入探討(研究所視角)

延續(continuation):控制流程的「終極抽象」

前面所有機制——例外、產生器、協程、async——其實都能用同一個更深的概念統一解釋:延續(continuation)。一個運算式的延續,是「算完這個值之後,程式接下來要做的所有事」這整個未來,被具體化(reify)成一個可以儲存、傳遞、甚至呼叫的物件。

延續傳遞風格(Continuation-Passing Style, CPS) 中,函式不再「回傳」,而是把結果交給一個代表「接下來」的延續函式 $k$。一般寫法 $f(x)$ 的回傳,在 CPS 下變成 $f(x, k)$,由 $f$ 在算完後呼叫 $k(\text{result})$。任何程式都可機械化地轉成 CPS——這是編譯器(尤其是函數式語言編譯器)常用的中間表示。

CPS 的威力在於它讓「控制流程」變成可被一級物件(first-class)操作的資料:

  • 例外 = 持有一個「錯誤延續」,拋出就是呼叫它而非正常延續。
  • 產生器/協程的暫停 = 把「當前延續」存起來,之後再呼叫它就能從原地復活。
  • async 的狀態機 = 編譯器手工實作的、受限的延續儲存(每個 await 存一份)。

Scheme 的 call/cc(call-with-current-current-continuation)把延續完整暴露給程式設計師,可以實作出上述全部控制結構,威力強大到被稱為「控制流程的 goto」——也同樣容易濫用。

一個統一的視角:control effect 與 effect handlers

把上面這條線索拉到當代研究前沿:例外、產生器、async、非決定性搜尋……這些看似各自獨立的控制特性,現在被統一在代數效應(algebraic effects)與效應處理器(effect handlers) 的框架下。其核心觀察是:這些機制都是「在某處暫停當前計算、把控制權連同一段被捕捉的延續交給一個處理器(handler)、由處理器決定是否、以及如何恢復」的特例。

  • 例外 = 處理器恢復延續(一次性、單向離開)。
  • 產生器 = 處理器恢復延續恰好一次,並可往返多次。
  • 非決定性/回溯搜尋 = 處理器恢復延續多次(每個選擇分支各一次)。

OCaml 5 的 effect handlers、Koka、Eff 等語言把這個理論落地,讓使用者能用同一套機制自訂新的控制結構,而不必等語言設計者把 asyncyield 一個個內建進來。其型別系統還能在型別上標記「這段程式可能引發哪些 effect」,把控制流程的副作用納入靜態檢查——這與入門篇談的純函數、參考透明遙相呼應:純度的本質,就是「沒有未被處理的 effect」。

與正確性分析的連結

CFG 不只是最佳化的工具,也是程式驗證的舞台。資料流分析(data-flow analysis)在 CFG 上以不動點(fixpoint)迭代求解:例如「活躍變數分析(liveness)」回答每個程式點上哪些變數之後還會被用到、「到達定義(reaching definitions)」追蹤某個賦值能影響到哪些使用點。這些分析支撐了未初始化變數警告、暫存器配置、常數傳播等功能。當控制流程引入例外與協程,CFG 必須加上「例外邊」與「暫停/恢復邊」,分析的精確度與複雜度都隨之上升——這也是為什麼支援完整 async 與例外語意的靜態分析工具,至今仍是活躍的研究領域。

從一個 yield 到代數效應,從 finally 到延續,控制流程的進階面貌揭示了一件事:「程式接下來做什麼」本身,就是一種可以被命名、被儲存、被傳遞、被型別檢查的資料。 當你能把「未來」當成第一級公民來操作,你才真正握住了控制流程的方向盤。

AI 共讀助教正在陪你讀:控制流程(進階)
嗨!我是這篇文章的共讀助教,只根據〈控制流程(進階)〉的內容回答。可以問我「解釋某段」「舉個例子」「出題考我」,或反白文中段落後點下方「解釋選取段落」。