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

控制流程

控制流程(進階)

當「下一行」不再是下一行:例外回溯、控制流程圖、產生器與協程,到 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 共讀助教正在陪你讀:控制流程(進階)
嗨!我是這篇文章的共讀助教,只根據〈控制流程(進階)〉的內容回答。可以問我「解釋某段」「舉個例子」「出題考我」,或反白文中段落後點下方「解釋選取段落」。