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

類別與物件

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__ 與元類別〉的內容回答。可以問我「解釋某段」「舉個例子」「出題考我」,或反白文中段落後點下方「解釋選取段落」。