模組與套件:把程式拆成好幾個檔案
程式一長,全部擠在一個檔案就很難找、很難改。這堂課把記帳小工具拆成好幾個檔案,學會用 import 把它們接起來,也順便弄懂「套件」到底是什麼。
從「能跑」到「好維護」
歡迎來到這個單元。你已經寫出了能用的記帳小工具,也用 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 是指揮,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 程式幾乎都會有這兩行,現在你知道它在做什麼了。
執行看看
在終端機切換到 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之後,外層那份記得刪掉,免得改錯檔案。
我有一個 Python 記帳程式(貼在下面),大約 60 行,全部寫在同一個檔案。我想把它拆成這樣的結構:main.py 放選單和輸入,tracker 套件裡的 storage.py 負責讀寫 CSV、report.py 負責報表。
請先只告訴我:每個函式應該搬到哪個檔案、每個檔案最上面要 import 什麼。不要直接改寫程式碼,也不要改變任何功能,我想自己動手搬。
(貼上 expense.py)
先請 AI 給「搬家清單」,自己動手搬,是理解專案結構最快的方法。下次換成 Claude Code 直接幫你搬時,你也看得懂它做了什麼。
- 照著這堂課,把
expense.py拆成main.py、tracker/storage.py、tracker/report.py,別忘了空白的tracker/__init__.py。 - 執行
python main.py,確認記一筆、看報表都和以前一樣,舊紀錄也都讀得到。 - 在
expense_tracker資料夾打開 Python 互動模式(在終端機輸入python),一行一行輸入下面兩行:
from tracker.report import summarize
summarize([{"category": "餐費", "amount": 85}, {"category": "餐費", "amount": 60}])你應該會看到 {'餐費': 145}。想一想:為什麼不用啟動選單、也不用真的記帳,就能單獨試這個函式?
4. 挑戰:在 tracker 裡新增一個模組 recent.py,放一個「回傳最近 5 筆紀錄」的函式,再從 main.py 的選單呼叫它。
讀完了嗎?
標記完成後,單元與專案的進度會自動更新(只存在這台裝置的瀏覽器)。
留言與討論
卡關了、有心得,或發現內容需要更新?都歡迎留言。留言使用 GitHub 帳號登入。