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

UeduGPTs

--

Jupyters

4

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

AI 回覆桌面通知

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

聊天訊息通知

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

聲音通知

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

類別與物件

Python 物件模型的引擎室:描述器、__slots__ 與元類別

當你寫下 obj.x 的瞬間,Python 啟動了一整套屬性查找協定——從 __dict__、描述器、@property 到 metaclass,拆開物件導向真正的底盤

當你寫下 obj.x 的瞬間,Python 到底做了多少事?

入門篇教你寫 self.name = name、用 super() 串起繼承、實作 __str__ 讓物件自我介紹。這些都沒錯,但它們把一件事藏了起來:當你寫下看似最樸素的 obj.x = 5 或讀取 obj.x 時,Python 其實啟動了一整套屬性查找協定(attribute lookup protocol)。這套協定才是 Python 物件模型真正的引擎室——它解釋了為什麼 @property 能讓一個方法假裝成屬性、為什麼 ORM 框架(如 Django、SQLAlchemy)能讓 user.name 背後偷偷查資料庫、為什麼 @dataclass 一行裝飾就能生出 __init__,以及為什麼某些類別加上 __slots__ 後記憶體驟減、速度變快。

這篇進階篇不再重述「類別是藍圖、物件是成品」,而是帶你拆開物件的底盤:屬性存在哪裡、查找走哪條路、描述器(descriptor)如何攔截、元類別(metaclass)又是什麼。讀完你會發現,Python 的 OOP 不是一堆硬規則,而是少數幾條協定彼此咬合的優雅機制。

物件的內在:__dict__ 與屬性的真實住所

先破除一個迷思:物件的屬性不是神秘地「掛在物件上」,而是實實在在地存在一個叫 __dict__ 的字典裡。

類別與物件進階概念示意圖

class Dog:
    species = "哺乳類"          # 類別屬性,存在 Dog.__dict__

    def __init__(self, name):
        self.name = name        # 實例屬性,存在 instance.__dict__


d = Dog("小白")
print(d.__dict__)        # {'name': '小白'}
print(Dog.__dict__.keys())  # 含 'species'、'__init__'、'__doc__' ...

d.__dict__["mood"] = "開心"  # 直接操作字典,等同 d.mood = "開心"
print(d.mood)            # 開心

每個實例有自己的 __dict__,每個類別也有自己的 __dict__。當你寫 d.name,Python 並非「直接拿」,而是執行一套查找順序,大致為:

  1. 先看 type(d)(也就是 Dog)及其 MRO 上有沒有資料描述器(data descriptor),有就交給它(稍後解釋)。
  2. 再看實例自己的 d.__dict__ 有沒有 name
  3. 再看類別與 MRO 上有沒有 name(類別屬性、方法、非資料描述器)。
  4. 全都沒有,呼叫 __getattr__(若有定義),否則拋 AttributeError

這套順序由 object.__getattribute__ 統一掌控。理解「屬性住在字典裡、查找有固定優先序」,你才看得懂後面所有進階機制——它們全都只是在這條查找鏈上插入攔截點。

@property:讓方法穿上屬性的外衣

入門篇可能讓你以為「屬性是資料、方法要加括號呼叫」。但 @property 打破了這條界線:它讓你用存取屬性的語法,背後卻執行方法的邏輯。這正是「統一存取原則(Uniform Access Principle)」的實踐——呼叫端不需要知道 temperature 是一個存好的數值,還是一個即時計算的結果。

class Temperature:
    def __init__(self, celsius=0):
        self._celsius = celsius      # 慣例:底線前綴表示「內部用」

    @property
    def celsius(self):
        return self._celsius

    @celsius.setter
    def celsius(self, value):
        if value < -273.15:
            raise ValueError("低於絕對零度,物理上不可能")
        self._celsius = value

    @property
    def fahrenheit(self):
        # 唯讀的「計算屬性」,沒有對應的 setter
        return self._celsius * 9 / 5 + 32


t = Temperature(25)
print(t.celsius)        # 25       ← 看起來是讀屬性,其實呼叫了 getter
print(t.fahrenheit)     # 77.0     ← 即時計算
t.celsius = 30          # 看起來是寫屬性,其實呼叫了 setter(含驗證)
# t.celsius = -300      # ← ValueError:低於絕對零度
# t.fahrenheit = 100    # ← AttributeError:沒有 setter,唯讀

@property 的巨大價值在於漸進式重構:一開始你可以單純把 celsius 當公開屬性用;某天需要加入驗證或改成計算值時,外部呼叫 t.celsius 的程式碼一個字都不用改。在 Java、C++ 裡你被教導「一開始就寫 getter/setter 以防萬一」,但在 Python 這是反模式——直接用公開屬性即可,需要時再升級成 property,這就是所謂的 You Aren't Gonna Need It 哲學。

描述器:property 背後的真正主角

@property 並非語言的內建特權,它只是描述器協定(descriptor protocol) 的一個應用。任何類別只要實作了 __get____set____delete__ 其中之一,它的實例放到另一個類別身上當屬性時,就會攔截存取。這是 Python OOP 最強大、卻最少被入門者觸及的機制。

  • 同時有 __get____set__(或 __delete__)→ 資料描述器,優先序高於實例 __dict__
  • 只有 __get__非資料描述器(方法、@staticmethod@classmethod 都屬此類),優先序低於實例 __dict__

我們親手寫一個「型別檢查描述器」,它能在賦值時自動驗證型別,且一個描述器可以被多個欄位重複利用

class Typed:
    def __init__(self, expected_type):
        self.expected_type = expected_type

    def __set_name__(self, owner, name):
        # Python 自動呼叫,告訴描述器「我被取名叫什麼」
        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 not isinstance(value, self.expected_type):
            raise TypeError(
                f"期望 {self.expected_type.__name__},得到 {type(value).__name__}"
            )
        setattr(obj, self.private_name, value)


class Person:
    name = Typed(str)      # 類別層級宣告,name 是一個資料描述器
    age = Typed(int)

    def __init__(self, name, age):
        self.name = name   # 觸發 Typed.__set__,自動驗證
        self.age = age


p = Person("小華", 20)
print(p.name)             # 小華
# p.age = "二十"          # ← TypeError:期望 int,得到 str

這段程式碼揭露了一個深刻的事實:@property 不過是「為單一欄位量身打造的資料描述器」的語法糖。而描述器的攔截能力一旦掌握,你就握有了 ORM 欄位、functools.cached_property、甚至 Python 方法綁定(method binding)本身的鑰匙——沒錯,普通的函式之所以能變成「綁定方法(bound method)」自動帶入 self,正是因為函式物件實作了 __get__,它是個非資料描述器。

看一個例子:方法綁定其實是描述器在運作

入門篇說「d.bark() 等價於 Dog.bark(d)」,但沒說 Python 是怎麼把 d 自動塞進去的。答案就是描述器:

class Dog:
    def bark(self):
        return f"{id(self)} 汪"


d = Dog()
print(type(Dog.__dict__["bark"]))  # <class 'function'>  ← 存著的是純函式
print(type(d.bark))                # <class 'method'>    ← 取出時變成綁定方法

# d.bark 觸發了 function.__get__(d, Dog),把 d 預先綁進去
bound = d.bark
print(bound())                     # 不必傳 self,因為已綁定

d.bark 之所以「自動知道」自己屬於 d,是因為 Dog.__dict__["bark"] 是個函式(非資料描述器),透過 d.bark 存取時,function.__get__ 被呼叫,回傳一個把 d 記住的綁定方法。整個「self 自動傳入」的魔法,底層只是描述器協定的一次標準運作而已。

__slots__:用空間換時間,也省下空間

每個實例都有 __dict__ 雖然彈性極高(可隨時新增屬性),但字典本身耗費可觀記憶體。當你要建立數百萬個同型別的小物件(粒子模擬、座標點、ORM 列),這筆開銷會壓垮記憶體。__slots__ 讓你宣告「這個類別只會有這幾個屬性」,Python 就不再為每個實例配置 __dict__,改用更精簡的固定配置(底層是預先算好偏移量的 C 結構欄位,本質上就是描述器)。

class PointDict:
    def __init__(self, x, y):
        self.x = x
        self.y = y


class PointSlots:
    __slots__ = ("x", "y")     # 宣告:只允許這兩個屬性
    def __init__(self, x, y):
        self.x = x
        self.y = y


p = PointSlots(1, 2)
print(p.x)                 # 1
# p.z = 3                  # ← AttributeError:'PointSlots' 沒有 'z'
# print(p.__dict__)        # ← AttributeError:根本沒有 __dict__

效果通常是:每個實例省下約 40% ∼ 50% 記憶體,屬性存取也略快(不用走字典雜湊)。代價是失去動態新增屬性的彈性、且多重繼承時 __slots__ 的組合有額外規則。實務準則:一般類別不必用,但對「會被大量實例化的資料載體」非常值得用。順帶一提,@dataclass(slots=True)(Python 3.10+)可以一鍵幫你套上。

__new____init__:物件的兩段式誕生

入門篇的重點提到「真正造出物件的是 __new__,日常改 __init__ 就夠」,但沒說明何時非用 __new__ 不可。關鍵在於:__init__ 只能初始化一個已存在的物件,它不能控制「要不要造、造哪一個、回傳什麼」。當你需要這層控制——例如實作不可變(immutable)型別或單例(singleton)——就必須下到 __new__

class Singleton:
    _instance = None

    def __new__(cls, *args, **kwargs):
        if cls._instance is None:
            cls._instance = super().__new__(cls)   # 只造一次
        return cls._instance


a = Singleton()
b = Singleton()
print(a is b)              # True ← 永遠是同一個物件

繼承不可變型別(如 tuplestr)時更非用 __new__ 不可,因為值必須在物件「成形的那一刻」就確定,__init__ 已經來不及:

class Coord(tuple):
    def __new__(cls, x, y):
        return super().__new__(cls, (x, y))   # 不可變值在這裡定型

    @property
    def x(self): return self[0]
    @property
    def y(self): return self[1]


c = Coord(3, 4)
print(c.x, c.y, c)        # 3 4 (3, 4)
# c[0] = 99               # ← TypeError:tuple 不可變

記住分工:__new__配置者(allocator),負責生出並回傳實例(它是隱含的靜態方法,第一參數是 cls);__init__初始化者(initializer),拿到已生出的 self 把資料填好,且不可回傳非 None 的值

@classmethod@staticmethod:不只是「不用 self 的方法」

入門篇幾乎都在談實例方法。但 Python 還有兩種方法,它們各自解決不同問題:

  • @classmethod:第一參數是 cls(類別本身),常用來寫替代建構子(alternative constructor)
  • @staticmethod:不接收 self 也不接收 cls,只是「邏輯上歸屬這個類別」的純函式。

classmethod 的精妙在於:因為它拿到的是 cls,在繼承時會自動指向正確的子類別,這是寫死類別名做不到的多型行為。

class Date:
    def __init__(self, year, month, day):
        self.year, self.month, self.day = year, month, day

    @classmethod
    def from_string(cls, s):
        y, m, d = map(int, s.split("-"))
        return cls(y, m, d)        # 用 cls 而非 Date,子類別也能用

    @staticmethod
    def is_leap_year(year):
        return year % 4 == 0 and (year % 100 != 0 or year % 400 == 0)

    def __repr__(self):
        return f"{type(self).__name__}({self.year}, {self.month}, {self.day})"


class TaiwanDate(Date):
    pass


print(Date.from_string("2026-06-14"))         # Date(2026, 6, 14)
print(TaiwanDate.from_string("2026-06-14"))   # TaiwanDate(2026, 6, 14) ← cls 生效!
print(Date.is_leap_year(2024))                # True

注意 TaiwanDate.from_string(...) 回傳的是 TaiwanDate 而非 Date——因為 cls 在被 TaiwanDate 呼叫時就是 TaiwanDate。若當初寫死成 return Date(...),這個多型就壞了。這是「為什麼要用 cls 而不是類別名」的核心理由,和入門篇「為什麼要用 super() 而不是父類別名」異曲同工。

@dataclass:把樣板程式碼交給編譯期生成

當一個類別主要任務是「裝資料」,你會發現自己一遍遍手寫 __init____repr____eq__,又臭又長還容易出錯。dataclasses(Python 3.7+)用一個裝飾器,根據你宣告的型別註記自動生成這些方法:

from dataclasses import dataclass, field


@dataclass(frozen=True, slots=True)   # frozen=不可變、slots=省記憶體
class Student:
    name: str
    score: int
    tags: list = field(default_factory=list)   # 可變預設值的正解


s1 = Student("小明", 90)
s2 = Student("小明", 90)
print(s1)                  # Student(name='小明', score=90, tags=[])
print(s1 == s2)            # True ← 自動生成的 __eq__ 比較「值」
# s1.score = 100          # ← FrozenInstanceError:frozen 不可變

特別注意 field(default_factory=list):這正是入門篇警告過的「可變物件當預設值會被共享」的官方解法——default_factory 保證每個實例都呼叫一次 list(),各得一份新串列@dataclass 不是新語言特性,它只是一個讀取型別註記、再用程式碼生成填好那些 dunder 方法的裝飾器;理解它的運作,你就懂了 Python「以協定與生成取代樣板」的設計取向。

重點回顧

  • 屬性住在 __dict__ 裡,查找有固定優先序:資料描述器 → 實例字典 → 類別/MRO → __getattr__。所有進階機制都只是在這條鏈上插攔截點。
  • @property 是「單一欄位的資料描述器」語法糖,實現統一存取原則:先用公開屬性,需要驗證或計算時再無痛升級,不必預先寫 getter/setter。
  • 描述器協定(__get__/__set__/__set_name__)是 OOP 引擎室,連「方法自動綁定 self」都是它的運作;掌握它等於握有 ORM 欄位、cached_property 的鑰匙。
  • __slots__ 用「禁止動態新增屬性」換取大量記憶體與速度,對會被海量實例化的資料載體特別划算。
  • __new__ 配置、__init__ 初始化;不可變型別與單例必須下到 __new__@classmethodcls 寫出對子類別友善的替代建構子。

深入探討(研究所視角)

元類別:類別本身也是物件,那它的「類別」是誰?

Python 有一句名言:「類別也是物件」。既然 Dog 是物件,它必然有自己的型別——而那個型別就是元類別(metaclass),預設是 typetype 既是內建型別,也是製造所有類別的工廠:

print(type(42))        # <class 'int'>     ← 42 的型別是 int
print(type(int))       # <class 'type'>    ← int 的型別是 type
print(type(type))      # <class 'type'>    ← type 的型別是它自己(遞迴終點)

你平常寫的 class Dog: ... 其實是一段語法糖,等價於呼叫 type("Dog", bases, namespace) 動態生出類別物件。自訂元類別讓你在「類別被建立的當下」介入——例如自動註冊子類別、強制命名規範、注入方法。這正是 ORM(models.Model)、abc.ABCMeta(抽象基底類別)、enum.Enum 的底層機制:

class AutoRegister(type):
    registry = {}
    def __new__(mcls, name, bases, ns):
        cls = super().__new__(mcls, name, bases, ns)
        if bases:                       # 跳過基底類別本身
            AutoRegister.registry[name] = cls
        return cls


class Plugin(metaclass=AutoRegister):
    pass


class CsvPlugin(Plugin): pass
class JsonPlugin(Plugin): pass

print(AutoRegister.registry)   # {'CsvPlugin': ..., 'JsonPlugin': ...}

值得一提的是,多數需要「攔截類別建立」的需求,如今已可用更輕量的 __init_subclass__ 掛鉤達成(Python 3.6+),不必動用元類別這把重武器。Tim Peters 的名言道破了取捨:「元類別是 99% 的使用者永遠不需要擔心的深層魔法——如果你拿不準需不需要,那答案就是不需要。」

抽象基底類別與結構型別:兩種「介面」哲學的會合

入門篇談過鴨子型別「只問會什麼、不問是誰」的彈性,代價是型別錯誤要到執行期才爆。Python 提供兩條互補的補強路線。其一是 abc 模組的抽象基底類別(Abstract Base Class, ABC),用名義型別(nominal typing) 強制子類別實作特定方法,否則連實例化都會失敗:

from abc import ABC, abstractmethod


class Shape(ABC):
    @abstractmethod
    def area(self) -> float: ...


class Circle(Shape):
    def __init__(self, r): self.r = r
    def area(self): return 3.14159 * self.r ** 2


# Shape()      # ← TypeError:含抽象方法,不可實例化
print(Circle(2).area())   # 12.56636

其二是 typing.Protocol結構型別(structural typing):不靠繼承,只要物件「形狀對」(有對的方法簽章)就被視為相容,把鴨子型別的精神帶進靜態檢查。mypypyright 能在執行前就驗證相容性,兼得彈性與安全。這兩種介面哲學——「你必須宣告繼承我」對上「你長得像我就算數」——分別呼應了 Java/C# 的名義型別與 Go 的結構型別,而 Python 兩者兼容並蓄。

屬性查找的完整協定與 __getattr__ 的攔截時機

最後把屬性查找講到底。obj.x 實際觸發的是 type(obj).__getattribute__(obj, "x"),它是無條件被呼叫的總入口;而 __getattr__ 只在前者找不到屬性、即將拋 AttributeError 的當下才作為後備被喚起。這個「正常路徑 vs 後備路徑」的分工,是實作惰性載入(lazy loading)與代理物件(proxy)的關鍵:

class LazyConfig:
    def __init__(self):
        self._loaded = {}

    def __getattr__(self, name):
        # 只在常規查找失敗時才進來——首次存取某設定才去「載入」
        print(f"[首次載入] {name}")
        value = f"value_of_{name}"
        self._loaded[name] = value
        setattr(self, name, value)   # 存進 __dict__,下次走正常路徑、不再進這裡
        return value


cfg = LazyConfig()
print(cfg.database_url)   # [首次載入] database_url → value_of_database_url
print(cfg.database_url)   # value_of_database_url(這次沒有「首次載入」訊息)

第一次存取 cfg.database_url 時,__dict__ 裡沒有它,__getattribute__ 失敗 → __getattr__ 接手、計算並寫回 __dict__;第二次存取就直接在 __dict__ 命中,__getattr__ 完全不被觸發。若你想攔截每一次存取(連已存在的屬性也要),則需覆寫更底層的 __getattribute__——但那極易寫出無窮遞迴(在裡面又寫 self.x 會再次觸發自己),務必透過 super().__getattribute__(name) 取值。

把這四層——__dict__、描述器、__getattr____getattribute__——串成一張圖,你就掌握了 Python 物件模型的全貌。從此 obj.x 對你不再是一個樸素的取值動作,而是一套你能精準介入、為框架與函式庫注入魔法的協定。動手把本文的描述器與 __getattr__ 範例改寫成你專案裡真實的需求(型別驗證、欄位審計、惰性 API),你對 Python OOP 的理解就會從「會用語法」真正進階到「懂得物件模型」。

AI 共讀助教正在陪你讀:Python 物件模型的引擎室:描述器、__slots__ 與元類別
嗨!我是這篇文章的共讀助教,只根據〈Python 物件模型的引擎室:描述器、__slots__ 與元類別〉的內容回答。可以問我「解釋某段」「舉個例子」「出題考我」,或反白文中段落後點下方「解釋選取段落」。