Python 实例赋值遮蔽类属性:一个从不报错的静默陷阱

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__ 依然为空;污染面覆盖 r1r2Registry(三者看到同一个 list)。静默、无报错、跨实例扩散。
  • r1.items = ['y']重新绑定 ,只在 r1.__dict__ 建实例属性;r2Registry 不受影响,此时 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 += 1c1.t += (7,):类属性原值不变(C.n = 0C.t = ()),实例字典里各建了一个同名属性。
  • c1.lst += [1]:最阴的一种。列表的 +=__iadd__ 原地扩展,于是类上那个 list 被真的改成了 [1]c2.lst 也被污染;同时 c1.__dict__ 里又出现了一个 'lst' 键,而 c1.lst is C.lstTrue------即实例属性与类属性此刻指向同一个对象(因为 STORE_ATTRextend 后的同一个 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,每个实例各自揣着一个 countGood 才是真的在计数。这是遮蔽陷阱最典型的线上翻车形态------不报错、统计恒为 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__)
相关推荐
做运维的阿瑞1 小时前
Python 标准库汇总:分类速览与常用模块清单
linux·运维·python
why-geo1 小时前
Hermes Agent 与 Python 的会产生什么样的碰撞
人工智能·python·ai编程
正经教主1 小时前
【FDE系列】阶段2:Day 29:FastAPI 进阶 — Pydantic 模型与完整 CRUD 实战
人工智能·python·fde
gCode Teacher 格码致知1 小时前
Pandas教学-6:coerce详解
python·pandas
梦在远山后1 小时前
Electron 与 FastAPI 如何完成流式 Agent 对话
python·langchain·agent
大衛說1 小时前
12 · 文件 I/O 与序列化
python
小静AI工程实验室2 小时前
Python 爬虫中文乱码排查:严格解码、UTF-8 BOM 与 JSON 转义的 12 项实验
字符编码·爬虫·python
天天被压力2 小时前
【跨市场数据实战 #08】可转债折价机会怎么筛:3个接口抓比价、列表和实时盘口
java·人工智能·python
weixin199701080162 小时前
[特殊字符]️《从0到1搭多平台二手ERP中台:闲鱼+淘宝+京东+拼多多+Mercari统一调度》(附Python源码)
python