Python 函數與模組:從重複貼上到優雅重用
從 def、參數與作用域,一路走到自訂模組與套件,學會用函數把程式拆成可組裝的積木。
從重複貼上到優雅重用:一個記帳小程式的進化
想像你正在寫一個記帳小工具,要把每筆金額換算成含稅價格。第一個版本你可能會這樣寫:
price1 = 100
total1 = price1 * 1.05
print(f"含稅後:{total1}")
price2 = 250
total2 = price2 * 1.05
print(f"含稅後:{total2}")
# 輸出:
# 含稅後:105.0
# 含稅後:262.5
看出問題了嗎?同樣的邏輯被複製貼上,一旦稅率從 5% 改成 6%,你得逐行修改,漏改一處就出 bug。函數(function)正是為了解決這種重複而生。把「一段做某件事的程式碼」打包起來,給它一個名字,之後就能反覆呼叫。這篇文章我們從最基本的 def 出發,一路走到模組(module)與套件(package),讓你的程式從一團散沙變成可組裝的積木。

用 def 定義你的第一個函數
在 Python 中,用 def 關鍵字定義函數。基本骨架是「def 名稱(參數):」加上縮排的函數主體:
def add_tax(price):
return price * 1.05
print(add_tax(100)) # 輸出:105.0
print(add_tax(250)) # 輸出:262.5
現在稅率邏輯只存在一個地方。price 是參數(parameter),代表呼叫時要傳入的輸入;return 把計算結果交還給呼叫端。如果一個函數沒有寫 return,它會自動回傳 None:
def greet(name):
print(f"你好,{name}")
result = greet("小明") # 輸出:你好,小明
print(result) # 輸出:None
這裡要分清楚兩件事:print() 是把文字「顯示」在畫面上,return 是把值「交還」給程式繼續使用。初學者最常混淆這兩者——一個函數只負責印出東西卻沒有 return,那它的回傳值就是 None,沒辦法拿去做後續運算。
參數與回傳:函數的輸入與輸出
函數可以接收多個參數,也可以回傳多個值(其實是回傳一個元組(tuple)):
def divide(dividend, divisor):
quotient = dividend // divisor
remainder = dividend % divisor
return quotient, remainder
q, r = divide(17, 5)
print(f"商:{q},餘數:{r}")
# 輸出:商:3,餘數:2
return quotient, remainder 實際上回傳了一個元組 (3, 2),左側的 q, r = ... 再把它拆開(unpacking)。這是 Python 很慣用的寫法,比起用 list 包起來再用索引取值要清楚得多。
呼叫函數時,預設是按照位置(positional)對應的——第一個值給第一個參數,第二個值給第二個參數。所以 divide(17, 5) 的 17 是被除數、5 是除數,順序不能亂。
預設值:讓參數可以省略
如果某個參數大多數時候都是同一個值,可以給它預設值(default value),呼叫時就能省略:
def add_tax(price, rate=0.05):
return price * (1 + rate)
print(add_tax(100)) # 用預設 5%,輸出:105.0
print(add_tax(100, 0.10)) # 改成 10%,輸出:110.00000000000001
有預設值的參數必須放在沒有預設值的參數後面,否則 Python 會直接報語法錯誤:
# 反模式:會 SyntaxError
# def add_tax(rate=0.05, price):
# ...
道理很直觀:如果可省略的參數排在前面,Python 在解析位置引數時就會無所適從。
關鍵字引數:用名字指定,順序不再重要
當參數變多,光靠位置容易記錯。這時可以用關鍵字引數(keyword argument),直接寫出參數名稱:
def make_profile(name, age, city):
return f"{name},{age} 歲,住在 {city}"
# 位置引數:必須記順序
print(make_profile("小華", 20, "台北"))
# 關鍵字引數:順序可以打亂,意圖更清楚
print(make_profile(city="高雄", name="小美", age=22))
# 輸出:
# 小華,20 歲,住在 台北
# 小美,22 歲,住在 高雄
關鍵字引數讓呼叫端的程式碼自我說明(self-documenting)。當你看到 make_profile(city="高雄", ...),立刻知道 "高雄" 是城市。位置引數與關鍵字引數可以混用,但位置引數一定要寫在前面:
print(make_profile("小強", city="台中", age=25)) # 正確
# print(make_profile(name="小強", 25, "台中")) # 錯誤:位置引數不能跟在關鍵字引數後
args 與 *kwargs:接收不定數量的引數
有時你不知道呼叫端會傳幾個值。*args 會把多餘的位置引數收集成一個元組,**kwargs 會把多餘的關鍵字引數收集成一個字典(dict):
def total(*args):
s = 0
for n in args:
s += n
return s
print(total(1, 2, 3)) # 輸出:6
print(total(10, 20, 30, 40)) # 輸出:100
def describe(**kwargs):
for key, value in kwargs.items():
print(f"{key}:{value}")
describe(name="小明", score=95, subject="數學")
# 輸出:
# name:小明
# score:95
# subject:數學
名稱 args 與 kwargs 只是慣例,真正起作用的是 * 與 **。你也可以把兩者跟一般參數一起用,完整的參數順序是:一般參數、*args、有預設值的參數、**kwargs:
def log(level, *messages, sep=" | ", **meta):
text = sep.join(messages)
print(f"[{level}] {text}")
for k, v in meta.items():
print(f" {k}={v}")
log("INFO", "啟動", "載入設定", user="admin", pid=1234)
# 輸出:
# [INFO] 啟動 | 載入設定
# user=admin
# pid=1234
反過來,* 與 ** 也能在呼叫時「拆開」序列與字典:
nums = [1, 2, 3]
print(total(*nums)) # 等同 total(1, 2, 3),輸出:6
info = {"name": "小美", "score": 88, "subject": "英文"}
describe(**info) # 等同 describe(name="小美", ...)
作用域 LEGB:變數從哪裡來
當你在函數裡用到一個變數,Python 怎麼決定它指的是哪一個?答案是 LEGB 規則,依序搜尋四層:
- Local:當前函數內部
- Enclosing:外層巢狀函數
- Global:模組層級(整個檔案)
- Built-in:Python 內建(如
len、print)
x = "global" # G
def outer():
x = "enclosing" # E
def inner():
x = "local" # L
print(x)
inner()
outer() # 輸出:local
inner 印出 x 時,先找自己的 Local,找到 "local" 就停止。如果把 inner 裡的賦值拿掉,它會往外找到 Enclosing 的 "enclosing";再拿掉就找到 Global 的 "global"。
要特別小心:在函數內賦值會建立一個新的區域變數,而不是修改外層的同名變數。這常造成困惑:
count = 0
def increment():
count = count + 1 # UnboundLocalError!
return count
# increment() # 會報錯
因為函數裡有 count = ...,Python 認定 count 是區域變數,但等號右側又要先讀取它,此時它還沒被賦值,於是出錯。如果真要修改全域變數,要明確宣告 global(修改 enclosing 變數則用 nonlocal):
count = 0
def increment():
global count
count += 1
return count
print(increment()) # 輸出:1
print(increment()) # 輸出:2
不過實務上,過度依賴 global 是反模式——它讓資料流變得難以追蹤。比較好的做法是把值當參數傳入、把結果 return 出來。
動手寫一段:成績統計小工具
把前面的概念串起來,寫一個能算出任意數量分數統計的小程式:
def analyze(*scores, passing=60):
"""計算一組分數的統計資訊。
Args:
*scores: 任意數量的分數
passing: 及格門檻,預設 60
Returns:
包含平均、最高、及格人數的字典
"""
if not scores:
return {"average": 0, "highest": 0, "passed": 0}
average = sum(scores) / len(scores)
highest = max(scores)
passed = sum(1 for s in scores if s >= passing)
return {
"average": round(average, 1),
"highest": highest,
"passed": passed,
}
report = analyze(85, 92, 47, 60, 73, passing=60)
print(f"平均:{report['average']}")
print(f"最高:{report['highest']}")
print(f"及格人數:{report['passed']}")
# 輸出:
# 平均:71.4
# 最高:92
# 及格人數:4
這段程式用 *scores 接收任意數量分數,用 passing 提供有預設值的及格門檻,用 dict 回傳結構化的結果,還寫了 docstring 說明用途。這正是符合 Python 慣例的函數設計。
import 與自訂模組:把函數搬出去重用
當函數越來越多,全擠在一個檔案裡會難以維護。模組(module)就是一個 .py 檔案,可以被別的檔案 import 進來使用。先看標準函式庫:
import math
print(math.sqrt(16)) # 輸出:4.0
from random import randint
print(randint(1, 6)) # 輸出:1 到 6 之間的隨機整數
import statistics as stats # 取別名
print(stats.mean([1, 2, 3, 4])) # 輸出:2.5
import math 把整個模組載入,用 math.sqrt 存取;from random import randint 只取出需要的名稱,可以直接寫 randint;import statistics as stats 給模組取別名,方便書寫。
自訂模組也一樣。假設你有一個 tax_utils.py:
# tax_utils.py
TAX_RATE = 0.05
def add_tax(price, rate=TAX_RATE):
return price * (1 + rate)
def remove_tax(total, rate=TAX_RATE):
return total / (1 + rate)
在同目錄的另一個檔案就能引用它:
# main.py
import tax_utils
print(tax_utils.add_tax(100)) # 輸出:105.0
print(tax_utils.TAX_RATE) # 輸出:0.05
from tax_utils import remove_tax
print(round(remove_tax(105), 2)) # 輸出:100.0
這裡有個重要慣例:模組底部常見的 if __name__ == "__main__": 區塊。當檔案被直接執行時,__name__ 等於 "__main__";被 import 時則等於模組名稱。藉此可以把「測試用的主程式」與「被引用的函數」分開:
# tax_utils.py 底部
if __name__ == "__main__":
# 只有直接執行 python tax_utils.py 才會跑這段
print("自我測試:", add_tax(200))
套件:用資料夾組織多個模組
當模組多到一個資料夾裝不下時,就用套件(package)——一個包含多個模組的資料夾。傳統上資料夾裡會放一個 __init__.py(可以是空檔),標示這是一個套件:
finance/
├── __init__.py
├── tax.py
└── interest.py
引用時用點號表示階層:
from finance.tax import add_tax
from finance import interest
print(add_tax(100))
print(interest.compound(1000, 0.02, 3))
這種階層式組織,讓大型專案的程式碼各歸其位,也是 Uedu 後端用 app_quiz、app_survey 等模組拆分功能的同樣精神——每個檔案專注一件事,彼此透過 import 協作。
常見錯誤
- 混淆
print與return:函數只print不return,回傳值就是None,無法拿去做後續運算。要把結果交給程式用,請用return。 - 預設值參數排在前面:
def f(rate=0.05, price)會直接語法錯誤。有預設值的參數一律放後面。 - 位置引數寫在關鍵字引數後面:
f(name="x", 25)會報錯。位置引數必須在前。 - 函數內想改全域變數卻沒宣告
global:count = count + 1在函數裡會引發UnboundLocalError,因為 Python 把count當成尚未賦值的區域變數。 - 濫用
global:能用參數傳入、回傳值傳出,就不要碰全域變數——它讓資料流難以追蹤、測試困難。
深入探討(研究所視角)
可變預設值的陷阱
預設值有一個經典坑:預設值物件只在函數「定義時」建立一次,而非每次呼叫時重建。如果預設值是可變物件(mutable)如 list 或 dict,這個物件會在多次呼叫間被共用:
def append_item(item, bucket=[]): # 反模式!
bucket.append(item)
return bucket
print(append_item(1)) # 輸出:[1]
print(append_item(2)) # 輸出:[1, 2] —— 竟然累積了!
print(append_item(3)) # 輸出:[1, 2, 3]
那個 [] 在函數物件建立時就生成,綁在函數的 __defaults__ 屬性上,所有呼叫共用同一個 list。正確做法是用 None 當哨兵(sentinel),在函數內判斷後才建立新物件:
def append_item(item, bucket=None):
if bucket is None:
bucket = []
bucket.append(item)
return bucket
print(append_item(1)) # 輸出:[1]
print(append_item(2)) # 輸出:[2] —— 每次都是新的
這個設計取捨源於 Python「預設值在定義期求值(evaluated at definition time)」的語義。理解它,你才能在看似詭異的 bug 面前不慌張。
第一級函數與閉包
在 Python 中,函數是第一級物件(first-class object):可以指派給變數、放進資料結構、當作引數傳遞、也能被當作回傳值。這是函數式程式設計(functional programming)的基礎:
def square(x):
return x * x
operations = [square, abs, str] # 函數放進 list
for op in operations:
print(op(-3))
# 輸出:
# 9
# 3
# -3
更進一步,函數可以「記住」它定義時所在的外層環境,形成閉包(closure)。下面的 make_multiplier 回傳一個內層函數,後者捕捉了外層的 factor:
def make_multiplier(factor):
def multiplier(n):
return n * factor # 記住外層的 factor
return multiplier
double = make_multiplier(2)
triple = make_multiplier(3)
print(double(10)) # 輸出:20
print(triple(10)) # 輸出:30
double 與 triple 各自封存了不同的 factor 值。這些被捕捉的變數存放在函數的 __closure__ 屬性中(每個是一個 cell 物件)。閉包正是裝飾器(decorator)、回呼(callback)、惰性求值等高階模式的基石。如果內層函數需要修改被捕捉的變數,就要用前面提過的 nonlocal 宣告。
模組載入機制
import 背後並非單純「把檔案貼進來」。第一次 import 一個模組時,Python 會執行整個模組檔案(這就是為什麼模組頂層的 print 會在 import 時觸發),把產生的命名空間包成一個 module 物件,並快取到 sys.modules 字典裡。之後再次 import 同一模組,Python 直接從 sys.modules 取出快取,不會重新執行:
import sys
import math
print("math" in sys.modules) # 輸出:True
這個快取機制有兩個重要後果。其一,模組頂層程式碼具有「單例(singleton)」般的初始化效果——設定資料庫連線、載入設定檔等只會跑一次。其二,如果你在開發中改了模組原始碼,已經 import 的程式不會自動看到變更,必須重啟直譯器(或在互動環境用 importlib.reload)。
Python 搜尋模組的路徑記錄在 sys.path 串列裡,依序包含當前目錄、PYTHONPATH 環境變數指定的路徑、以及標準函式庫與第三方套件的安裝位置。理解這條搜尋鏈,就能解釋「為什麼我的 import my_module 找不到檔案」這類問題——多半是因為該檔案不在 sys.path 的任一目錄中。掌握載入機制與快取語義,是從「會寫函數」邁向「會設計模組架構」的關鍵一步。