Home
Explore Uedu
Student Console
Register as Member/Login
(2) In future presentations of the research findings, in addition to the course project website and public presentations, your real name and personal information will not appear in this research report. If you are interested in the research results, we can provide you with an executive summary after the study is completed.
問卷中心
Teacher Console
Course Setup
Support & Messages
Uptime Data

UeduGPTs

--

Jupyters

0

Local AI

--

CISOSE26 本地 AI UG26
政治大學 AQI 18 29°C

AI Reply Desktop Notifications

Show a desktop notification when the AI TA finishes replying

Chat Message Notifications

Notify me when classmates post messages in the forum

Sound notification

Play an alert sound whenever there is a new notification

物件導向概念

物件導向概念

從一杯飲料的點餐系統出發,理解封裝、繼承、多型與抽象如何協同管理軟體的複雜度,並深入動態分派與 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 共讀助教正在陪你讀:物件導向概念
嗨!我是這篇文章的共讀助教,只根據〈物件導向概念〉的內容回答。可以問我「解釋某段」「舉個例子」「出題考我」,或反白文中段落後點下方「解釋選取段落」。