M6 函式與模組
- 用
def定義函式,分清參數、引數和回傳值; - 分清
print和return,看懂函式回傳None時的錯誤; - 用標準函式庫、第三方套件和自己寫的模組,說出
import從哪裏找東西; - 在 notebook 裏修改了自己的模組之後,知道為甚麼要重新啟動 kernel。
1同一段程式寫了三次 The same code, three times
M5 的日報計算每張訂單的金額時,寫了一段兩層迴圈。後來店長想知道「打九折後是多少」,又想知道「只計某一位客人的訂單」,同一段迴圈就被複製了三次。
複製的程式有一個大問題:發現其中一份有錯(例如忘了跳過目錄裏沒有的書),要記得改全部三份。漏改一份,三個地方算出來的數就不一樣。
函式(function)就是把一段程式起一個名字,需要時用名字叫它。你其實早就在用函式:len()、print()、sorted()、json.loads() 都是。
2定義與呼叫 Defining and calling
def order_total(order, titles):
amount = 0
for item in order["items"]:
book = titles.get(item["title"])
if book is not None:
amount += book["price"] * item["qty"]
return amount
def開始定義一個函式,後面是函式的名字,括號裏是它需要的資料,叫做參數(parameter);- 縮排了的部分是函式的內容,和
if、for一樣用縮排表示「屬於它」; return把結果交回給呼叫它的地方,叫做回傳值(return value)。
titles 就是 M5 日報裏那個「書名 → 書」的 dict。M5 練習裏它叫 by_title;這一單元 by_title 成了 bookshop 模組裏建立它的函式的名字,所以 dict 改叫 titles。
定義只是把這段程式記下來,不會執行。要執行,就呼叫它:寫名字,加括號,把資料放進去。放進去的實際資料叫做引數(argument)。
order_total(orders[0], titles) # 335
order_total(orders[3], titles) # 435
3參數與回傳值 Parameters and return values
參數可以有預設值。呼叫時不給,就用預設值:
def order_total(order, titles, discount=1.0):
... # 同上
return amount * discount
order_total(orders[0], titles) # 335.0:不打折
order_total(orders[3], titles, discount=0.9) # 391.5:九折
order_total(orders[3], titles, 0.9) # 391.5:按位置傳入也可以
discount=0.9 這種寫法叫關鍵字引數:寫明交給哪個參數,讀的人一看就懂;0.9 單獨一個則要靠位置對應,參數多了就容易對錯。有預設值的參數,習慣上都用關鍵字引數傳入。
一個函式也可以回傳幾個值,其實是回傳一個 tuple(M4),接收時可以一次拆開。min(b["price"] for b in books) 的括號裏,是 M5 的 comprehension 去掉了方括號:逐本書取出價錢,直接交給 min()。
def price_range(books):
return min(b["price"] for b in books), max(b["price"] for b in books)
low, high = price_range(catalog) # 95, 180
4print 和 return print vs return
這是初學者寫函式最常犯的錯:
def show_total(order, titles):
amount = ... # 同上,算出金額
print(amount) # 只是印出來,沒有 return
result = show_total(orders[0], titles) # 畫面印出 335
result # None
result * 0.9 # TypeError: unsupported operand type(s) for *: 'NoneType' and 'float'
print 是給人看的,畫面上有了,但程式拿不到;return 是給程式用的,呼叫的地方拿到這個值,可以再計算。函式沒有寫 return,就自動回傳 None(M3)。看到 'NoneType' 出現在錯誤訊息裏,先檢查是不是有函式忘了 return。
一個函式只做一件事;需要的資料全部從參數進來;結果用 return 交出去。這樣它不依賴外面的變數,搬到別的 notebook 也能用。
函式裏面設定的名字只在函式裏有效:函式執行完,這些名字就消失了。上面的 amount 在函式外用不到,會報 NameError。這是好事:函式內外的名字互不干擾。
5模組與 import Modules and import
函式寫好了,怎樣在另一個 notebook 用它?把它放進一個 .py 檔,這個檔就是一個模組(module)。例如在 python-course 資料夾建立 bookshop.py:
# bookshop.py
def format_price(x):
return f"{x:,.0f} 元"
def order_total(order, titles, discount=1.0):
...
在同一個資料夾的 notebook 裏:
import bookshop
bookshop.format_price(1220) # '1,220 元'
from bookshop import format_price # 只取一個名字,之後不用寫 bookshop.
format_price(96.0) # '96 元'
檔名 bookshop.py,import 時寫 bookshop,不加 .py。
到目前為止,你已經用過三種模組:
| 種類 | 例子 | 從哪裏來 |
|---|---|---|
| 標準函式庫 | json、pathlib(工程線還有 math) |
隨 Python 附送,不用安裝 |
| 第三方套件 | yaml(工程線還有 numpy) |
用 uv add 安裝進 .venv(M1) |
| 自己的模組 | bookshop |
自己寫的 .py 檔 |
import 按次序在幾個地方找,第一個找到的就用:
flowchart LR
I["import bookshop"] --> Z{"內建模組<br/>(例如 math)有 bookshop?"}
Z -->|有| U["用它"]
Z -->|沒有| A{"目前的資料夾<br/>有 bookshop.py?"}
A -->|有| U
A -->|沒有| B{"標準函式庫<br/>有 bookshop?"}
B -->|有| U
B -->|沒有| C{".venv 裏<br/>裝了 bookshop?"}
C -->|有| U
C -->|沒有| E["ModuleNotFoundError"]
classDef q fill:#ffffff,stroke:#d3d1cb,color:#37352f
classDef ok fill:#f1f1ef,stroke:#d3d1cb,color:#37352f
classDef err fill:#fdebec,stroke:#c4423d,color:#5d1715
class Z,A,B,C q
class I,U ok
class E err
目前的資料夾排在 .venv 前面。如果你把練習檔命名為 yaml.py,import yaml 就會找到你的檔,而不是安裝好的 PyYAML。結果是莫名其妙的錯誤,例如 AttributeError: module 'yaml' has no attribute 'safe_load'。檔名避開常見模組的名字。(Python 3.13 起,錯誤訊息會提示你改名。)
json.py 也一樣危險,只是在 notebook 裏未必看得到:kernel 啟動時已經 import 了 json,之後的 import json 直接用記憶體裏那個(見下一個提示)。用 uv run python 執行和 json.py 放在同一個資料夾的腳本時,它就會出事:AttributeError: module 'json' has no attribute 'loads'。至於 math,它是內建模組,排在最前面,同名的檔蓋不過它。
同一個 kernel 裏,每個模組只會 import 一次。你在 VS Code 修改了 bookshop.py,回到 notebook 再執行 import bookshop,用的仍然是舊版本:它早已在 kernel 的記憶體裏(M2)。改完模組,選 Kernel → Restart Kernel and Run All Cells。
6為函式寫說明 Docstrings
函式第一行可以放一段字串,說明它做甚麼,叫做 docstring:
def order_total(order, titles, discount=1.0):
"""一張訂單的金額:目錄裏找不到的書不計算。"""
...
在 notebook 裏輸入 help(order_total),或者在函式名後按 Shift+Tab,就會顯示這段說明。三個月後的你,會感激現在寫了它的你。
7容易混淆 Easily confused
| 這兩樣 | 分別 |
|---|---|
print 和 return |
前者顯示給人看,函式仍回傳 None;後者把值交回給程式。 |
| 定義和呼叫 | def 只是記下程式;寫 order_total(...) 加括號才執行。漏了括號,得到的是函式本身。 |
| 參數和引數 | 參數是定義時括號裏的名字;引數是呼叫時放進去的實際值。 |
import bookshop 和 from bookshop import format_price |
前者之後寫 bookshop.format_price;後者直接寫 format_price。 |
| 檔名和模組名 | 檔案 bookshop.py,import 時寫 bookshop。 |
| 函式裏和函式外的名字 | 函式裏設定的名字,函式執行完就消失,外面用不到。 |
8動手:書店工具箱 Hands-on: the bookshop toolbox
- 下載
m6-bookshop-tools.ipynb和bookshop.py,連同 M4 的catalog.yaml和 M5 的orders.yaml,放進python-course資料夾; - 執行
uv run jupyter lab,打開 notebook,逐格執行。
bookshop.py 已經有讀目錄和顯示價錢的函式。你要在 notebook 裏寫 order_total(),用它改寫 M5 的日報,撞一次 print 和 return 的錯,最後把函式搬進 bookshop.py,親身經歷「改了模組要重新啟動 kernel」。
m6-sensor-tools-eng.ipynb 和 sensortools.py,需要 M4 的 sensors.json:寫 celsius_to_kelvin() 和 moving_average(),用 assert 檢查它們的結果,再放進自己的模組。
M7 講檔案與路徑:pathlib、相對與絕對路徑、文字編碼,以及為甚麼 notebook 有時找不到明明就在旁邊的檔案。