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

函數與模組化

函數與模組化

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

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

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

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