我在 Python 类里直接写了个空列表当属性、想让每个对象都有自己独立的一份,结果给一个对象的列表追加元素、所有对象的列表里竟然都冒出了同一个元素,我对着代码看了半天都不敢相信,最后才搞懂那个列表根本不属于任何一个对象、而是被全体实例共用的同一个
这是一次让我把 Python 里"在类体里写 tags = []"这件事,从"给每个对象一个空列表属性",重新理解成"给整个类创建一个被所有实例共享的列表"的事故。我在类里直接写了个空列表当属性,本意是让每个对象都有自己独立的一份。结果给一个对象的列表 append 一个元素,所有对象的列表里竟然都冒出了同一个元素。我对着代码看了半天都不敢相信——我明明只动了一个对象啊。最后才彻底搞懂:那个列表根本不属于任何一个对象,而是属于这个类、被全体实例共用的同一个。这篇就把这次"改一个对象、全体跟着变"的事故,从头到尾复盘一遍。
故障现场:给一个实例加标签,所有实例都长出了同一个标签
我写了个表示用户的类,想给每个用户一个"标签列表"。图省事,我直接在类体里写了 tags = [],以为这样每 new 一个对象,它就有一个属于自己的空列表。然后我给某个用户 append 一个标签,准备只给他打标。
可诡异的事发生了:我只给 user_a 加了一个标签,打印 user_b、user_c 的 tags,里面竟然也有那个标签。我以为是自己哪里写错了引用,反复检查 append 的对象,确实只动了 user_a。我又怀疑是不是打印代码错了、打成了同一个对象,加了 id() 一看——三个 user 是不同的对象没错,但它们的 tags 属性,id() 竟然完全一样,是同一个列表!直到这时我才反应过来根因——我写在类体里的 tags = [],创建的是一个"类属性":这个列表在类定义的时候就被创建了一次,从此属于这个类本身,所有实例访问 self.tags 时,拿到的都是同一个类属性列表;我给一个实例 append,本质是往这个共享列表里 append,自然全体可见。
# 我最初的写法: 在类体里直接写可变对象当属性
class User:
tags = [] # ★ 这是【类属性】, 类定义时创建一次, 全体共享!
def __init__(self, name):
self.name = name
user_a = User("a")
user_b = User("b")
user_c = User("c")
user_a.tags.append("vip") # 我以为只给 a 打标
print(user_b.tags) # ['vip'] ← b 也有?!
print(user_c.tags) # ['vip'] ← c 也有?!
# 一查 id, 真相大白: 三个实例的 tags 是同一个列表
print(id(user_a.tags) == id(user_b.tags) == id(user_c.tags)) # True
# tags 根本不属于某个 user, 它属于 User 这个类, 大家共用一份
问题被钉死在这个认知错位上:我以为"在类里写 tags = []"是"为每个实例各自准备一个空列表",但它实际是"在类身上挂一个列表,这个列表在类定义时创建一次、被所有实例共享"。self.tags 之所以能取到它,是因为 Python 查找属性时,实例自己没有就去类上找;所有实例自己都没有 tags,于是全都落到了类那一个共享的 tags 上。我对 user_a.tags.append(...) 的理解是"修改 a 自己的列表",可 a 根本没有"自己的列表",它修改的是那个共享的类属性列表;一改,全体实例透过各自的 self.tags 都看到了这个变化。我以为我给每个对象发了一个独立的本子,其实我把一个本子钉在了墙上、让所有人往同一页上写。
第一件事:想明白类属性和实例属性,是住在两个地方的两种东西
把这次事故彻底想清楚,关键是理解Python 里"类属性"和"实例属性"是两种不同的东西:类属性写在类体里、在类定义时创建一次、属于类本身、被所有实例共享;实例属性通常在 __init__ 里用 self.xxx = ... 创建、每创建一个实例就单独创建一份、属于那个实例自己。要每个实例独立拥有一个可变对象,必须把它建成实例属性。
这两者的差别,在"不可变对象"上往往看不出问题,却在"可变对象"上酿成大祸。原因是:读一个类属性,所有实例读到的是同一个值——如果这个值是不可变的(数字、字符串),你想"改"它时(self.x = 5),Python 会在实例上新建一个同名实例属性把类属性遮蔽掉,于是看起来"各改各的",相安无事。但如果这个值是可变的(列表、字典、集合),而你是原地修改它(self.tags.append(...)、self.data[k]=v),你根本没有给 self 赋新值、不会触发遮蔽,你改的就是那个共享的类属性本身——于是全体实例一起遭殃。"赋值会创建实例属性遮蔽类属性"骗过了我,让我以为类属性也是各实例独立的;可"原地修改可变类属性"会戳穿这层假象。
# 对比: 赋值(遮蔽, 看着独立) vs 原地改可变对象(共享, 全体变)
class Demo:
count = 0 # 不可变类属性
items = [] # 可变类属性
d1, d2 = Demo(), Demo()
# ① 赋值: 在实例上新建同名实例属性, 遮蔽类属性 → 看着各自独立
d1.count = 10 # d1 现在有了自己的实例属性 count=10
print(d1.count, d2.count) # 10 0 ← d2 仍读类属性, 没被影响, "正常"
# ② 原地修改可变对象: 没赋新值, 改的是共享的类属性本身 → 全体变
d1.items.append("x") # 没有 d1.items = ..., 改的是类的那个 items
print(d1.items, d2.items) # ['x'] ['x'] ← d2 也变了!
# 验证: count 被遮蔽后各自独立, items 始终是同一个
print(id(d1.items) == id(d2.items)) # True (共享)
# 结论: 可变对象当类属性 + 原地修改 = 全体共享的隐形陷阱
想通这一层,我才明白自己错在哪:我把"在类里定义一个属性"想当然地当成了"每个实例各有一份",而忽略了"写在类体里、定义时执行一次"意味着它属于类、被共享。这和 Python 里另外几个经典坑是同一个内核:可变默认参数(def f(x=[]) 的 [] 在函数定义时创建一次、被所有调用共享)、浅拷贝共享内层——它们都是"一个可变对象在'定义/创建'时只生成了一次,却被多个'使用方'共享"。我这次是"定义时创建一次的列表,被全体实例共享"。每个实例要有独立的可变状态,这份状态就必须在"每次创建实例时"分别创建,而不能在"定义类时"创建一次了事。
第二件事:正解——可变的每实例状态,一律在 __init__ 里创建
找到根因,正解就清晰了:凡是"每个实例应该各自独立拥有"的可变状态(列表、字典、集合),一律在 __init__ 里用 self.xxx = [] 创建——这样每创建一个实例,就单独 new 一份,互不干扰。类体里只放真正想被所有实例共享的、或不可变的常量/配置。
# 错误: 可变对象当类属性, 全体共享
class User:
tags = [] # ✗ 共享, 一个 append 全体变
def __init__(self, name):
self.name = name
# 正解: 每实例独立的可变状态, 在 __init__ 里创建实例属性
class User:
# 类体里只放: 真想共享的、或不可变的常量
MAX_TAGS = 20 # 不可变常量, 共享无妨
def __init__(self, name):
self.name = name
self.tags = [] # ✓ 每个实例各 new 一个独立的空列表
user_a = User("a")
user_b = User("b")
user_a.tags.append("vip")
print(user_a.tags, user_b.tags) # ['vip'] [] ← 各自独立 ✓
print(id(user_a.tags) == id(user_b.tags)) # False ← 不再是同一个
# 用 dataclass 时同理: 可变默认值必须用 field(default_factory=...)
from dataclasses import dataclass, field
@dataclass
class UserDC:
name: str
tags: list = field(default_factory=list) # ✓ 每实例调用 list() 各建一份
# tags: list = [] ← dataclass 会直接报错, 正是在帮你避开这个坑
这套做法的精髓,是把"每个实例应独立的可变状态"放到"每次创建实例都会执行"的 __init__ 里去创建,而不是放到"只在定义类时执行一次"的类体里。判断标准很简单:这个东西是"每个对象各有一份、各改各的"(那就进 __init__ 做实例属性),还是"全类共享一份、所有对象看到同一个"(那才放类体做类属性)。可变的、代表每个对象自身状态的(标签、购物车、缓存),几乎都属于前者。dataclass 的 field(default_factory=...) 更是把这条规则固化进了语法——你给可变默认值,它强制你用工厂函数,正是在替你避开"共享同一个"的坑。不是不能用类属性,而是要分清"该共享的"和"该独立的",把可变的每实例状态放对地方。
【类属性 vs 实例属性, 我现在认死的几条】
1. 类体里 x = [] 是【类属性】, 定义时创建一次, 全体实例共享同一个
2. __init__ 里 self.x = [] 是【实例属性】, 每个实例各创建一份
3. 每实例应独立的可变状态(列表/字典/集合), 一律放 __init__
4. 类体里只放: 真想共享的、或不可变的常量/配置
5. 不可变类属性看着"独立"是假象: 赋值会新建实例属性遮蔽它
6. 原地修改可变类属性(append/[k]=v)会戳穿假象: 全体一起变
7. dataclass 可变默认值必须 field(default_factory=list/dict/set)
第三件事:其他"定义时创建一次、却被多方共享"的同类坑
顺着"一个可变对象在定义时只创建一次、却被多个使用方共享"这条线,我把 Python 里同类的坑都排查了一遍,它们是同一个内核的不同变体:
第一个,可变默认参数。def add(item, lst=[]),那个 [] 在函数定义时创建一次,被所有"没传 lst"的调用共享,上次的元素会留到下次。要用 lst=None 再在函数内 lst = lst or []。
第二个,类属性做缓存/计数被所有实例共写。想给每个实例计数却写成类属性,结果所有实例共用一个计数器,你加我也加,乱成一团。
第三个,用同一个可变对象初始化多份。rows = [[]] * 3 造的是三个指向同一个内层列表的引用,改一行全改;要用 [[] for _ in range(3)]。
第四个,多个对象共享传入的同一个可变参数。def __init__(self, data): self.data = data,如果几个对象传进同一个 list,它们就共享了它;需要独立就 self.data = list(data) 拷一份。
第四件事:类属性 vs 实例属性——一张对照表
我把类属性和实例属性摆在一起对比,核心看"创建时机、归属、是否共享":
| 维度 | 类属性(类体里 x = []) | 实例属性(__init__ 里 self.x = []) |
|---|---|---|
| 创建时机 | 类定义时, 只创建一次 | 每次创建实例时, 各创建一份 |
| 归属 | 属于类本身 | 属于那个实例 |
| 是否共享 | 所有实例共享同一个 | 每个实例独立, 互不影响 |
| 原地改可变对象 | 全体实例一起变(陷阱) | 只改自己那一份 |
| 适合放什么 | 共享常量/配置/真要共享的状态 | 每实例自己的状态(尤其可变) |
| self.x = 新值 | 会新建实例属性遮蔽类属性 | 就是改自己的实例属性 |
看清这张表,放对地方就有谱了:每个实例自己的、尤其是可变的状态,放 __init__ 做实例属性;真正想被所有实例共享的常量或配置,才放类体做类属性。我这次踩坑,就是把一个本该每实例独立的可变列表,错放成了全体共享的类属性。两者住在不同的地方、归属不同、共享性也不同,放错位置,可变对象上就会出"改一个全体变"的事故。
第五件事:我曾经对类属性想当然的几个误区
这次事故也把我对类属性的一堆"想当然"照了个底朝天:
| 我以为 | 实际上 |
|---|---|
| 类里写 tags=[] 就是每个实例一个空列表 | 是类属性, 定义时创建一次, 全体实例共享同一个 |
| self.tags 是每个实例自己的 | 实例自己没有时, self.tags 取的是共享的类属性 |
| 给一个实例 append 只影响它自己 | 原地改的是共享列表, 全体实例透过 self.tags 都看到 |
| 之前类属性赋值各自独立, 列表也一样 | 赋值会遮蔽类属性看着独立; 原地改可变对象则共享 |
| dataclass 里 tags: list = [] 没问题 | dataclass 直接报错, 强制你用 default_factory |
这些误区的根子是同一个:我没分清"这个属性是在'定义类'时创建一次(归类、共享),还是在'创建实例'时各创建一份(归实例、独立)",而把前者当成了后者。"在类里写一行 tags = []" 看起来像是"声明每个对象都有 tags 这个属性",可它实际执行的是"此刻创建一个列表、挂在类身上";这一个列表,要被未来所有实例共用。把"定义时创建一次的共享物"误当成"每个实例独立的私有物",是这一整类共享陷阱的共同根源。
第六件事:写类、排查"改一个对象全体跟着变"时,我现在的自检习惯
现在每当我写类、或排查"明明只改了一个实例、别的实例也跟着变",我都会先按这张图问自己:
这张图的精髓,是"可变对象想每实例独立, 就放 __init__ 创建实例属性; 放类体会被定义时创建一次、全体共享"。设计就把每实例独立的可变状态放 __init__、类体只放共享常量、dataclass 用 default_factory、排查就对'改一个全体变'的属性查 id() 看是不是同一个对象。这套习惯,让我从"在类里随手写 tags=[]"变成了"先想这个东西该共享还是该独立、再决定放哪"——核心始终是:Python 里类属性和实例属性是住在两个地方的两种东西:写在类体里的属性是类属性,它在类定义时被创建一次、属于类本身、被所有实例共享;在 __init__ 里用 self.xxx 创建的是实例属性,每创建一个实例就单独创建一份、属于那个实例自己;这个差别在不可变对象上常被掩盖(因为给 self 赋新值会在实例上新建同名属性遮蔽掉类属性、看起来各自独立),却在可变对象上酿成大祸——因为对可变类属性做原地修改(append、[k]=v)并没有给 self 赋新值、不会触发遮蔽,改的就是那个被全体共享的类属性本身,于是动一个实例、所有实例透过各自的 self 都看到了变化;正解是凡是每个实例应当各自独立拥有的可变状态(列表、字典、集合)一律在 __init__ 里创建成实例属性、类体里只放真正想共享的或不可变的常量配置,用 dataclass 时可变默认值必须用 field(default_factory=...);它和可变默认参数、列表乘法共享引用、浅拷贝共享内层是同一个内核——一个可变对象在"定义/创建"时只生成了一次、却被多个使用方共享。
我立下的几条规矩
这场"改一个对象、全体跟着变"的事故,换来了我写 Python 类时,刻进骨子里的几条铁律:
- 类体里写 x=[] 是类属性,定义时创建一次,被所有实例共享同一个。
- 每个实例应独立的可变状态(列表/字典/集合),一律在 __init__ 里创建。
- 类体里只放:真想被所有实例共享的、或不可变的常量/配置。
- 不可变类属性看着"独立"是假象——赋值会新建实例属性遮蔽它。
- 原地修改可变类属性(append/[k]=v)会戳穿假象,全体一起变。
- dataclass 的可变默认值,必须用 field(default_factory=list/dict/set)。
- 排查"改一个全体变",先查 id() 是不是同一个对象;先分清该共享还是该独立。
附:我现在写类时"该独立放 __init__、该共享放类体"的骨架
这是我现在写 Python 类固定套的骨架——把这次踩坑的教训(可变的每实例状态放 __init__、共享常量放类体、dataclass 用 default_factory)固化成一套结构,让"改一个对象全体变"那种坑再不会埋进代码:
from dataclasses import dataclass, field
# 写法一: 普通类 —— 共享的放类体, 独立的可变状态放 __init__
class ShoppingCart:
# ① 类体: 只放真想被所有实例【共享】的、或【不可变】的常量/配置
CURRENCY = "CNY" # 不可变常量, 所有购物车共用无妨
MAX_ITEMS = 99 # 配置, 共享
def __init__(self, owner):
# ② __init__: 每个实例【独立】的状态, 尤其是可变对象, 全放这里
self.owner = owner
self.items = [] # ✓ 每个购物车各一个独立列表
self.coupons = {} # ✓ 每个购物车各一个独立字典
self.seen = set() # ✓ 每个购物车各一个独立集合
# 写法二: dataclass —— 可变默认值【必须】用 default_factory
@dataclass
class ShoppingCartDC:
owner: str
items: list = field(default_factory=list) # ✓ 每实例 list() 各建一份
coupons: dict = field(default_factory=dict) # ✓ 同理
CURRENCY: str = "CNY" # 不可变默认值, 直接写没问题
# items: list = [] # ✗ dataclass 直接报 ValueError, 替你拦下这个坑
# 自检: 两个实例的可变状态, 必须是不同的对象
a, b = ShoppingCart("a"), ShoppingCart("b")
a.items.append("x")
assert a.items == ["x"] and b.items == [] # 各自独立 ✓
assert id(a.items) != id(b.items) # 不是同一个对象 ✓
这套骨架把我这次的教训钉死在了结构里:类体里只放共享的常量和配置(币种、上限这些所有实例本就该一样的东西)、每个实例独立的可变状态(购物车的商品、优惠券、已见集合)一律放进 __init__ 各建一份;用 dataclass 就靠 field(default_factory=...) 让每个实例各调一次工厂函数。再配一个"两个实例的可变状态 id() 必须不同"的自检断言兜底,就彻底告别了"append 一个、全体冒出来"的诡异。把"分清该共享与该独立、把可变的每实例状态放对地方"这个道理,沉淀成写类的固定骨架,这是我对这次"改一个全体变"最实在的交代——毕竟,每个对象自己的本子,就该在它出生时单独发一本,而不是大家挤在墙上同一页。
写在最后
回头看,这场由"可变对象当类属性"引发的"改一个全体变"事故,真正教给我的,远不止"把列表放进 __init__"这一个技巧。它让我对"当我们'定义'一个东西时创建的某个可变之物,和我们'每次使用'时新建的可变之物,是截然不同的:前者只在'定义那一刻'诞生一次,此后被所有'使用者'共享同一个;后者则随每一次使用各自诞生、彼此独立。把'定义时创建一次的共享物'误当成'每次使用各有一份的独立物',是一个极其隐蔽、却反复出现的认知陷阱",有了一次刻骨的体会。我栽跟头,是因为我把一个"在定义类时创建一次、归属于类、被全体共享"的列表,误当成了"每个实例各有一份、归属于实例、彼此独立"的列表——我在类体里写下 tags = [] 时,脑子里想的是"声明每个 user 都有一个 tags",像是在描述一个"每个对象的字段";可 Python 实际做的,是"此刻就创建一个列表、挂在 User 这个类身上"——它只被创建了这一次,以后所有 user 共用它;而"赋值会遮蔽类属性"这个机制,又恰好在我用不可变值时帮我圆了场、让我误以为类属性也是各实例独立的,直到我对一个可变列表做原地 append,这层假象才被一个"全体跟着变"狠狠戳穿。这让我领悟到一个关于"定义时创建 与 使用时创建、共享 与 独立"的深刻认知:一个可变对象,它的"诞生时机"决定了它的"共享范围":在"定义"处(类体、函数默认参数、模块顶层)创建的,只诞生一次,会被此后所有使用它的地方共享同一个实体;在"每次使用"处(__init__、函数体内、循环体内)创建的,则随每次使用各自诞生、彼此隔离;当我们需要"每个使用者各有独立一份"时,就绝不能把它创建在"定义处"——否则大家拿到的是同一个,一个使用者的原地修改会神不知鬼不觉地波及所有人;这个陷阱之所以隐蔽,是因为它在"只读"或"整体赋值"时安然无恙(读到同一个值无妨、赋值会创建独立的新绑定),只在"原地修改共享的可变对象"时才爆发——而我们的直觉,常常分不清"赋值"和"原地修改"对一个共享对象意味着多么不同的后果。这给了我一种看待"一切'定义一个可变的默认/初始值'之事"时的清醒:每当我在某个"定义处"写下一个可变对象(类体的属性、函数的默认参数、配置的初值)时,要追问"这个对象是只在定义时诞生一次、被所有使用者共享,还是我其实想让每个使用者各有独立的一份?如果想要独立,我是不是把它错放在了只创建一次的定义处"——把"该共享的"放在定义处、把"该独立的可变状态"放在每次使用时创建,绝不让一个本该各自独立的东西,因为创建在了定义处而被全体共享;"分清定义时创建一次的共享物与使用时各建一份的独立物、把可变的每实例状态放对地方",是用对 Python 类属性、也是避开一整类共享陷阱的关键。认清类属性定义时创建一次被全体共享、可变的每实例状态要放 __init__、赋值遮蔽掩盖了共享而原地修改会戳穿它——这,是我用一次"改一个对象全体跟着变"的事故,换来的、关于 Python、也关于如何分清共享与独立的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次在类体里随手写下 tags = [] 时,先停一秒想想"我是想让每个对象各有一个,还是让所有对象共用一个?",并把那个该独立的列表挪进 __init__,那我对着那三个 user 长出同一个标签、对着相同的 id() 发懵的大半天,就值了。
—— 别看了 · 2026