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

物件導向概念

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

當多重繼承讓「往上找方法」變得有歧義,語言設計者如何用 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 共讀助教正在陪你讀:物件導向概念(進階):方法解析、線性化與物件模型內部
嗨!我是這篇文章的共讀助教,只根據〈物件導向概念(進階):方法解析、線性化與物件模型內部〉的內容回答。可以問我「解釋某段」「舉個例子」「出題考我」,或反白文中段落後點下方「解釋選取段落」。