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 並非「直接拿」,而是執行一套查找順序,大致為:
- 先看
type(d)(也就是Dog)及其 MRO 上有沒有資料描述器(data descriptor),有就交給它(稍後解釋)。 - 再看實例自己的
d.__dict__有沒有name。 - 再看類別與 MRO 上有沒有
name(類別屬性、方法、非資料描述器)。 - 全都沒有,呼叫
__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 ← 永遠是同一個物件
繼承不可變型別(如 tuple、str)時更非用 __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__。@classmethod用cls寫出對子類別友善的替代建構子。
深入探討(研究所視角)
元類別:類別本身也是物件,那它的「類別」是誰?
Python 有一句名言:「類別也是物件」。既然 Dog 是物件,它必然有自己的型別——而那個型別就是元類別(metaclass),預設是 type。type 既是內建型別,也是製造所有類別的工廠:
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):不靠繼承,只要物件「形狀對」(有對的方法簽章)就被視為相容,把鴨子型別的精神帶進靜態檢查。mypy、pyright 能在執行前就驗證相容性,兼得彈性與安全。這兩種介面哲學——「你必須宣告繼承我」對上「你長得像我就算數」——分別呼應了 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 的理解就會從「會用語法」真正進階到「懂得物件模型」。