Day 14 物件導向進階與小專案
引言
昨天學會了類別、屬性、方法與特殊方法,已經能寫出具備基本結構的物件。今天要把物件導向的三大支柱補完:繼承讓我們在既有類別上延伸新功能、封裝把內部細節藏起來保護資料、多型則讓不同物件對同一訊息有不同反應。學完這些,就能寫出真正可重複使用、容易維護的類別設計。這三個觀念不只在 Python 中重要,幾乎所有主流的物件導向語言(Java、C++、C#、Kotlin)都把它們列為核心。
為了讓觀念落地,我們會在最後用這些機制實作一個簡單的記帳系統:包含兩種類型的交易(收入與支出)、能計算總額、也能列出明細。這個小專案同時會用到檔案讀寫,把前面學的內容串起來。專案規模雖然小,卻已經用到了類別階層、方法覆寫、多型與檔案 I/O,是個很棒的綜合練習。完成後你會發現,這個記帳系統其實已經具備了一個真實 App 的核心架構:資料模型(類別)+ 商業邏輯(方法)+ 儲存層(JSON)。
繼承:讓子類別複用父類別
繼承(inheritance)讓新類別複用既有類別的屬性與方法。被繼承的稱為父類別(base class),衍生的新類別稱為子類別(derived class)。子類別除了擁有父類別的功能,還能新增自己的功能,或覆寫(override)父類別的方法。這樣做的好處是「共同的部分寫一次就好」,差異的部分交給各子類別自己實作。
class Animal:
def __init__(self, name):
self.name = name
def speak(self):
raise NotImplementedError("子類別必須實作 speak()")
class Dog(Animal):
def speak(self):
return f"{self.name} 汪汪叫"
class Cat(Animal):
def speak(self):
return f"{self.name} 喵喵叫"
animals = [Dog("小黑"), Cat("小花"), Dog("旺財")]
for a in animals:
print(a.speak())
# 輸出:
# 小黑 汪汪叫
# 小花 喵喵叫
# 旺財 汪汪叫
如果想在子類別呼叫父類別的方法,可以用 super() 取得父類別的實作,這在建構子中特別常用,可以避免重複撰寫屬性初始化的程式碼。
class Employee:
def __init__(self, name, base_salary):
self.name = name
self.base_salary = base_salary
def monthly_pay(self):
return self.base_salary
class Manager(Employee):
def __init__(self, name, base_salary, bonus):
super().__init__(name, base_salary)
self.bonus = bonus
def monthly_pay(self):
return self.base_salary + self.bonus
m = Manager("小華", 50000, 15000)
print(m.monthly_pay()) # 輸出:65000
Python 支援多重繼承(同時繼承多個父類別),但實務上很容易造成維護困難,建議盡量保持單一繼承,並用組合(在一個類別裡放另一個類別的物件)來解決複用問題。NotImplementedError 是告訴子類別「你必須自己實作這個方法」,這是 Python 慣用的抽象方法約定,等於是介面的溫和版。從 Python 3.4 起,也可以用標準庫的 abc 模組更正式地定義「抽象基底類別」(ABCs),強制子類別實作特定方法,違規時直接拋出錯誤,這在大型框架中很常見。
封裝:用底線保護內部狀態
封裝(encapsulation)的精神是「把實作細節藏起來,只暴露必要的介面」。在 Python 中並沒有真正的 private,但有幾個命名慣例:單底線 _var 表示「內部使用、不建議外部存取」;雙底線 __var 會觸發名稱改寫(name mangling),讓外部更不容易存取到。封裝不是要禁止外部碰,而是要清楚表達「這是內部細節,未來可能改變」。
class BankAccount:
def __init__(self, owner, balance=0):
self.owner = owner
self.__balance = balance # 雙底線:不易從外部直接改
def deposit(self, amount):
if amount <= 0:
raise ValueError("存款金額必須大於 0")
self.__balance += amount
def withdraw(self, amount):
if amount > self.__balance:
raise ValueError("餘額不足")
self.__balance -= amount
def get_balance(self):
return self.__balance
acc = BankAccount("小明", 1000)
acc.deposit(500)
print(acc.get_balance()) # 輸出:1500
# print(acc.__balance) # AttributeError
print(acc._BankAccount__balance) # 不建議這樣用
實務上,雙底線很少用,多數情況下單底線就足以表達「這是內部變數」的訊號。更重要的是搭配「property」裝飾器,把讀取與寫入屬性的方式寫得像存取屬性一樣自然,但內部可以加上檢查邏輯。設計良好的封裝能讓之後修改內部實作時不必更動外部呼叫,是大型專案能穩定演進的基礎。
class Temperature:
def __init__(self, celsius):
self._celsius = celsius
@property
def celsius(self):
return self._celsius
@celsius.setter
def celsius(self, value):
if value < -273.15:
raise ValueError("溫度低於絕對零度")
self._celsius = value
t = Temperature(25)
print(t.celsius) # 輸出:25
t.celsius = 30 # 透過 setter 檢查
print(t.celsius) # 輸出:30
property 讓我們用「看起來像屬性」的語法呼叫,實際上背後還是呼叫函式,因此能加上檢查、轉換、紀錄等邏輯。這種寫法在很多 Python 套件(例如 Django 的 models、PyTorch 的 nn.Module 參數)都很常見,是寫 API 友善類別的關鍵技巧。property 也常和唯讀的「唯讀屬性」搭配使用:只寫 getter 不寫 setter,就能表達「這個值建立後不能改」。
多型:同一個介面、不同行為
多型(polymorphism)讓不同類別的物件,對同一個方法呼叫產生不同行為。第一個範例中的 Dog.speak() 與 Cat.speak() 就是多型:我們呼叫同一個 .speak(),卻得到不同的結果。這也是為什麼我們能用 for 迴圈走訪多種動物,卻不需要在意具體型別。
class Shape:
def area(self):
raise NotImplementedError
class Rectangle(Shape):
def __init__(self, w, h):
self.w = w
self.h = h
def area(self):
return self.w * self.h
class Circle(Shape):
def __init__(self, r):
self.r = r
def area(self):
return 3.14159 * self.r ** 2
shapes = [Rectangle(3, 4), Circle(2), Rectangle(5, 5)]
total = sum(s.area() for s in shapes)
print(total) # 輸出:12 + 12.56636 + 25 = 49.56636
多型讓我們寫「針對抽象介面」的程式,而不是針對具體類別。這正是 PyTorch 設計 nn.Module 的核心觀念:每個模型層都有 forward(),但具體實作各自不同。我們之後寫自訂模型層時,會大量用到這個觀念。多型也是日後理解「為什麼 Python 函式庫這麼靈活」的關鍵,因為呼叫端只需要知道介面,不用知道實作。Python 的內建機制例如 +、len()、for 迴圈也都靠多型:只要物件定義了對應的特殊方法,就能直接套用內建語法,這也是鴨子型別的具體實踐。
小專案:記帳程式
把今天學的內容用一個小專案串起來。我們設計一個記帳系統:所有交易共用一個父類別 Transaction,再分成 Income 與 Expense 兩個子類別。Wallet 負責管理交易清單與計算餘額,並能把資料儲存成 JSON 檔案。為了避免每筆交易都用 dict 處理,採用類別設計能讓加總、儲存的邏輯更清楚。
import json
from datetime import date
class Transaction:
def __init__(self, amount, note=""):
self.date = date.today().isoformat()
self.amount = amount
self.note = note
def sign(self):
raise NotImplementedError
class Income(Transaction):
def sign(self):
return +abs(self.amount)
class Expense(Transaction):
def sign(self):
return -abs(self.amount)
class Wallet:
def __init__(self, owner):
self.owner = owner
self.items = []
def add(self, tx):
self.items.append(tx)
def balance(self):
return sum(tx.sign() for tx in self.items)
def dump(self, path):
with open(path, "w", encoding="utf-8") as f:
data = [vars(tx) | {"type": type(tx).__name__} for tx in self.items]
json.dump(data, f, ensure_ascii=False, indent=2)
w = Wallet("小明的錢包")
w.add(Income(50000, "十月薪水"))
w.add(Expense(1200, "午餐"))
w.add(Expense(800, "交通"))
print(w.balance()) # 輸出:48000
w.dump("wallet.json")
執行後,wallet.json 會儲存三筆交易資料,餘額為 48000。Income 與 Expense 透過覆寫 sign() 達到多型效果:對 Wallet 來說,它只關心每筆交易「加多少或減多少」,具體是收入還是支出則由子類別決定。這就是一個小而完整的物件導向設計。可以再延伸成「每筆交易儲存類別」、「新增篩選本月支出的方法」、「把記錄匯入到 Wallet」,都是不錯的練習。如果想再升級,可以把這個類別放進 my_wallet.py 模組,再用 __main__ 區塊做互動式選單,整個專案就會接近一個可以日常使用的小工具了。
結語
今天把物件導向的繼承、封裝、多型都跑了一遍,並用記帳小專案把觀念落地。這個專案雖然只有 60 行程式碼,卻用到了類別階層、方法覆寫、多型與檔案 I/O,是個很紮實的綜合練習。從明天起,我們會進入資料科學的世界,先用 NumPy 處理陣列運算,再進入 Pandas 做資料清理與分析。這兩套工具的設計哲學都深受物件導向影響,看完今天的內容再去學 NumPy 與 Pandas,會更能體會它們的 API 為什麼長成那樣。如果你對物件導向還不熟悉,建議在進入資料章節前,先自己改造一次這個記帳程式,會比純讀程式碼更有感。
明天,我們會正式踏入 NumPy,認識 ndarray、shape、dtype 與基本運算,這是後續 Pandas 與 PyTorch 張量的共同基礎。
留言
張貼留言