Python 实例赋值遮蔽类属性:一个从不报错的静默陷阱
系列 :《Python 对象模型实测篇》------与《Python namedtuple 不可变真相:从语法糖到 ctypes 实测》同系列。
说明:本文所有断言均由脚本在本机真实跑出,输出逐字保留,未做任何改写。
读由查找链决定,写永远落在实例 :obj.attr = v 从不改类,只会遮蔽。类属性没变,只是你再也看不见它了。
0. 环境
- 主解释器:CPython 3.11.9(Marvis 运行时,
python3 -V) - 版本矩阵补充解释器:
/opt/homebrew/bin/python3.10→ 3.10.18;python3.12 → 3.12.10;python3.13 → 3.13.3;python3.14 → 3.14.7 - 静态检查:mypy 2.3.1 (compiled: yes)
- 机器:macOS 26.5.2 / Apple Silicon
1. 实验 A:不可变类属性被静默遮蔽
代码:
python
class Config:
version = 1
a, b = Config(), Config()
a.version = 2
print("a.version =", a.version, "| b.version =", b.version, "| Config.version =", Config.version)
print("a.__dict__ =", a.__dict__, "| b.__dict__ =", b.__dict__)
del a.version
print("del a.version 后 -> a.version =", a.version, "| a.__dict__ =", a.__dict__)
实测输出:
text
a.version = 2 | b.version = 1 | Config.version = 1
a.__dict__ = {'version': 2} | b.__dict__ = {}
del a.version 后 -> a.version = 1 | a.__dict__ = {}
结论:
a.version = 2不报错、不影响b、不改变Config.version,只在a.__dict__里新建了同名实例属性。- 类属性并未被修改,只是被遮蔽(shadowing)。
del a.version删除实例属性后,读取回退到类属性,a.version又变回 1------说明类属性一直完好。- 关键判据:
obj.attr = value永远只写实例__dict__,从不会写类。
诊断一行流(实测):
text
是否被实例遮蔽: 'version' in vars(a) -> True
实例字典: {'version': 2} | 类字典里的值仍为: 1
只看类自己定义的属性: {'version': 1}
2. 实验 B:可变类属性------原地修改 vs 重新绑定
代码:
python
class Registry:
items = []
r1, r2 = Registry(), Registry()
r1.items.append('x')
print("append 后 :", r1.items, r2.items, Registry.items, "| r1.__dict__ =", r1.__dict__)
r1.items = ['y']
print("赋值后 :", r1.items, r2.items, Registry.items, "| r1.__dict__ =", r1.__dict__)
实测输出:
text
append 后 : ['x'] ['x'] ['x'] | r1.__dict__ = {}
赋值后 : ['y'] ['x'] ['x'] | r1.__dict__ = {'items': ['y']}
结论(两种伤害形态完全不同):
.append('x'):原地修改 类上那个 list 对象本身,r1.__dict__依然为空;污染面覆盖r1、r2和Registry(三者看到同一个 list)。静默、无报错、跨实例扩散。r1.items = ['y']:重新绑定 ,只在r1.__dict__建实例属性;r2与Registry不受影响,此时r1被遮蔽。
3. 实验 C:增量赋值落在三种类型上的差异(+=)
代码:
python
class C:
n = 0
t = ()
lst = []
c1, c2 = C(), C()
c1.n += 1
c1.t += (7,)
c1.lst += [1]
print("C.n =", C.n, "| c1.n =", c1.n, "| c1.__dict__ =", c1.__dict__)
print("C.t =", C.t, "| c1.t =", c1.t, "| c2.t =", c2.t)
print("C.lst =", C.lst, "| c1.lst =", c1.lst, "| c2.lst =", c2.lst)
print("c1.lst is C.lst :", c1.lst is C.lst)
实测输出:
text
C.n = 0 | c1.n = 1 | c1.__dict__ = {'n': 1, 't': (7,), 'lst': [1]}
C.t = () | c1.t = (7,) | c2.t = ()
C.lst = [1] | c1.lst = [1] | c2.lst = [1]
c1.lst is C.lst : True
结论:
c1.n += 1与c1.t += (7,):类属性原值不变(C.n = 0、C.t = ()),实例字典里各建了一个同名属性。c1.lst += [1]:最阴的一种。列表的+=是__iadd__原地扩展,于是类上那个 list 被真的改成了[1],c2.lst也被污染;同时c1.__dict__里又出现了一个'lst'键,而c1.lst is C.lst为True------即实例属性与类属性此刻指向同一个对象(因为STORE_ATTR把extend后的同一个 list 又存回了实例字典)。- 一句话:先在类对象上造成污染,再在实例字典上叠一层遮蔽,双重伤害。
4. 实验 C-2:字节码层面的解释(dis 实测,Python 3.11.9)
obj.attr = value:
text
1 2 LOAD_NAME 0 (value)
4 LOAD_NAME 1 (obj)
6 STORE_ATTR 2 (attr)
16 LOAD_CONST 0 (None)
18 RETURN_VALUE
obj.attr += 1:
text
1 2 LOAD_NAME 0 (obj)
4 COPY 1
6 LOAD_ATTR 1 (attr)
16 LOAD_CONST 0 (1)
18 BINARY_OP 13 (+=)
22 SWAP 2
24 STORE_ATTR 1 (attr)
34 LOAD_CONST 1 (None)
36 RETURN_VALUE
obj.attr.lst += [1](重点看 += 的落点):
text
1 2 LOAD_NAME 0 (obj)
4 LOAD_ATTR 1 (attr)
14 COPY 1
16 LOAD_ATTR 2 (lst)
26 LOAD_CONST 0 (1)
28 BUILD_LIST 1
30 BINARY_OP 13 (+=)
34 SWAP 2
36 STORE_ATTR 2 (lst)
46 LOAD_CONST 1 (None)
48 RETURN_VALUE
结论:
+=不是"读改写类属性"的语法糖:它展开为LOAD_ATTR(读)→BINARY_OP (+=)(原地运算)→STORE_ATTR(写),而STORE_ATTR的目标恒为那个实例对象。- 读的位置由查找链决定(可能读到类属性),写的位置永远是实例------读写不对称,这就是静默遮蔽在字节码层面的根因。
obj.attr.lst += [1]中,LOAD_ATTR attr(拿到类上 list)、LOAD_ATTR lst(拿到该 list)、BINARY_OP +=(原地 extend)、STORE_ATTR lst(把结果存回实例obj的字典)------第 36 行的STORE_ATTR lst目标仍是obj,不是attr对象。
5. 实验 D:为什么 property 拦得住,普通类属性拦不住
代码与实测输出:
python
class W:
@property
def x(self):
return 1
W().x = 5
text
AttributeError : property 'x' of 'W' object has no setter
__set__ 有无 的实测对照:
text
Desc 实例有 __set__ ? True
普通函数有 __set__ ? False
普通 int 类属性有 __set__ ? False
property 有 __set__ ? True
结论:
- 属性写入会先看类型 (这里是
W)的元数据里该名字是否绑定了带__set__的数据描述符 。property有__set__,于是它接管写入并抛AttributeError;普通类属性(int、str、list 等)没有__set__,写入被直接丢给实例__dict__,于是静默遮蔽。 - 前作 namedtuple 的
_tuplegetter属于同一机制(数据描述符),所以my_car.color = 'green'会报can't set attribute。
6. 实验 D-2:属性查找四层顺序实证
代码与实测(Python 3.11.9 与 3.13.3 输出一致):
text
1) 类字典有值 -> 实例/类字典里的普通值
2) 数据描述符优先于类字典?-> 描述符 __get__ 被调用
经 __set__ 落点 __dict__['_v'] = {'_v': 1}
3) 非数据描述符,实例字典为空时 -> 非数据描述符 __get__
塞进实例字典后 -> 实例字典覆盖
数据描述符存在时读 -> 描述符 __get__ 被调用
(对应代码结构:Base.show 是普通函数 = 非数据描述符;Sub.show = "实例/类字典里的普通值" 演示类字典直取;Desc 同时实现 __get__/__set__ = 数据描述符;NonData 只有 __get__ = 非数据描述符。)
结论------查找顺序为:
text
实例 __dict__ → 类型里的数据描述符 → 类 __dict__ → 非数据描述符(含普通函数)
- 写入(
obj.attr = v)只走:类型数据描述符的__set__(若有)→ 否则实例__dict__。 - 所以"能不能拦住意外写入"的分水岭就是:该名字在类型里是不是数据描述符。
7. 实验 E:__slots__ 把静默遮蔽变成硬报错
代码与实测(3.11.9):
python
class S:
__slots__ = ('x',)
y = 1
s = S()
s.y = 2
text
AttributeError : 'S' object attribute 'y' is read-only
补充:__slots__ 的边界(3.11.9 与 3.13.3 实测)
text
无 __slots__ 子类 -> 可写 n.anything = 123 | 有 __dict__: True | {'anything': 123}
但 n.y = 2 -> "'NoSlots' object attribute 'y' is read-only"
有 __slots__ 子类 -> w.anything = 123 -> AttributeError : 'WithSlots' object has no attribute 'anything'(3.11.9)
(3.13.3 同一条报错文案更长:'WithSlots' object has no attribute 'anything' and no __dict__ for setting new attributes)
结论:
__slots__让"实例属性"只能落进预分配的槽位,因此给类属性名赋实例值会直接报AttributeError------静默变成显式。- 父类写了
__slots__,子类没写就重新拥有__dict__,防线失效(anything可随意写);子类必须也写__slots__ = ()才封死。
8. 实验 F:两种正确的类属性写法(实测)
代码与实测:
python
class G:
v = 1
g = G()
type(g).v = 99
print("type(g).v = 99 -> g.v =", g.v, "| G.v =", G.v)
G.v = 100
print("G.v = 100 -> g.v =", g.v, "| G.v =", G.v)
text
type(g).v = 99 -> g.v = 99 | G.v = 99
G.v = 100 -> g.v = 100 | G.v = 100
补充(继承场景,实测):
text
type(obj) is obj.__class__ : True
type(obj).v = 7 -> obj.v = 7 | Base2.v = 1 | Child.v = 7
结论:
- 想改类属性,必须显式写到类对象上:
type(obj).attr = ...或Cls.attr = ...。 - 在继承链上,
type(obj)命中的是实际运行时类 (Child),不会误改基类(Base2.v仍为 1)。所以类方法/__init__里改类状态推荐type(self).count += 1,比硬编码类名更抗继承。
9. 实验 G:类级计数器------同一段需求,两种命运
代码与实测:
python
class Bad:
count = 0
def __init__(self):
self.count += 1
b1 = Bad()
print("Bad.count =", Bad.count, "| b1.__dict__ =", b1.__dict__)
_ = [Bad() for _ in range(3)]
print("再建 3 个后 Bad.count =", Bad.count)
class Good:
count = 0
def __init__(self):
type(self).count += 1
_ = [Good() for _ in range(3)]
print("Good.count =", Good.count)
text
Bad.count = 0 | b1.__dict__ = {'count': 1}
再建 3 个后 Bad.count = 0
Good.count = 3
结论:Bad 建了 4 个实例,类计数器仍然是 0,每个实例各自揣着一个 count;Good 才是真的在计数。这是遮蔽陷阱最典型的线上翻车形态------不报错、统计恒为 0。
10. 实验 H:可变默认值------dataclass 报错,普通类沉默
代码与实测:
python
from dataclasses import dataclass, field
@dataclass
class D:
items: list = []
text
ValueError : mutable default <class 'list'> for field items is not allowed: use default_factory
python
@dataclass
class D2:
items: list = field(default_factory=list)
x, y = D2(), D2()
x.items.append(1)
print("default_factory=list -> x.items =", x.items, "| y.items =", y.items)
text
default_factory=list -> x.items = [1] | y.items = []
python
class P:
items = []
p1, p2 = P(), P()
p1.items.append(1)
print("普通类 items = [] -> p1.items =", p1.items, "| p2.items =", p2.items, "| P.items =", P.items)
text
普通类 items = [] -> p1.items = [1] | p2.items = [1] | P.items = [1]
结论:同样的"可变默认值"错误,@dataclass 在类定义时就替你拦下了(ValueError,并且直接指出 use default_factory);手写普通类只能靠人自觉------p1.append 就把 P.items 和所有实例一起污染了。
11. 实验 I:ClassVar + mypy 静态捕获
被测代码:
python
from typing import ClassVar
class Counter:
v: ClassVar[int] = 1
def f(c: Counter) -> None:
c.v = 2
mypy 2.3.1 实测输出(默认模式下即报,--strict 输出相同):
text
t.py:7: error: Cannot assign to class variable "v" via instance [misc]
Found 1 error in 1 file (checked 1 source file)
对照组:不给 ClassVar 标注(普通 v: int = 1)时,mypy 2.3.1 实测输出为:
text
Success: no issues found in 1 source file
结论:静态检查只在你显式声明了这是类变量 (ClassVar)时才能替你拦住实例写入。否则 3.11 的 mypy 同样保持沉默------运行时和静态层面对这个陷阱都是"默认不设防"。
12. 实验 J:跨版本报错文案矩阵(全部本机实跑)
| 场景 | 3.10.18 | 3.11.9 | 3.12.10 | 3.13.3 | 3.14.7 |
|---|---|---|---|---|---|
s.y = 2(__slots__ + 类属性同名) |
'S' object attribute 'y' is read-only |
同左 | 同左 | 同左 | 同左 |
W().x = 5(property 无 setter) |
can't set attribute 'x' |
property 'x' of 'W' object has no setter |
同 3.11 | 同 3.11 | 同 3.11 |
不可变类属性遮蔽后 a.version / b.version / Config.version |
2 1 1 |
2 1 1 |
2 1 1 |
2 1 1 |
2 1 1 |
坏计数器 Bad.count |
0 | 0 | 0 | 0 | 0 |
结论:
__slots__报错文案 3.10--3.14 完全一致,可放心作为判定依据。- property 文案在 3.11 起改版 (3.10 是
can't set attribute 'x',3.11+ 是带类名与属性名的长文案)。写兼容性判断正则时要同时覆盖两种。 - 遮蔽行为本身在 3.10--3.14 之间没有任何改变 :5 个版本、5 个版本号,
Config.version始终是 1,坏计数器始终是 0。
13. 防御清单
| 手段 | 写法 | 拦住什么 | 代价 / 边界 |
|---|---|---|---|
| 显式写类 | type(obj).attr = v / Cls.attr = v |
从前端杜绝误写实例 | 需作者自觉;无报错 |
| 数据描述符 | property / 自定义 __set__ |
拦下所有经实例的写入并报错 | 只对该属性生效 |
__slots__ |
本类 __slots__ = ('x',),子类补 __slots__ = () |
意外实例写入报 AttributeError |
子类漏写即失效;失去 __dict__ |
ClassVar |
v: ClassVar[int] = 1 |
mypy 静态报 [misc] |
需类型检查流程;不标注则无效 |
| dataclass | 可变默认值用 field(default_factory=list) |
定义期 ValueError 提前暴露 |
仅 dataclass |
| 诊断 | 'attr' in vars(obj)、obj.__dict__ |
事后定位是否被遮蔽 | 非预防 |
14. 一句话结论
- 读由查找链决定,写永远落在实例:
obj.attr = v从不改类,只会遮蔽。 - 类属性没变,只是你再也看不见它了;
del掉实例属性就能看见。 - 可变类属性最毒的地方是
.append不产生遮蔽、只污染所有人------连"查实例字典"这招都救不了你。 - 遇到"统计恒为 0""配置莫名其妙不生效",先敲一行
print(obj.__dict__)。