所有入門單元免費開放 · 課程內容持續更新中

模組與套件:把程式拆成好幾個檔案

程式一長,全部擠在一個檔案就很難找、很難改。這堂課把記帳小工具拆成好幾個檔案,學會用 import 把它們接起來,也順便弄懂「套件」到底是什麼。

第 3.1 堂約 12 分鐘

從「能跑」到「好維護」

歡迎來到這個單元。你已經寫出了能用的記帳小工具,也用 pandas 整理過表格。程式會跑了,接下來要學的是另一件事:讓程式好維護。

「好維護」的意思是:三個月後的你、你的同事,或是你請來幫忙的 AI Agent,打開這個專案時,能很快看懂、敢放心修改,改壞了也馬上知道。

這個單元的六堂課,我們會一路帶著同一個專案:入門單元做的 expense.py 記帳小工具。每一堂幫它加上一項「工程基本功」,最後它會變成一個結構清楚、有測試、有版本紀錄的小專案。

第一步,先把它拆開。

為什麼要拆成好幾個檔案?

還記得 expense.py 大約 60 行嗎?60 行還好。但只要再加幾個功能,「只看本月份」「匯出 Excel」「預算提醒」,很快就會變成 300 行。到時候你想改報表,得在一大片程式裡上下捲動,找半天才找到。

把程式拆成幾個檔案,就像把一整箱雜物分裝到貼了標籤的抽屜:

  • 跟存檔讀檔有關的,放一個檔案
  • 跟報表計算有關的,放一個檔案
  • 跟選單、問使用者有關的,放主程式

這對 AI 也很重要。當你跟 Claude Code 說「報表的加總好像算錯了」,如果有一個叫 report.py 的檔案,它一眼就知道該看哪裡,不用把整個專案讀一遍,也比較不會順手改到不相干的地方。

模組:一個檔案就是一個模組

在 Python 裡,模組(module)就是一個 .py 檔。你其實早就用過了:import csv 就是借用 Python 內建的 csv 模組。

現在,我們自己做一個。在練習資料夾裡建一個新資料夾 expense_tracker,把原本的 expense.py 和 expenses.csv 複製進去,再新增一個 storage.py,把讀檔、存檔的部分搬過去:

# storage.py:負責讀寫 expenses.csv
import csv
from pathlib import Path

FILE = Path("expenses.csv")
FIELDS = ["date", "category", "item", "amount"]


def load_records():
    if not FILE.exists():
        return []
    with FILE.open(encoding="utf-8-sig", newline="") as f:
        records = list(csv.DictReader(f))
    for r in records:
        r["amount"] = int(r["amount"])
    return records


def save_records(records):
    with FILE.open("w", encoding="utf-8-sig", newline="") as f:
        writer = csv.DictWriter(f, fieldnames=FIELDS)
        writer.writeheader()
        writer.writerows(records)

內容一個字都沒改,只是換了地方住。接著在 expense.py 最上面,把它借回來,並刪掉原本那兩個函式:

from storage import load_records, save_records

這一行的意思是:「去 storage.py 裡,把 load_records 和 save_records 這兩個函式拿過來用。」拿過來之後,用法跟寫在同一個檔案裡完全一樣。

兩種 import 寫法

# 寫法一:整個模組借過來,用的時候加上模組名稱
import storage
records = storage.load_records()

# 寫法二:只借需要的函式,用的時候直接叫名字
from storage import load_records
records = load_records()

兩種都對。寫法一的好處是一看就知道函式從哪來;寫法二比較短。同一個專案選一種習慣就好,AI 寫的程式兩種都會出現,你看得懂就行。

套件:把模組收進同一個資料夾

模組變多之後,可以再收一層。套件(package)就是一個裝著好幾個模組的資料夾,裡面放一個叫 __init__.py 的檔案,告訴 Python:「這個資料夾是一個套件。」這個檔案可以完全空白,檔名的 init 前後各有兩條底線。

我們把記帳工具的「零件」收進一個叫 tracker 的套件,主程式改名叫 main.py 放在外面:

expense_tracker/
├── main.py            ← 主程式:選單、問使用者
├── expenses.csv       ← 資料
└── tracker/           ← 套件:記帳工具的零件
    ├── __init__.py    ← 空白檔案,代表這是套件
    ├── storage.py     ← 讀檔、存檔
    └── report.py      ← 報表計算與顯示

專案結構圖:main.py 在最外層,透過 import 使用 tracker 套件裡的 storage.py 和 report.py,storage.py 負責讀寫 expenses.csv

main.py 是指揮,tracker 裡的模組是各自負責一件事的零件。

從套件裡 import,就是在模組名稱前面加上套件名稱和一個點:

from tracker.storage import load_records, save_records
from tracker.report import show_report

讀法是「從 tracker 套件的 storage 模組,拿出 load_records」。

順便做一件好事:計算和顯示分開

把 show_report 搬到 report.py 的時候,我們順手把它拆成兩個函式:

# tracker/report.py:報表


def summarize(records):
    """依類別加總,回傳像 {"餐費": 145, "交通": 30} 的字典。"""
    by_category = {}
    for r in records:
        cat = r["category"]
        by_category[cat] = by_category.get(cat, 0) + r["amount"]
    return by_category


def show_report(records):
    if not records:
        print("還沒有任何紀錄")
        return
    totals = summarize(records)
    for cat, subtotal in totals.items():
        print(f"{cat}:{subtotal} 元")
    print(f"總計:{sum(totals.values())} 元")

summarize 只負責算,用 return 把結果交回來;show_report 只負責印。還記得入門單元說的「print 是給人看的,return 是給程式繼續用的」嗎?分開之後,summarize 就能被別的地方重複使用,例如之後要做「匯出 Excel」也用得到。第 5 堂寫自動化測試時,你會發現這個小動作省了多少麻煩。

函式第一行那段用三個引號包起來的文字叫 docstring,是寫給人看的說明。編輯器把滑鼠移到函式名稱上時會顯示它,AI 讀程式時也會參考。

主程式和 __name__ 的小秘密

最後,main.py 長這樣:

# main.py:選單與輸入
from datetime import date

from tracker.report import show_report
from tracker.storage import load_records, save_records


def ask_amount(question):
    while True:
        text = input(question)
        if text.isdigit():
            return int(text)
        print("請輸入阿拉伯數字,例如 85")


def add_record(records):
    category = input("類別(例如 餐費、交通):")
    item = input("項目:")
    amount = ask_amount("金額:")
    records.append({"date": date.today().isoformat(), "category": category,
                    "item": item, "amount": amount})
    save_records(records)
    print("已記下!")


def main():
    records = load_records()
    print(f"已讀入 {len(records)} 筆紀錄")
    while True:
        choice = input("\n1 記一筆 2 看報表 q 離開:")
        if choice == "1":
            add_record(records)
        elif choice == "2":
            show_report(records)
        elif choice == "q":
            print("掰掰,明天也要記帳喔!")
            break
        else:
            print("請輸入 1、2 或 q")


if __name__ == "__main__":
    main()

和以前比,最後一行從 main() 變成了 if __name__ == "__main__": 底下的 main()。這是什麼?

每個模組都有一個內建的變數 __name__。當你直接執行一個檔案(python main.py),它的 __name__ 是 "__main__";當它是被別人 import 的時候,__name__ 是它自己的模組名稱。

所以這兩行的意思是:「只有在我被直接執行的時候,才啟動選單。」這樣一來,之後別的程式要 import main.py 裡的函式時,選單就不會突然跳出來等你輸入。AI 寫的 Python 程式幾乎都會有這兩行,現在你知道它在做什麼了。

比較圖:直接執行 main.py 時,name 變數的值是 main,條件成立所以啟動選單;被 import 時,name 變數是模組自己的名稱,條件不成立,只載入函式不執行

執行看看

在終端機切換到 expense_tracker 資料夾,執行:

python main.py

(Mac 請把 python 換成 python3。)

功能應該和以前一模一樣,而且原本的紀錄都還在。程式外表沒變,但內部整齊多了,這就是重構(refactor):不改功能,只改結構。

執行後你可能會看到多出一個 __pycache__ 資料夾,那是 Python 為了下次載入快一點自動產生的暫存檔,可以不用理它,刪掉也沒關係。

你可能會卡在這裡

  • ModuleNotFoundError: No module named 'tracker':通常是終端機不在 expense_tracker 資料夾裡。先用 cd 切換過去再執行。
  • ImportError: cannot import name 'show_report':函式名稱拼錯,或是那個函式還沒搬進 report.py。
  • 搬完之後忘了刪:如果 main.py 裡還留著舊的 load_records,就會有兩份。搬家要搬乾淨,主程式裡只留 import。
  • 舊的 storage.py 還在外面:搬進 tracker 之後,外層那份記得刪掉,免得改錯檔案。
可以請 Claude 幫你規劃

我有一個 Python 記帳程式(貼在下面),大約 60 行,全部寫在同一個檔案。我想把它拆成這樣的結構:main.py 放選單和輸入,tracker 套件裡的 storage.py 負責讀寫 CSV、report.py 負責報表。

請先只告訴我:每個函式應該搬到哪個檔案、每個檔案最上面要 import 什麼。不要直接改寫程式碼,也不要改變任何功能,我想自己動手搬。

(貼上 expense.py)

先請 AI 給「搬家清單」,自己動手搬,是理解專案結構最快的方法。下次換成 Claude Code 直接幫你搬時,你也看得懂它做了什麼。

行動練習
  1. 照著這堂課,把 expense.py 拆成 main.py、tracker/storage.py、tracker/report.py,別忘了空白的 tracker/__init__.py。
  2. 執行 python main.py,確認記一筆、看報表都和以前一樣,舊紀錄也都讀得到。
  3. 在 expense_tracker 資料夾打開 Python 互動模式(在終端機輸入 python),一行一行輸入下面兩行:
from tracker.report import summarize
summarize([{"category": "餐費", "amount": 85}, {"category": "餐費", "amount": 60}])

你應該會看到 {'餐費': 145}。想一想:為什麼不用啟動選單、也不用真的記帳,就能單獨試這個函式? 4. 挑戰:在 tracker 裡新增一個模組 recent.py,放一個「回傳最近 5 筆紀錄」的函式,再從 main.py 的選單呼叫它。

讀完了嗎?

標記完成後,單元與專案的進度會自動更新(只存在這台裝置的瀏覽器)。

卡住了?讓 AI 當你的家教複製這段提問範本,貼到 Claude 或其他 AI 工具,再補上你卡住的地方。

留言與討論

卡關了、有心得,或發現內容需要更新?都歡迎留言。留言使用 GitHub 帳號登入。

留言區即將開放。現在可以先到 GitHub 討論區 發問,或看看 交流頁 的其他管道。