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

UeduGPTs

--

Jupyters

4

UG26 CISOSE26
臺北 AQI 46 · 臺中 AQI 26 · 臺南 AQI 21 · 高雄 AQI 33

AI 回覆桌面通知

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

聊天訊息通知

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

聲音通知

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

物件導向概念

物件導向概念(進階):方法解析、線性化與物件模型內部

當多重繼承讓「往上找方法」變得有歧義,語言設計者如何用 MRO、C3 線性化與描述器協定,把繼承的混亂收進一條精密的軌道。

當兩個父類別都有 greet(),到底會呼叫哪一個?

你已經知道繼承(inheritance)讓子類別自動擁有父類別的方法,也知道「組合優於繼承」。現在請試著回答一個更刁鑽的問題:如果一個類別同時繼承兩個父類別,而這兩個父類別恰好都定義了同名方法 greet(),當你在子類別的物件上呼叫 greet(),Python 會執行哪一份程式碼?更進一步,如果這兩個父類別又共有同一個「祖父類別」,形成一個鑽石(diamond)形狀的繼承圖,那個祖父的方法會被呼叫幾次?

class A:
    def greet(self): print("A")
class B(A):
    def greet(self): print("B"); super().greet()
class C(A):
    def greet(self): print("C"); super().greet()
class D(B, C):
    def greet(self): print("D"); super().greet()

D().greet()

如果你直覺以為輸出是 D B A C A(先把 B 那條路走到底、再走 C),那你低估了 OOP 語言為了「正確處理多重繼承」所下的功夫。真正的輸出是 D B C A——祖父 A 只被呼叫一次。這篇文章要帶你深入物件導向語言的引擎室:方法到底是怎麼被「找到」的,物件在記憶體裡長什麼樣子,以及語言設計者為了讓繼承不淪為一團混亂,藏了哪些精巧的機制。

方法解析順序:繼承的「線性化」

物件導向概念進階概念示意圖

入門篇說「子類別找不到方法時,會往父類別找」。這在單一繼承下很單純:沿著一條鏈往上爬即可。但一旦進入多重繼承,「往上找」就有了歧義——往哪個父類別找?找的順序是什麼?答案就藏在每個類別的方法解析順序(method resolution order, MRO)裡。

MRO 是把一張可能很複雜的繼承「圖」,攤平成一條沒有歧義的「線性順序」。當你呼叫 obj.method(),直譯器並不是即興地四處搜尋,而是沿著 type(obj).__mro__ 這條早已算好的清單,從頭到尾找到第一個定義了該方法的類別,就停下來執行。

print([c.__name__ for c in D.__mro__])
# ['D', 'B', 'C', 'A', 'object']

這條清單解釋了一切。D().greet() 先找到 Dgreet,執行並印出 D;其中的 super().greet() 並不是「呼叫父類別」,而是「呼叫 MRO 裡下一個」——也就是 BBsuper() 又指向 MRO 的下一個 C(注意:不是 A!);Csuper() 才到 A。於是 A 只被經過一次。這就是為什麼鑽石繼承下,共同祖先不會被重複初始化——只要每一層都老實呼叫 super(),MRO 會保證每個類別恰好被走訪一次。

這裡藏著一個常被誤解的真相:super() 不等於「父類別」。它是「在當前物件的 MRO 上,從現在這個類別往後一格」。同一個 B 類別,在 D 的脈絡下 super() 指向 C,但若你單獨 B().greet()Bsuper() 就指向 Asuper() 的目標是動態的,取決於最終物件的 MRO,而非靜態寫死的繼承關係。

C3 線性化:MRO 不是隨便排的

那麼這條 MRO 清單是怎麼算出來的?Python(以及多數現代語言)用的是 C3 線性化(C3 linearization) 演算法。它不是「深度優先」也不是「廣度優先」,而是一套要同時滿足三個約束的排序:

  1. 子類別永遠排在父類別前面(找方法時先看自己再看祖先)。
  2. 父類別的相對順序被保留class D(B, C) 寫的順序 BC 前,MRO 就尊重它)。
  3. 單調性(monotonicity):子類別的 MRO 必須是其各父類別 MRO 的「一致延伸」,不能出現矛盾的順序倒置。

C3 的計算可以用一個運算子 merge(合併)來描述。記 $L[X]$ 為類別 $X$ 的線性化清單,則:

$$L[X] = X + \text{merge}(L[P_1], L[P_2], \dots, L[P_n], [P_1, P_2, \dots, P_n])$$

其中 $P_1 \dots P_n$ 是 $X$ 的直接父類別。merge 的規則是:反覆取第一個清單的「頭(head)」,檢查它是否出現在任何其他清單的「尾(tail,即非頭部分)」中;若沒有,就把它取出放進結果並從所有清單刪除;若有,就改試下一個清單的頭。重複直到清空。

動手算一下

讓我們手動推導前面那個 D(B, C) 的 MRO。已知各父類別的線性化(可自行驗證):

  • $L[A] = [A, O]$($O$ 代表 object
  • $L[B] = [B, A, O]$
  • $L[C] = [C, A, O]$

於是:

$$L[D] = D + \text{merge}([B,A,O],\ [C,A,O],\ [B,C])$$

逐步合併:

  • 取候選 B:它是 [B,A,O] 的頭。B 有出現在其他清單的尾部嗎?[C,A,O] 的尾是 [A,O][B,C] 的尾是 [C],都沒有 B。✅ 取 B。剩 merge([A,O], [C,A,O], [C])
  • 取候選 A:它是 [A,O] 的頭。A 有在其他尾部嗎?[C,A,O] 的尾是 [A,O]——A 在裡面!❌ 不能取 A,跳過這個清單。
  • 改取候選 C:它是 [C,A,O] 的頭。C 在其他尾部嗎?[A,O] 沒有、[C] 的尾是空。✅ 取 C。剩 merge([A,O], [A,O], [])
  • A:是頭,且不在任何尾部。✅ 取 A。剩 merge([O],[O])
  • O:✅。

結果 $L[D] = [D, B, C, A, O]$,與直譯器報告的完全一致。第二步那個「A 被擋下」正是 C3 的精髓:它強制「共同祖先 A 必須等到所有比它更具體的後代(BC)都排完,才能出場」。這保證了「先特化、後一般化」的搜尋順序在多重繼承下依然成立。

值得一提的是,C3 並非萬能——有些繼承結構會讓 merge 卡死(某個清單的頭永遠出現在別人的尾部,形成循環矛盾),這時 Python 會直接拋出 TypeError: Cannot create a consistent method resolution order。這不是 bug,而是語言誠實地告訴你:「你要求的繼承關係在邏輯上自相矛盾,沒有任何排序能同時滿足你的需求。」

Mixin:把多重繼承用在刀口上

理解了 MRO,你就能正確使用多重繼承最有價值的形態——mixin(混入類別)。Mixin 是一種刻意設計成「不獨立存在、只用來被混進別人」的小型類別。它通常不定義 __init__、不持有自己的核心狀態,只提供一組「橫切(cross-cutting)」的行為,讓多個彼此無關的類別都能共享。

class JSONSerializableMixin:
    def to_json(self):
        import json
        return json.dumps(self.__dict__, ensure_ascii=False)

class TimestampMixin:
    def touch(self):
        from datetime import datetime
        self.updated_at = datetime.now()

class Drink(JSONSerializableMixin, TimestampMixin):
    def __init__(self, name, price):
        self.name = name
        self.price = price

d = Drink("奶茶", 60)
d.touch()
print(d.to_json())   # {"name": "奶茶", "price": 60, "updated_at": "..."}

Drink 的本質是一杯飲料,「能序列化成 JSON」「能記錄更新時間」都不是它的核心身份,而是可插拔的附加能力。Mixin 讓這些能力跨越類別階層被重用,而不必把它們塞進一個臃腫的共同基底類別。這正好呼應入門篇提到的「介面隔離」與「組合優於繼承」精神——只是這裡用多重繼承實現了一種「能力的拼裝」。

使用 mixin 的紀律是:讓它們待在 MRO 的前段(寫在繼承串列的左邊),並且各自只負責一件正交(orthogonal)的事。當每個 mixin 都老實呼叫 super(),C3 線性化會像疊積木一樣,把多個 mixin 的行為串成一條協作鏈——這個模式被稱為「協作式多重繼承(cooperative multiple inheritance)」。

物件不只是字典:__slots__ 與記憶體模型

入門篇談的是「物件持有狀態」這個抽象。進階一點,我們該問:那些狀態在記憶體裡實際上長什麼樣子?在 CPython 中,一般物件的屬性預設存放在一個隱藏的字典 __dict__ 裡。這帶來了驚人的彈性——你可以在執行期隨意給物件新增屬性:

d = Drink("綠茶", 30)
d.extra_note = "少冰"   # 從未在類別中宣告,照樣成立

這份彈性的代價是記憶體與速度。每個物件都背著一個字典,當你要建立數百萬個小物件時,這些字典的雜湊表開銷會非常可觀。__slots__ 機制讓你用一點彈性換回大量效率:

class Point:
    __slots__ = ("x", "y")     # 明確宣告:這個類別的物件只有這兩個屬性
    def __init__(self, x, y):
        self.x = x
        self.y = y

p = Point(1, 2)
# p.z = 3   # AttributeError:slots 物件不允許新增未宣告的屬性

宣告 __slots__ 後,直譯器不再為物件配置 __dict__,而是像 C 的 struct 那樣,在固定偏移量(offset)上直接存放屬性值。屬性存取因此從「雜湊表查找」變成「固定位移定址」,更快也更省記憶體(對小物件,記憶體可省下數成)。代價是失去動態新增屬性的能力,以及一些與多重繼承、弱引用的相容性細節。這個取捨清楚揭示了一條原則:OOP 的「物件」是一層抽象,但它最終要落在具體的記憶體佈局上,抽象與機器之間總存在可被工程師主動操控的張力。

描述器:屬性存取背後的協定

最後一塊進階拼圖回答了入門篇沒解釋的問題:當你寫 obj.attr,這個看似平凡的「點」運算,背後究竟發生了什麼?答案是描述器協定(descriptor protocol)——Python 物件模型中最被低估、卻最強大的機制,propertyclassmethodstaticmethod,甚至「方法本身」都是它的產物。

一個描述器,就是任何定義了 __get____set____delete__ 的類別。當它被放進「另一個類別」當作類別屬性時,對它的存取會被攔截、轉交給這些方法處理。我們可以用描述器,把「驗證邏輯」一次寫好,套用到任意多個屬性上——這是入門篇 deposit 裡那種手寫檢查的進化版:

看一個例子

class Positive:
    """一個描述器:保證被它管理的屬性永遠為正數。"""
    def __set_name__(self, owner, name):
        self.private_name = "_" + name      # 自動得知自己被綁到哪個屬性

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return getattr(obj, self.private_name)

    def __set__(self, obj, value):
        if value <= 0:
            raise ValueError(f"{self.private_name[1:]} 必須為正,收到 {value}")
        setattr(obj, self.private_name, value)

class Drink:
    price = Positive()        # 把「正數驗證」掛到 price 屬性上
    volume = Positive()       # 同一套邏輯,零重複,套到 volume

    def __init__(self, price, volume):
        self.price = price    # 觸發 Positive.__set__,自動驗證
        self.volume = volume

d = Drink(60, 500)
print(d.price)                # 60,觸發 Positive.__get__
# d.price = -10               # ValueError: price 必須為正,收到 -10

注意這段程式碼的威力:驗證「必須為正」的規則只寫了一次(在 Positive 裡),卻同時守護了 pricevolume 兩個屬性,而且使用端 self.price = price 看起來和最普通的賦值毫無二致。封裝的不變量保護,被提煉成一個可重用、可組合的元件。property 其實就是描述器協定的一個內建特例——它把 getter/setter 包成一個描述器;而你親手寫的 Positive,能做到 property 做不到的事:被多個屬性共享。

這也揭穿了一個常見迷思:「Python 沒有真正的私有屬性,所以封裝很弱」。事實上,透過描述器,Python 能在「賦值」這個最底層的動作上插入任意邏輯,其攔截能力遠比 Java 的 private 關鍵字更深入、更靈活——只是它選擇用「協定」而非「關鍵字」來表達。

重點回顧

  • 方法解析順序(MRO) 把繼承圖攤平成一條無歧義的線性清單;呼叫方法時沿 __mro__ 找到第一個匹配者即停。多重繼承下「往上找」的歧義,由它徹底消解。
  • super() 不是「父類別」,而是「MRO 上的下一個」。它的目標隨最終物件的型別動態改變,這正是鑽石繼承下共同祖先只被呼叫一次的關鍵。
  • C3 線性化merge 演算法產生 MRO,同時保證「子類別優先」「父類別順序保留」「單調性」三大約束;無法滿足時直譯器會誠實報錯。
  • Mixin 是多重繼承最有價值的用法——拼裝正交的橫切能力,配合 super() 形成協作鏈,是「組合優於繼承」精神在多重繼承下的展現。
  • __slots__ 與描述器揭示物件的真實機器面貌:屬性可以是字典、是固定偏移、或是被協定攔截的存取點。封裝與不變量保護,最終都能被提煉成可重用的底層元件。

深入探討(研究所視角)

從 MRO 到型別理論:線性化與子型別關係。 C3 線性化表面是個排序演算法,深層卻牽涉子型別(subtyping)的格(lattice)結構。多重繼承讓型別之間形成的不是一條鏈,而是一個偏序集(partially ordered set);C3 所做的,是為這個偏序找出一個與之相容的全序(total order)——這正是拓撲排序(topological sort)的變體,只是額外加上了「保留局部順序」與「單調性」的約束。單調性之所以關鍵,是因為它保證:若你之後再衍生子類別,既有類別在新 MRO 中的相對順序不會被打亂,從而讓 super() 的協作鏈在類別階層擴張時依然可靠。這種「組合不破壞既有保證」的性質,與型別系統中的可靠性(soundness)論證一脈相承。

Liskov 替換原則的形式化。 入門篇以「正方形繼承長方形」說明里氏替換(Liskov Substitution Principle)為何重要;研究所層級則要看它的形式定義。Liskov 與 Wing 在 1994 年的行為子型別(behavioral subtyping)框架中,用前置條件(precondition)與後置條件(postcondition)精確刻畫:子型別覆寫方法時,前置條件只能放寬(contravariance,逆變)、後置條件只能收緊(covariance,共變),且必須維持父型別的不變量與歷史約束(history constraint)。違反者——例如子類別要求更嚴格的輸入,或回傳更弱的保證——就會讓「凡能用父型別之處皆能代以子型別」這個契約破裂。這套理論直接連到現代語言的型別檢查:例如方法參數型別的逆變、回傳型別的共變規則,本質上就是 LSP 的靜態近似。

名義子型別 vs 結構子型別。 OOP 語言對「什麼算是某型別的子型別」有兩種根本不同的哲學。Java、C++ 採名義型別(nominal typing):你必須明文 implements/extends 某介面,編譯器才認你是其子型別——身份由「名字的宣告」決定。Python 的鴨子型別(duck typing)、Go 的介面、以及 TypeScript 的型別系統則偏向結構型別(structural typing):只要你「長得像」(具備所需的方法簽章),就被當成相容型別,無須事先宣告血緣。Python 後來引入的 typing.Protocol 正是把這種「結構相容」提升為可被靜態檢查器驗證的一級概念——它讓「對介面寫程式」這個入門口號,從執行期的君子協定,升級為編譯前可驗證的契約。理解這條軸線,你會發現入門篇講的「抽象」「多型」「介面」,在不同語言裡其實對應著截然不同的型別論承諾,而這些承諾的取捨,正是程式語言設計領域至今仍在演進的核心議題。

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