物件導向概念
從一杯飲料的點餐系統出發,理解封裝、繼承、多型與抽象如何協同管理軟體的複雜度,並深入動態分派與 SOLID 原則
為什麼一杯飲料的訂單,會讓程式碼變得一團亂?
想像你在寫一個手搖飲店的點餐系統。一開始只有三種飲料,你用幾個變數記下名稱、容量、加料,算錢時寫一段 if 判斷就解決了。三個月後,店長要你加上「會員折扣」「第二杯半價」「外送加價」「珍珠加價但奶茶免費續珍」……你的 if 越疊越深,每改一處都怕弄壞別處。某天你終於發現:問題不在於規則太多,而在於你把「資料」(飲料有什麼)和「行為」(怎麼算錢)散落在四處,沒有人負責把它們綁在一起。
物件導向程式設計(object-oriented programming, OOP)正是為了回應這種困境而生。它的核心主張很單純:與其讓資料任人擺布,不如讓每個「東西」自己保管資料、自己負責回答關於自己的問題。一杯飲料應該知道自己賣多少錢,而不是讓散落各處的程式碼去猜。
從程序式到物件導向:思維的轉向

在程序式(procedural)的思維裡,程式是「一連串對資料的操作」。資料是被動的記錄,函式(function)是主動的操作者。你有一堆 dict 或 struct 存著飲料資料,再寫一個 calculate_price(drink) 去處理它。資料與操作分屬兩個世界,誰都可以伸手去動那份資料。
物件導向把這個關係倒了過來:資料與操作它的程式碼,應該住在同一個地方。我們把「一種東西」抽象成類別(class),把「具體的一個」實例化(instantiate)成物件(object)。類別是設計圖,物件是照圖蓋出來的房子。
class Drink:
def __init__(self, name, base_price, size):
self.name = name
self.base_price = base_price
self.size = size
def price(self):
size_extra = {"S": 0, "M": 5, "L": 10}
return self.base_price + size_extra[self.size]
milk_tea = Drink("奶茶", 50, "L")
print(milk_tea.price()) # 60
這裡 Drink 是類別,milk_tea 是物件。name、base_price、size 是物件的屬性(attribute / 狀態),price() 是方法(method / 行為)。算價錢的邏輯不再散落在外面,而是飲料「自己會算」。當規則改變時,你只需要改 Drink 這一處。
封裝:把該藏的藏起來
封裝(encapsulation)是 OOP 的第一塊基石。它的意思是:一個物件對外只暴露「該被使用的介面」,把內部實作細節藏起來。外界不必、也不應該知道飲料價錢是怎麼算出來的——它只要呼叫 price() 拿到結果就好。
為什麼這很重要?因為它劃出了一條清楚的界線:界線之內,你可以自由重構;界線之外,使用者的程式碼不會壞。如果哪天你想把 size_extra 從寫死的字典改成查資料庫,只要 price() 的回傳意義不變,外界完全無感。
class BankAccount:
def __init__(self, balance):
self._balance = balance # 慣例:底線前綴表示「內部用」
def deposit(self, amount):
if amount <= 0:
raise ValueError("存款金額必須為正")
self._balance += amount
def balance(self):
return self._balance
注意 deposit 裡的檢查:因為餘額只能透過 deposit 這道「閘門」修改,我們才有機會強制「金額必須為正」這條規則。如果允許外界直接 account._balance = -9999,這條規則就形同虛設。封裝保護的不只是資料,更是資料的不變量(invariant)——那些「永遠該成立的條件」。
需要說明的是,Python 的封裝是「君子協定」(底線只是慣例,技術上仍可存取),不像 Java 有 private 強制限制。但精神是一致的:把內部與外部分開,讓變動被局限在可控範圍。
繼承與組合:重用程式碼的兩條路
當你有「氣泡飲」這種特殊飲料,它幾乎和一般飲料一樣,只是多了「氣泡強度」這個屬性,你會怎麼做?
繼承(inheritance)讓你說:「氣泡飲『是一種』飲料」(is-a),於是它自動擁有飲料的一切,再加上自己的特色。
class SparklingDrink(Drink):
def __init__(self, name, base_price, size, fizz_level):
super().__init__(name, base_price, size)
self.fizz_level = fizz_level
def price(self):
return super().price() + self.fizz_level * 3 # 氣泡越強越貴
SparklingDrink 繼承自 Drink(稱父類別/基底類別),不必重寫 name、size 的處理,只覆寫(override)了 price()。這看似省事,但繼承有個著名的陷阱:它是一種非常「強」的耦合。子類別深深依賴父類別的內部行為,父類別一改,所有子類別可能跟著遭殃。而且現實世界的「是一種」關係常常不像表面那麼單純——經典反例是「正方形是一種長方形」,在數學上成立,但若長方形有「分別設定長與寬」的方法,正方形繼承它就會違反幾何約束。
於是有了一條廣為接受的設計準則:組合優於繼承(favor composition over inheritance)。組合(composition)的關係是「有一個」(has-a):與其讓飲料「是一種」可折扣的東西,不如讓飲料「擁有一個」折扣策略。
class PercentDiscount:
def __init__(self, rate):
self.rate = rate
def apply(self, price):
return price * (1 - self.rate)
class Drink:
def __init__(self, name, base_price, discount=None):
self.name = name
self.base_price = base_price
self.discount = discount # 「擁有一個」折扣策略
def price(self):
p = self.base_price
if self.discount:
p = self.discount.apply(p)
return p
現在「會員九折」「第二杯半價」都只是不同的折扣物件,可以在執行期自由替換、自由組合,而不必為每種組合生出一個新的子類別。組合用「擁有並委派」取代「繼承並覆寫」,換來更鬆的耦合與更高的彈性。這正是設計模式(design pattern)中策略模式(Strategy)的雛形。
多型與抽象:對介面寫程式
多型(polymorphism)是 OOP 最有威力的概念,字面意思是「多種形態」。它讓你用同一種方式對待不同型別的物件,只要它們都回應相同的訊息。
def print_receipt(drinks):
total = 0
for d in drinks:
print(f"{d.name}: {d.price()} 元")
total += d.price()
print(f"總計: {total} 元")
order = [Drink("綠茶", 30), SparklingDrink("氣泡檸檬", 45, "M", 2)]
print_receipt(order)
print_receipt 完全不在乎清單裡是普通飲料還是氣泡飲,它只知道「每個東西都會回應 .price()」。普通飲料和氣泡飲各自用不同方式算錢,但對呼叫者而言形態統一。將來新增「季節限定飲」「套餐組合」,只要它也有 name 和 price(),這段程式碼一字不改就能運作。
這背後是抽象(abstraction)的力量:我們對「介面(interface)」寫程式,而非對「具體實作」寫程式。介面是一份約定——「凡是飲料,都要會算價錢」——至於各自怎麼算,是每個型別自己的事。許多語言用抽象類別(abstract class)或介面型別明確表達這份約定:
from abc import ABC, abstractmethod
class Beverage(ABC):
@abstractmethod
def price(self):
... # 只規定「必須有」,不規定「怎麼做」
Beverage 無法被實例化,它只是一份契約。任何宣稱自己是飲料的型別,都必須兌現 price()。抽象讓我們把「做什麼(what)」和「怎麼做(how)」分離,這是管理大型系統複雜度的根本手段。
動手看一個例子
讓我們把四大支柱串起來,看一個小型計費系統如何演化。需求是:對一份訂單算總價,且要能支援不同付款方式(現金、行動支付加 1% 手續費、會員點數全免)。
先看「沒有多型」會寫成什麼樣子:
def checkout(total, method):
if method == "cash":
return total
elif method == "mobile":
return total * 1.01
elif method == "points":
return 0
# 每加一種付款方式,就要回來改這個函式
這段程式碼有個味道(code smell):每新增一種付款方式,就得回頭修改 checkout,違反了「對擴充開放、對修改封閉」的精神。改用多型重寫:
class PaymentMethod(ABC):
@abstractmethod
def charge(self, total): ...
class Cash(PaymentMethod):
def charge(self, total): return total
class MobilePay(PaymentMethod):
def charge(self, total): return round(total * 1.01, 2)
class Points(PaymentMethod):
def charge(self, total): return 0
def checkout(total, method: PaymentMethod):
return method.charge(total)
print(checkout(100, Cash())) # 100
print(checkout(100, MobilePay())) # 101.0
print(checkout(100, Points())) # 0
現在 checkout 永遠不必再改。要支援「貨到付款加 30 元」?新增一個 CashOnDelivery 類別即可,既有程式碼毫髮無傷。我們把「會變的部分」(各種付款方式)隔離成獨立的物件,把「不變的部分」(結帳流程)固定下來——這就是抽象、封裝、多型協同運作的成果。
下表整理四大支柱各自回答的問題:
| 支柱 | 一句話 | 它回答的問題 |
|---|---|---|
| 封裝 | 把資料與行為綁在一起並藏起細節 | 「誰能動這份資料?」 |
| 繼承 | 從既有類別衍生出特化版本 | 「這是不是一種那個?」 |
| 多型 | 同一介面對應多種實作 | 「我能不能不在乎你的具體型別?」 |
| 抽象 | 只暴露本質,隱藏複雜 | 「使用者真正需要知道什麼?」 |
重點回顧
- 物件導向把「資料」與「操作資料的行為」綁在同一個物件裡,扭轉了程序式「被動資料、主動函式」的關係,讓每個物件為自己負責。
- 封裝劃出內外界線、守護不變量;繼承表達「是一種」(is-a) 的特化關係,但耦合強、易脆弱。
- 在多數情況下,組合(「有一個」(has-a),委派給內含物件)比繼承更有彈性,因此有「組合優於繼承」的準則。
- 多型讓你對介面而非實作寫程式:同一段呼叫端程式,能無痛應對未來新增的型別,這是 OOP 擴充性的核心來源。
- 抽象把「做什麼」與「怎麼做」分離,是控制大型系統複雜度的根本武器。
深入探討(研究所視角)
四大支柱是直覺,但要在工程上站得住腳,需要更深入的機制與原則。
動態分派與虛擬方法表(virtual table)。 多型在執行期之所以成立,靠的是動態分派(dynamic dispatch):呼叫 d.price() 時,真正執行哪一份程式碼,要到執行期才依物件的實際型別決定,而非編譯期靜態決定。在 C++、Java 這類語言裡,常見的實作是虛擬方法表(virtual method table, 簡稱 vtable)。每個含有虛擬方法的類別,編譯器會建一張函式指標表;每個物件的記憶體開頭暗藏一個指向該表的指標(vptr)。呼叫虛擬方法時,機器做的事大致是:先透過 vptr 找到 vtable,再到表中固定的位移取出對應的函式位址,然後跳轉執行。
這個機制的代價是一次間接定址(indirection),時間複雜度為 $O(1)$,但比起靜態呼叫多了一次記憶體存取,且妨礙編譯器內聯(inline)最佳化。理解 vtable 也解釋了若干實務現象:為何 C++ 的多型基底類別需要虛擬解構子(否則經由基底指標刪除子類別物件會行為未定義);為何虛擬呼叫在極度效能敏感的迴圈中會被刻意避開。Python 的分派則更動態——它沒有 vtable,而是執行期沿著型別的方法解析順序(method resolution order, MRO)在字典裡查找屬性,更靈活但開銷也更大。
SOLID 原則的精神。 SOLID 是五條物件導向設計原則的縮寫,它們可視為「如何把抽象用對」的準則:
- 單一職責(Single Responsibility):一個類別應只有一個改變的理由。把「算價」與「存資料庫」「印收據」混在同一個類別,等於把多個改變理由綁在一起。
- 開放封閉(Open–Closed):對擴充開放、對修改封閉。前面用多型取代
if-elif的重構,正是這條原則的體現——新增行為靠新增類別,而非修改既有程式碼。 - 里氏替換(Liskov Substitution):子類別必須能無痛替換父類別而不破壞正確性。前述「正方形繼承長方形」之所以是反例,正是因為它違反了里氏替換——一段預期「設定寬不影響高」的程式碼,遇到正方形就會出錯。這條原則為「何時該用繼承」提供了嚴格判準。
- 介面隔離(Interface Segregation):寧可有多個專一的小介面,也不要一個臃腫的大介面,避免實作者被迫兌現用不到的方法。
- 依賴反轉(Dependency Inversion):高層模組與低層模組都應依賴抽象,而非高層依賴低層的具體實作。
checkout依賴抽象的PaymentMethod而非具體的Cash,正是依賴反轉。
這五條原則共同指向一個目標:讓「變動」的影響範圍可控。它們與前面的組合、策略模式環環相扣——好的物件導向設計,本質上是在管理「改變」如何在系統中傳播。
與其他典範的連結。 物件導向並非唯一答案。函數式程式設計(functional programming)用不可變(immutable)資料與純函式(pure function)達成另一種可組合性,近年也深刻影響了 OOP 實務(例如多用不可變物件以避免共享狀態的並行問題)。值得體會的是,封裝對抗的是「狀態被任意修改」,而函數式乾脆主張「不要可變狀態」——兩者從不同方向逼近同一個敵人:失控的複雜度。成熟的工程師往往不拘泥於單一典範,而是依問題本質,在「物件保管狀態」與「函式轉換資料」之間做出權衡。