函數與模組化
從定義、參數到呼叫堆疊與純函數,理解如何把程式拆成可重用、可推理的乾淨零件
為什麼食譜不用每次都從「種小麥」寫起?
想像你要寫一份「做三明治」的食譜。如果每次都得從「種一棵小麥、磨成麵粉、烤一條吐司」寫起,這份食譜會長到沒人想看。真實世界的食譜會直接寫「取兩片吐司」——因為「做吐司」這件事已經被別人封裝好了,你只要知道怎麼用,不必知道怎麼做。
程式裡的函數(function)就是這個「做吐司」的封裝。它把一段有名字、可重複使用的邏輯包起來,給定輸入、產生輸出。你呼叫它的時候,只需要在意「我給它什麼、它還我什麼」,而不必每次重寫內部細節。這種「把複雜度藏進盒子,只露出一個乾淨介面」的思維,正是寫出可維護程式的起點。
函數的三件事:定義、參數、回傳
一個函數通常由三個部分構成:定義(給它一個名字與一段邏輯)、參數(parameter)(從外面接收資料)、回傳(return)(把結果交回呼叫者)。

以一個計算圓面積的函數為例:
def circle_area(radius): # 定義:名字叫 circle_area
pi = 3.14159
return pi * radius * radius # 回傳計算結果
a = circle_area(5) # 呼叫,並把 5 當成參數傳入
print(a) # 78.53975
這裡有兩個容易混淆的詞要分清楚:
- 參數(parameter):函數定義時寫在括號裡的「佔位名稱」,例如上面的
radius。 - 引數(argument):實際呼叫時填進去的「真實值」,例如
circle_area(5)裡的5。
換句話說,radius 是「請給我一個半徑」這個約定,5 才是你真正交出去的數字。
回傳值讓函數可以被組合。circle_area(5) 的結果是一個數,可以再丟給別的函數,像積木一樣串接。如果一個函數沒有寫 return,多數語言會回傳一個「空」的值(Python 是 None),代表「我做完了,但沒有東西要交給你」。
傳值 vs 傳參考:你交出去的是「副本」還是「本尊」?
當你把資料傳進函數,到底傳的是什麼?這牽涉到兩種語意:
- 傳值(pass by value):函數收到的是一份副本。在函數內怎麼改,都不影響外面的原始資料。
- 傳參考(pass by reference):函數收到的是指向原始資料的參考(reference)。在函數內修改,外面也跟著變。
我們用一個對照表來看不同型別的行為:
| 資料型別 | 行為 | 函數內修改會影響外部嗎 |
|---|---|---|
| 整數、字串(不可變 immutable) | 像傳值 | 否 |
| 串列、字典(可變 mutable) | 像傳參考 | 是 |
實際在 Python 看一下:
def add_tax(price):
price = price * 1.05 # 改的是參數的副本
return price
p = 100
add_tax(p)
print(p) # 仍然是 100,外面沒被改到
def append_item(cart):
cart.append("apple") # 直接改到外部那個串列
items = []
append_item(items)
print(items) # ['apple'],外面被改到了!
嚴格說,Python 的機制是「傳物件參考的副本」(pass by object reference):傳進去的永遠是「參考」,但不可變物件(數字、字串、元組)因為無法被就地修改,看起來就像傳值;可變物件(串列、字典)則可以被就地改動,看起來像傳參考。
這個區別是無數 bug 的源頭。最常見的迷思是「我傳進去就安全了」——對可變物件來說並非如此。如果你不希望函數動到原始資料,要主動傳一份複本(例如 items[:] 或 list(items))。
作用域:名字住在哪裡
作用域(scope) 決定一個變數名字在哪些地方「看得到」。函數內部宣告的變數是區域變數(local variable),只在該函數執行期間存在,函數結束就消失。函數外面宣告的是全域變數(global variable),整個程式都看得到。
total = 0 # 全域變數
def add(x):
result = x + 1 # 區域變數,只活在 add 內部
return result
add(5)
print(result) # 錯誤!外面看不到 result
當函數內外有同名變數時,內部的會「遮蔽(shadow)」外部的。Python 查找名字的順序遵循 LEGB 規則:Local(區域)→ Enclosing(外層巢狀函數)→ Global(全域)→ Built-in(內建)。理解作用域能避免「為什麼我改了變數卻沒效果」這類困惑——很可能你改的是區域副本,不是全域本尊。
刻意把變數的作用域收窄是好習慣。大量依賴全域變數會讓程式變得難以推理:任何函數都可能偷偷改動它,出錯時你不知道兇手是誰。
呼叫堆疊:函數呼叫函數時,誰先回來?
當 A 呼叫 B、B 又呼叫 C,程式怎麼記得 C 做完要回到 B、B 做完要回到 A?答案是呼叫堆疊(call stack)。
每呼叫一個函數,系統就在堆疊頂端壓入一個堆疊框(stack frame),裡面存放這次呼叫的區域變數、參數,以及「做完要回到哪一行」的返回位址。函數回傳時,它的框被彈出,控制權交還給下一層。這是個後進先出(LIFO, Last-In-First-Out)的結構——最後被呼叫的最先回來。
動手看一個例子
我們追蹤這段程式的堆疊變化:
def greet(name):
msg = make_message(name)
return msg
def make_message(name):
return "Hi, " + name
greet("Ada")
逐步演算堆疊狀態:
步驟 1:呼叫 greet("Ada")
┌─────────────────────┐
│ greet name="Ada" │ ← 堆疊頂
└─────────────────────┘
步驟 2:greet 內呼叫 make_message("Ada")
┌─────────────────────────┐
│ make_message name="Ada" │ ← 堆疊頂
├─────────────────────────┤
│ greet name="Ada" │
└─────────────────────────┘
步驟 3:make_message 回傳 "Hi, Ada",其框被彈出
┌─────────────────────┐
│ greet msg="Hi, Ada"│ ← 堆疊頂
└─────────────────────┘
步驟 4:greet 回傳,堆疊清空
這個結構也解釋了為什麼程式出錯時印出來的訊息叫做「堆疊追蹤(stack trace)」——它其實就是把當下堆疊裡每一層框由上而下列出來,讓你看到呼叫的脈絡。
模組化與重用:把程式切成可組裝的零件
當功能變多,把所有程式碼塞在一個檔案、一個大函數裡會迅速失控。模組化(modularity) 是把程式拆成多個各司其職的小單元(函數、模組、套件),每個單元有清楚的職責與介面。
模組化帶來幾個直接好處:
- 重用(reuse):寫好的
circle_area可以在十個地方呼叫,不必複製貼上十次。 - 可讀性:好的函數名稱本身就是文件,
calculate_tax()比一串看不懂的算式好懂。 - 可測試性:小函數容易單獨測試。
- 可維護性:修 bug 或改演算法時,只要動一個地方。
Python 用 import 把別的模組拉進來重用:
import math
print(math.sqrt(16)) # 4.0,不必自己寫開根號
模組化的核心精神是單一職責:一個函數只做一件事,並把它做好。如果你發現一個函數名字裡得用「而且」才能描述清楚(「驗證使用者而且寄送郵件而且寫入資料庫」),那它通常該被拆開。
副作用:函數在背地裡做的事
副作用(side effect) 指函數除了回傳值之外,還對外部世界造成的改變:修改了全域變數、改動了傳入的串列、印出文字、寫入檔案、發送網路請求……這些都是副作用。
副作用本身不是壞事——程式總得在某處印出畫面、存檔、改資料庫。問題在於不可預期的副作用。一個名字看起來只是「計算」的函數,卻偷偷改了全域狀態,會讓人在除錯時抓破頭。
log = []
def calculate(x):
result = x * 2
log.append(result) # 副作用:偷偷改了外部的 log
return result
呼叫者只看函數簽名 calculate(x),完全猜不到它會動到 log。當很多這種函數交織在一起,程式的行為就會變得難以推理。
純函數:可預測的好處
純函數(pure function) 是一種理想的函數,它滿足兩個條件:
- 相同輸入永遠得到相同輸出(不依賴外部可變狀態)。
- 沒有副作用(不改動外部世界)。
# 純函數:給定 a、b,結果永遠一樣,且不碰外部
def add(a, b):
return a + b
# 非純函數:依賴外部狀態,且有副作用
counter = 0
def next_id():
global counter
counter += 1 # 改動外部狀態
return counter
純函數的好處非常實際:
- 容易測試:給定輸入就能斷言輸出,不必先佈置一堆外部環境。
- 容易推理:你可以孤立地理解它,不必擔心它在背後動了什麼手腳。
- 可快取(cache):相同輸入既然永遠同樣輸出,結果可以記憶下來重用(這個技巧叫 memoization)。
- 可安全平行化:純函數之間沒有共享狀態的競爭,天生適合多執行緒/分散式運算。
實務上不可能(也不需要)讓每個函數都純。常見的做法是把純粹的計算邏輯和有副作用的部分(讀寫檔案、印出、連網)分開:核心算邏輯保持純淨、好測試,副作用集中在程式邊緣的少數幾個地方。這種「功能核心、命令式外殼(functional core, imperative shell)」的架構,能同時兼顧可測試性與實用性。
重點回顧
- 函數把可重複使用的邏輯封裝起來,由定義、參數、回傳三部分構成;參數是定義時的佔位名稱,引數是呼叫時的真實值。
- 傳值傳的是副本、傳參考傳的是本尊;Python 對不可變物件像傳值、對可變物件像傳參考,傳可變物件時要小心被就地改動。
- 作用域決定名字的可見範圍;區域變數函數結束就消失,刻意收窄作用域可降低錯誤。
- 呼叫堆疊以後進先出方式管理函數呼叫,每次呼叫壓入一個堆疊框,回傳時彈出。
- 純函數(相同輸入相同輸出、無副作用)易測試、易推理、可快取、可平行化;把純邏輯與副作用分離是良好架構。
深入探討(研究所視角)
呼叫堆疊與堆疊溢位的機制。 呼叫堆疊在記憶體中是一塊固定(或上限受限)的連續區域。每個堆疊框佔用空間存放區域變數、保存的暫存器、與返回位址。當遞迴(recursion)太深、或函數的區域變數太大,堆疊不斷往下增長而碰到上限,就會發生堆疊溢位(stack overflow)。以一個沒有正確終止條件的遞迴為例:
def boom(n):
return boom(n + 1) # 永遠呼叫下去,框不斷堆疊
boom(0) # RecursionError: maximum recursion depth exceeded
每一層 boom 都壓入一個新框卻永遠不返回,堆疊終究撐爆。Python 設有遞迴深度上限(預設約 1000)以友善地拋出例外,而 C/C++ 這類沒有保護的語言則可能直接導致程式崩潰,甚至成為被利用的資安弱點。
這也帶出尾呼叫最佳化(tail call optimization, TCO) 的議題。如果一個遞迴呼叫是函數做的「最後一件事」(尾呼叫),理論上可以重用當前的堆疊框而不必新增一層,把遞迴在空間上降為 $O(1)$。Scheme、部分 Scala 場景與某些函數式語言實作了 TCO;但 Python 設計者刻意不支援,理由是會犧牲堆疊追蹤的可讀性。因此在 Python 寫深層遞迴時,往往要改寫成迭代(iteration)、或用自己維護的顯式堆疊,把空間從呼叫堆疊搬到堆積(heap)。
遞迴與迭代的等價性與成本。 任何遞迴都能改寫為迭代(反之亦然),這是計算理論的基本結論。但兩者的常數成本不同:遞迴版本每層都有函數呼叫的開銷(壓框、傳參、返回),對應的空間複雜度通常是 $O(d)$,其中 $d$ 是遞迴深度;迭代版本則常能做到 $O(1)$ 額外空間。設計演算法時,這個空間取捨往往和時間複雜度同等重要。
純度、參考透明與編譯器最佳化。 純函數對應到一個更深的性質——參考透明(referential transparency):一個運算式可以被它的值直接取代而不改變程式語意。具備這個性質,編譯器與執行環境就能放心地做積極最佳化:共用子運算式消除、惰性求值(lazy evaluation)、自動記憶化、以及把獨立的純運算分派到多核心平行執行。Haskell 這類純函數式語言把副作用用型別系統(如 IO monad)明確標記出來,正是為了讓「哪裡有副作用」在型別上一目了然,把前面提到的「功能核心、命令式外殼」上升為語言層級的保證。
與其他主題的連結。 堆疊框的配置與作業系統的記憶體管理、與編譯器如何生成函數呼叫的機器碼(calling convention、暫存器保存規則)緊密相關;副作用的控制則與並行程式設計裡的資料競爭(data race)、與資料庫的交易隔離概念遙相呼應。把函數設計得更接近純粹、把作用域收得更窄、把副作用集中管理,這些看似只是「寫程式的好習慣」,背後其實連結著從硬體到型別理論的一整條知識鏈。