Home
探索 Uedu
學生控制台
註冊會員/登入
研究知情同意中心
教師控制台
課程設定
支援與訊息
Uptime 數據

UeduGPTs

--

Jupyters

4

UG26 CISOSE26
臺北 AQI 46 · 臺中 AQI 28 · 臺南 AQI 24 · 高雄 AQI 33

AI 回覆桌面通知

AI 助教回覆完成時顯示桌面通知

聊天訊息通知

同學在討論區發送訊息時通知

聲音通知

每當有新通知時播放提示音

物件導向概念

物件導向概念

從一杯飲料的點餐系統出發,理解封裝、繼承、多型與抽象如何協同管理軟體的複雜度,並深入動態分派與 SOLID 原則

為什麼一杯飲料的訂單,會讓程式碼變得一團亂?

想像你在寫一個手搖飲店的點餐系統。一開始只有三種飲料,你用幾個變數記下名稱、容量、加料,算錢時寫一段 if 判斷就解決了。三個月後,店長要你加上「會員折扣」「第二杯半價」「外送加價」「珍珠加價但奶茶免費續珍」……你的 if 越疊越深,每改一處都怕弄壞別處。某天你終於發現:問題不在於規則太多,而在於你把「資料」(飲料有什麼)和「行為」(怎麼算錢)散落在四處,沒有人負責把它們綁在一起。

物件導向程式設計(object-oriented programming, OOP)正是為了回應這種困境而生。它的核心主張很單純:與其讓資料任人擺布,不如讓每個「東西」自己保管資料、自己負責回答關於自己的問題。一杯飲料應該知道自己賣多少錢,而不是讓散落各處的程式碼去猜。

從程序式到物件導向:思維的轉向

程式設計概念示意圖

在程序式(procedural)的思維裡,程式是「一連串對資料的操作」。資料是被動的記錄,函式(function)是主動的操作者。你有一堆 dictstruct 存著飲料資料,再寫一個 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 是物件。namebase_pricesize 是物件的屬性(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(稱父類別/基底類別),不必重寫 namesize 的處理,只覆寫(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()」。普通飲料和氣泡飲各自用不同方式算錢,但對呼叫者而言形態統一。將來新增「季節限定飲」「套餐組合」,只要它也有 nameprice(),這段程式碼一字不改就能運作。

這背後是抽象(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 實務(例如多用不可變物件以避免共享狀態的並行問題)。值得體會的是,封裝對抗的是「狀態被任意修改」,而函數式乾脆主張「不要可變狀態」——兩者從不同方向逼近同一個敵人:失控的複雜度。成熟的工程師往往不拘泥於單一典範,而是依問題本質,在「物件保管狀態」與「函式轉換資料」之間做出權衡。

AI 共讀助教正在陪你讀:物件導向概念
嗨!我是這篇文章的共讀助教,只根據〈物件導向概念〉的內容回答。可以問我「解釋某段」「舉個例子」「出題考我」,或反白文中段落後點下方「解釋選取段落」。