
讲个真事儿。
去年我写了一个小型的任务管理模块,里面有个 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__**(类属性)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() 也依赖 MROsuper() 的行为也由 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 的类属性查找顺序,核心就三句话:
self.x = ... 创建实例属性;Class.x = ... 修改类属性。记住那个储物柜的比喻:类属性是公共储物柜,实例属性是私人抽屉。查找时先翻自己的抽屉,再翻公共储物柜。直接赋值等于往自己抽屉里放东西,原地修改等于在公共储物柜里动手脚。
我那个任务管理模块后来改成了在 __init__ 里初始化 self.tags = [],所有实例就都独立了。从那以后,每次定义类属性,我都会问自己一句:这个属性,是所有实例共享的,还是每个实例独有的?答案清楚了,坑就绕过去了。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。