讲个真事儿。
去年我写了一个小型的任务管理模块,里面有个 Task 类,用来表示一个任务。我心想,每个任务都应该有一个标签列表,方便分类。于是我在类里定义了一个类属性:
class Task:
tags = []
def __init__(self, name):
self.name = name
def add_tag(self, tag):
self.tags.append(tag)
看起来没问题。我创建了两个任务:
t1 = Task("写文档")
t2 = Task("改bug")
t1.add_tag("紧急")
print(t2.tags) # 输出什么?
我预期 t2.tags 是空的,因为 t2 还没加过标签。结果它输出 ['紧急']。
我盯着屏幕看了半天,以为是自己眼花了。t1 加标签,怎么 t2 也跟着有了?它们不是两个独立的对象吗?
那天我花了两个小时才彻底搞明白:类属性和实例属性的查找顺序,以及赋值和修改的区别,比我以为的要反直觉得多。
类属性和实例属性,到底有什么区别
先从头说清楚。
在 Python 里,属性分为两种:
- 类属性:定义在类里面、方法外面的属性。它属于类本身,所有实例共享。
- 实例属性:定义在
__init__里,或者通过self.xxx = ...动态添加的属性。它属于每个实例自己。
class Dog:
species = "犬科" # 类属性,所有狗共享
def __init__(self, name):
self.name = name # 实例属性,每只狗独有
访问属性时,Python 的查找顺序是:
- **先找实例自己的
__dict__**(实例属性) - **如果没找到,再找类的
__dict__**(类属性) - 如果还没找到,沿着继承链往上找父类(MRO)
- 最后找不到就抛
AttributeError
这个顺序本身不反直觉。反直觉的地方在于赋值和修改的区别。
赋值:在哪个字典里写,就在哪个字典里读
当你写 self.tags = [...] 时,Python 会在实例的 __dict__ 里创建一个新的键值对。这叫做“实例属性赋值”。
当你写 Task.tags = [...] 时,Python 会在类的 __dict__ 里修改。这叫做“类属性赋值”。
但如果你写 self.tags.append(...),情况就完全不一样了。
self.tags 首先触发查找:先看实例 __dict__ 里有没有 tags。如果没有,就去找类 __dict__。找到了类属性 tags,它是一个列表。然后 .append() 是原地修改这个列表。这个列表是类属性,属于类本身,所有实例共享。
所以 t1 和 t2 的 self.tags 最终指向的是同一个列表对象。t1 往里加东西,t2 看到的自然也是同一个列表。
这就是我踩的坑:我以为 self.tags 是每个实例自己的,但其实它只是一个查找结果。查找找到的是类属性,修改它就是在修改类属性。
用生活场景理解:公共储物柜和私人抽屉
我想到一个比喻。
类属性就像公司里的公共储物柜。所有员工(实例)都可以打开它,往里放东西,或者拿东西。你放进去的东西,别人也能看到。
实例属性就像每个员工工位上的私人抽屉。你自己往里放东西,别人看不到。
当你执行 self.tags.append("紧急") 时,你实际上是在说:“先去我的私人抽屉找 tags,没找到?那去公共储物柜找。找到了?好,往公共储物柜里塞一个‘紧急’。”
而当你执行 self.tags = ["紧急"] 时,你是在说:“在我的私人抽屉里放一个叫 tags 的东西,值是一个新列表。”
前者修改了公共物品,后者创建了私有物品。这就是区别。
可变类属性的坑:列表、字典、集合
这个坑最常出现在列表、字典、集合这些可变对象上。因为它们可以被原地修改,而不是被重新赋值。
class User:
permissions = [] # 危险:所有用户共享这个列表
def add_permission(self, perm):
self.permissions.append(perm)
如果你真的希望每个用户有独立的权限列表,应该这样写:
class User:
def __init__(self):
self.permissions = [] # 每个实例自己的列表
或者,如果你确实需要一个类级别的默认值,但不想共享,可以用 None 作为占位符:
class User:
permissions = None
def add_permission(self, perm):
if self.permissions is None:
self.permissions = [] # 第一次访问时创建实例属性
self.permissions.append(perm)
这样,第一次调用 add_permission 时,会在实例的 __dict__ 里创建一个新的 permissions 列表。之后所有操作都在实例自己的列表上进行,不会影响其他实例。
继承中的查找顺序:比你想的复杂
类属性的查找顺序在单继承时还算直观:先实例,再子类,再父类。
但在多继承时,Python 使用 C3 线性化算法 来确定方法的解析顺序(MRO,Method Resolution Order)。这个顺序有时候很反直觉。
看一个经典的“钻石继承”:
class A:
def who(self):
print("A")
class B(A):
def who(self):
print("B")
class C(A):
def who(self):
print("C")
class D(B, C):
pass
d = D()
d.who() # 输出什么?
如果你以为输出 A,那就错了。输出是 B。
因为 D 的 MRO 是:D → B → C → A → object。
查找 who 方法时,先找 D(没有),再找 B(有),所以输出 B。
这个顺序不是简单的“深度优先”,而是 C3 线性化计算出来的。它的规则是:子类永远在父类前面,多个父类按声明顺序从左到右,但如果有共同的祖先,要保证祖先在多个路径中只出现一次且位置正确。
你可以用 D.__mro__ 查看:
print(D.__mro__)
# (<class '__main__.D'>, <class '__main__.B'>, <class '__main__.C'>, <class '__main__.A'>, <class 'object'>)
这个顺序在属性查找时同样适用。所以如果你在 B 和 C 里都定义了同名的类属性,D 的实例会优先找到 B 的那个。
一个更隐蔽的坑:子类覆盖类属性
假设你有一个基类,定义了一个类属性:
class Base:
config = {"timeout": 30, "retries": 3}
class Child(Base):
pass
然后你修改 Child.config:
Child.config["timeout"] = 60
你以为只改了 Child 的配置?错了。因为 Child.config 先查找 Child 的 __dict__,没找到,去 Base 找,找到了那个字典。然后你原地修改了它。Base 的 config 也被改了。
print(Base.config) # {'timeout': 60, 'retries': 3}
如果你想让 Child 有自己独立的配置,应该重新赋值:
Child.config = {"timeout": 60, "retries": 3}
这样会在 Child 的 __dict__ 里创建一个新的字典,Base 不受影响。
类属性什么时候该用
类属性不是坏东西,用对了地方很方便。以下场景适合用类属性:
- 常量:比如
PI = 3.14159,所有实例共享,不需要修改。 - 共享的不可变配置:比如
species = "犬科"。 - 类级别的计数器:但要注意线程安全和并发问题。
- 单例模式中的实例引用。
但以下场景应该避免用类属性:
- 可变对象作为默认值(列表、字典、集合),除非你明确希望所有实例共享。
- 需要每个实例独立维护的状态。
- 在继承中可能被修改的配置。
如何正确管理属性
一个简单的原则:如果一个属性的值会因实例而异,就放在 __init__ 里作为实例属性。如果一个属性是所有实例共享且不会变的,才放在类里作为类属性。
class Task:
# 类属性:所有任务共享的常量
MAX_RETRIES = 3
def __init__(self, name):
# 实例属性:每个任务独有的
self.name = name
self.tags = []
self.retries = 0
如果你需要在类级别共享一个可变对象,但又希望每个实例可以独立修改,可以用 None 占位,在 __init__ 或首次访问时创建实例属性。
查看 __dict__ 来理解一切
Python 中一切皆对象,类和实例都有自己的 __dict__。查看它们能帮你理解属性到底存在哪里。
class Task:
tags = []
def __init__(self, name):
self.name = name
t = Task("写文档")
print(t.__dict__) # {'name': '写文档'}
print(Task.__dict__) # 包含 tags, __init__ 等
当你访问 t.tags 时,Python 先看 t.__dict__,没有;再看 Task.__dict__,有,返回那个列表。
当你执行 t.tags.append("紧急"),t.__dict__ 依然只有 name,Task.__dict__ 里的 tags 被修改了。
当你执行 t.tags = ["紧急"],t.__dict__ 里多了一个 tags 键,之后 t.tags 就优先用这个了。
多重继承中的 super() 也依赖 MRO
super() 的行为也由 MRO 决定。它不是在找“父类”,而是在 MRO 列表里找“下一个类”。
class A:
def __init__(self):
print("A")
class B(A):
def __init__(self):
super().__init__()
print("B")
class C(A):
def __init__(self):
super().__init__()
print("C")
class D(B, C):
def __init__(self):
super().__init__()
print("D")
d = D()
# 输出顺序:A, C, B, D
因为 D 的 MRO 是 D → B → C → A。super() 从 B 开始,B 的 super() 找到 C,C 的 super() 找到 A。所以初始化顺序是 A → C → B → D。
这个顺序和单继承的直觉完全不同,但它是 C3 线性化的必然结果。
总结
Python 的类属性查找顺序,核心就三句话:
- 查找时:先实例,后类,再父类,按 MRO 顺序。
- 赋值时:
self.x = ...创建实例属性;Class.x = ...修改类属性。 - 修改可变类属性时:如果实例没有自己的同名属性,修改的是类属性,所有实例共享。
记住那个储物柜的比喻:类属性是公共储物柜,实例属性是私人抽屉。查找时先翻自己的抽屉,再翻公共储物柜。直接赋值等于往自己抽屉里放东西,原地修改等于在公共储物柜里动手脚。
我那个任务管理模块后来改成了在 __init__ 里初始化 self.tags = [],所有实例就都独立了。从那以后,每次定义类属性,我都会问自己一句:这个属性,是所有实例共享的,还是每个实例独有的?答案清楚了,坑就绕过去了。