KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA

04 · 类:属性分界、property 与 dataclass — keel 龙骨

## 这一章解决什么问题

这一章解决什么问题

很多人写类只会 __init__ 加一串方法。这一章补三块:类属性与实例属性的查找规则(一个 Mutable defaults 级别的坑源)、property(把方法伪装成属性)、dataclass(样板代码终结者)。最后用魔法方法把「类如何参与语言协议」串起来。

类属性 vs 实例属性

class Task:
    status_choices = ("pending", "running", "done")   # 类属性:所有实例共享
    counter = 0

    def __init__(self, name):
        self.name = name        # 实例属性:每个实例一份
        Task.counter += 1       # 显式写类名,才是改类属性

读 t.status_choices 时,Python 先查实例自己的字典,没有再查类的字典——这就是属性查找顺序。所以:

>>> t1 = Task("a")
>>> t2 = Task("b")
>>> t1.status_choices          # 实例上没有 → 顺着找到类上
('pending', 'running', 'done')
>>> t1.status_choices = ("x",) # 注意:这是在实例上"新建"一个同名属性
>>> t1.status_choices          # ('x',) —— 遮蔽了类属性
>>> t2.status_choices          # ('pending', 'running', 'done') —— 类属性没变

读会向上找,赋值永远只写当前层。 这条规则能解释一大类「为什么我改了 A 却没生效」的困惑。

真正的雷出现在「类属性是可变对象」时:

class Config:
    tags = []                      # ❌ 所有实例共享同一个 list

    def add(self, tag):
        self.tags.append(tag)      # 这是"改"不是"赋值",走的是类属性!

>>> a, b = Config(), Config()
>>> a.add("x")
>>> b.tags
['x']

self.tags.append 是对 self.tags 这个找到的对象调用方法(改对象),不是给 self.tags 赋值(写名字)——所以查找一路找到类上,所有实例遭殃。01 章的可变默认参数是同一颗雷的函数版。规则:实例状态一律放 __init__;类属性只放真正的常量。

property:把计算伪装成属性

class Rectangle:
    def __init__(self, w, h):
        self._w = w
        self._h = h

    @property
    def area(self):
        return self._w * self._h

>>> r = Rectangle(3, 4)
>>> r.area          # 不加括号,像属性一样读
12

property 的价值是保持调用方接口不变的情况下,把字段升级成计算。调用方原来写 r.area,哪天 area 改成查数据库,调用方一行不用动。

property 也能拦写入:

    @property
    def w(self):
        return self._w

    @w.setter
    def w(self, value):
        if value <= 0:
            raise ValueError("width must be positive")
        self._w = value

工程上把握一个度:简单的属性校验用 property 合适;超过三行逻辑、或者需要带参数,就直接写方法,别硬塞进 property。

dataclass:告别样板

手写一个数据类的成本:__init__ 里逐字段赋值、__repr__、__eq__……dataclass 一次性生成:

from dataclasses import dataclass, field

@dataclass
class TaskResult:
    run_id: str
    status: str = "pending"
    tags: list[str] = field(default_factory=list)   # 可变默认值的正确姿势!

    def is_done(self):
        return self.status == "done"

三个要点:

  1. 类型注解是必需的——dataclass 靠注解识别哪些是字段(08 章的类型注解在这里第一次变成功能性代码)。
  2. 可变默认值必须用 field(default_factory=list)——直接写 tags: list = [] 会直接拒绝你生成,语言层面把 01 章那颗雷焊死了。
  3. 自动生成 __repr__(打印实例能看清内容)和 __eq__(逐字段比较相等)。

只读数据用 frozen——不可变版本的 dataclass:

@dataclass(frozen=True)
class Endpoint:
    host: str
    port: int = 6379

>>> Endpoint("localhost").port = 1234
FrozenInstanceError      # 改不了,赋值直接报错

frozen 实例可哈希、能进 set、能当 dict 键、能安全地在多个模块间传递而不用担心被改——01 章「不可变性」在工程里的落地形态就是它。后续课的配置对象、事件记录,都用 frozen dataclass。

魔法方法:类如何接入语言协议

双下划线方法不是装饰,是协议插槽——语言在特定时机调用它们:

你写的 Python 实际调用的 场景
len(x) x.__len__() 内置函数
x == y x.__eq__(y) 运算符
x[k] x.__getitem__(k) 下标访问
for i in x iter(x) → x.__iter__() 迭代(07 章主角)
with x: x.__enter__() / x.__exit__() 上下文管理(07 章主角)

自己动手体会一次:

class Playlist:
    def __init__(self, songs):
        self._songs = list(songs)

    def __len__(self):
        return len(self._songs)

    def __getitem__(self, idx):
        return self._songs[idx]

>>> pl = Playlist(["a", "b"])
>>> len(pl)        # 2
>>> pl[0]          # 'a'
>>> for s in pl: print(s)   # 竟然能 for——因为 __getitem__ 让 Python 回退到
                            # "从 0 开始逐个取,直到 IndexError" 的老协议

__len__ + __getitem__ 就够让对象表现得像序列。07 章会回到这个表,讲清 __iter__/__next__ 的完整协议——那是 yield 课的直接入口。

类与实例的内省变量:对象在运行时暴露了什么

「魔法方法」让你往语言协议里插插槽;另一类双下划线属性你不需要定义,解释器给每个类和实例都默认挂了一套——它们描述对象自己的结构。读懂它们,就懂了前面「读会向上找」规则的实现原理,也接上了拔高课 06 章的反射。

__dict__:实例 / 类的属性字典

每个实例都有一本「自己的属性账本」——__dict__,vars(obj) 是它的简写:

class Task:
    kind = "task"
    def __init__(self, name):
        self.name = name

t = Task("export")
t.owner = "api"            # 运行时动态加属性
t.__dict__                 # {'name': 'export', 'owner': 'api'}
vars(t)                    # 同上,语法糖
del t.owner
t.__dict__                 # {'name': 'export'}

类自己也有 __dict__(装着 kind、__init__ 等方法)。前面那句「读会向上找,赋值只写当前层」的真相就是:属性查找沿着 实例.__dict__ → 类.__dict__ → 父类.__dict__ 这条链走。 这也是为什么改可变类属性会「株连」所有实例——大家共享同一个类 __dict__ 里的那个对象。

self.__class__ / __module__ / __qualname__

__bases__ 与 __mro__:继承关系的两张图

class A: ...
class B(A): ...

B.__bases__        # (<class '__main__.A'>,)              —— 直接父类元组
B.__mro__          # (<class 'B'>, <class 'A'>, <class 'object'>)  —— 实际查找顺序

__bases__ 看「爸爸是谁」;__mro__(Method Resolution Order,C3 线性化)看「找属性时到底按什么顺序翻字典」。它就是「向上找」规则在多继承时的精确版本:b.foo 不是简单「先父后子」,而是严格按 __mro__ 这个线性序列逐个翻 __dict__,命中的第一个胜出。调试「为什么调到这个父类的方法」时,print 一下 __mro__ 比猜快十倍。

__slots__:关掉 __dict__,换内存与约束

默认每个实例都有 __dict__,灵活但费内存(一个 dict 对象 + 哈希表开销)。实例多到百万级时这开销吃不消。__slots__ 把「允许的属性名」写死:

class Point:
    __slots__ = ("x", "y")      # 禁止动态加属性,实例不再有 __dict__
    def __init__(self, x, y):
        self.x, self.y = x, y

p = Point(1, 2)
p.z = 3              # AttributeError:z 不在 slots 里

收益有三:① 内存——百万实例省下百万个 dict;② 防手滑——拼错属性名立刻报错,而不是悄悄新建一个;③ 更快的属性访问。代价:不能随便动态挂属性;如果父类没声明 slots 而子类声明了,父类那层还是会生成 __dict__,所以整套继承链要一起规划。很多高性能数据类 / ORM 模型底层就是 slots。

这些特殊属性,正是拔高课 06 章用 getattr / inspect 读取的对象——那里是为了「运行时按名派发」,这里是为了「看清对象本身」。两条线合起来,就是 Python 内省能力的全貌。

自检五题

  1. self.tags.append(x) 和 self.tags = [x] 在「类属性 tags=[]」的前提下行为有何不同?为什么?
  2. property 相比 get_area() 方法最大的工程价值是什么?
  3. field(default_factory=list) 防的是什么?它和可变默认参数是同一个问题吗?
  4. 为什么 t.__dict__ 和 Task.__dict__ 内容不同?属性查找的「向上找」规则在这两条 __dict__ 链上怎么体现?
  5. __mro__ 解决的是什么问题?什么场景下你会主动去打印它?__slots__ 相比默认 __dict__ 换来哪三样东西?

下一章

05 章讲异常:层次结构、传播规则、raise from 的用途,以及 EAFP 风格——Python 处理错误的世界观。

进入 keel 阅读