Python 資料全景課 / 第二部分 Python 基礎 · M6

M6 函式與模組

M5 的日報裏,「計算一張訂單的金額」那一段,日報要用,打折要用,統計也要用。把它包成一個函式,寫一次、改一次,到處都能用;再把函式放進自己的模組,別的 notebook 也能 import。
約 50 分鐘練習 notebook 1 個+自己的模組 1 個先修:M5
學完本單元,你能夠:
  1. 用 def 定義函式,分清參數、引數和回傳值;
  2. 分清 print 和 return,看懂函式回傳 None 時的錯誤;
  3. 用標準函式庫、第三方套件和自己寫的模組,說出 import 從哪裏找東西;
  4. 在 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

titles 就是 M5 日報裏那個「書名 → 書」的 dict。M5 練習裏它叫 by_title;這一單元 by_title 成了 bookshop 模組裏建立它的函式的名字,所以 dict 改叫 titles。

定義只是把這段程式記下來,不會執行。要執行,就呼叫它:寫名字,加括號,把資料放進去。放進去的實際資料叫做引數(argument)。

order_total(orders[0], titles)     # 335
order_total(orders[3], titles)     # 435
圖 1 函式像一部機器:參數是入口,return 是出口。呼叫時放進引數,機器裏的程式執行完,把回傳值交出來。
圖 1 函式像一部機器:參數是入口,return 是出口。呼叫時放進引數,機器裏的程式執行完,把回傳值交出來。

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

這是初學者寫函式最常犯的錯:

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'
圖 2 print 和 return 的分別。print 把值顯示給人看,值沒有交回給程式;沒有 return 的函式,回傳的是 None。
圖 2 print 和 return 的分別。print 把值顯示給人看,值沒有交回給程式;沒有 return 的函式,回傳的是 None。

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
圖 3 import bookshop 時 Python 找的次序。找到就停;四處都找不到,就報 ModuleNotFoundError。
不要把自己的檔案叫做 yaml.py

目前的資料夾排在 .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,它是內建模組,排在最前面,同名的檔蓋不過它。

改了模組,notebook 沒有變

同一個 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

  1. 下載 m6-bookshop-tools.ipynb 和 bookshop.py,連同 M4 的 catalog.yaml 和 M5 的 orders.yaml,放進 python-course 資料夾;
  2. 執行 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 有時找不到明明就在旁邊的檔案。