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"
三个要点:
- 类型注解是必需的——dataclass 靠注解识别哪些是字段(08 章的类型注解在这里第一次变成功能性代码)。
- 可变默认值必须用
field(default_factory=list)——直接写tags: list = []会直接拒绝你生成,语言层面把 01 章那颗雷焊死了。 - 自动生成
__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__
self.__class__:实例永远知道自己是哪个类(拔高课 06 用它做clone()/from_config工厂)。__module__:类定义在哪个模块——日志、序列化时用来回答「这对象从哪来」。__qualname__:限定名,含外层类 / 函数。嵌套类Outer.Inner的__qualname__是"Outer.Inner",而__name__只是"Inner"——靠它才能唯一区分。
__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 内省能力的全貌。
自检五题
self.tags.append(x)和self.tags = [x]在「类属性 tags=[]」的前提下行为有何不同?为什么?- property 相比
get_area()方法最大的工程价值是什么? field(default_factory=list)防的是什么?它和可变默认参数是同一个问题吗?- 为什么
t.__dict__和Task.__dict__内容不同?属性查找的「向上找」规则在这两条__dict__链上怎么体现? __mro__解决的是什么问题?什么场景下你会主动去打印它?__slots__相比默认__dict__换来哪三样东西?
下一章
05 章讲异常:层次结构、传播规则、raise from 的用途,以及 EAFP 风格——Python 处理错误的世界观。