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

UeduGPTs

--

Jupyters

4

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

AI 回覆桌面通知

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

聊天訊息通知

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

聲音通知

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

函數與模組化

函數與模組化

從定義、參數到呼叫堆疊與純函數,理解如何把程式拆成可重用、可推理的乾淨零件

為什麼食譜不用每次都從「種小麥」寫起?

想像你要寫一份「做三明治」的食譜。如果每次都得從「種一棵小麥、磨成麵粉、烤一條吐司」寫起,這份食譜會長到沒人想看。真實世界的食譜會直接寫「取兩片吐司」——因為「做吐司」這件事已經被別人封裝好了,你只要知道怎麼用,不必知道怎麼做

程式裡的函數(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) 是一種理想的函數,它滿足兩個條件:

  1. 相同輸入永遠得到相同輸出(不依賴外部可變狀態)。
  2. 沒有副作用(不改動外部世界)。
# 純函數:給定 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)、與資料庫的交易隔離概念遙相呼應。把函數設計得更接近純粹、把作用域收得更窄、把副作用集中管理,這些看似只是「寫程式的好習慣」,背後其實連結著從硬體到型別理論的一整條知識鏈。

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