前言
很多人学 Python 的类时,第一课听到的是「类属性(class attribute)是所有实例共享的变量」。这句话本身没错,但它只讲了「读」的一面,漏掉了「写」的一面,于是初学者很快就会撞上一个诡异现象:给某个实例改了属性,别的实例却纹丝不动。
根源在于——Python 的类本身也是一个对象,类体里写的名字都存放在这个类对象的__dict__里;而每个实例又有自己的__dict__。属性访问是一次「沿查找链找名字」的过程,属性赋值则是一次「往某个字典里塞名字」的动作。两者方向不同,所以「读」和「写」的结果可以完全不一致。
本文只讲清三件事:类属性和实例属性各自存在哪里、__dict__的查找顺序到底是怎样的、以及「读走类、写归实例」这条铁律。结论依据 Python 官方文档的数据模型(Data Model)与语言参考(Language Reference),代码按 Python 3.8+ 的写法给出。
另外要澄清题面里的一个常见歧义:类属性并不等于 C++ / Java 里的 static 成员。它只是「定义在类命名空间里、不属于某一个实例」的名字;实例在找不到自己的同名属性时会回退到它,仅此而已,没有访问控制,也没有编译期约束。
一、类属性存在哪里:类本身就是一个对象
在 Python 里,class语句执行完会得到一个对象——一个类对象。类体里写的每一条赋值语句,产生的名字都进入这个类对象的命名空间,可以通过类名.__dict__看到。
# 适用于 Python 3.8+
class Circle:
pi = 3.14159 # 类属性:写在类体里,不在任何方法内部
def __init__(self, r):
self.r = r # 实例属性:写在方法里,赋值给 self
print(sorted(k for k in Circle.__dict__ if not k.startswith("__")))
# 推演输出:['pi']
c = Circle(2.0)
print(sorted(k for k in c.__dict__ if not k.startswith("__")))
# 推演输出:['r']要点有两条:
pi在Circle.__dict__里,不在c.__dict__里。类属性「属于类」。r在c.__dict__里。实例属性「属于实例」,每 new 一个实例就有一份新的。
__dict__是一个映射(mapping),Python 3 里它对外暴露成mappingproxy,对类来说只读——你不能直接Circle.__dict__["pi"] = 3去改它,要用Circle.pi = 3。这一点很重要:改类属性要走「属性赋值」这条通道,而不是「改字典」这条通道。
二、属性查找顺序:实例 → 类 → 基类
当写下obj.x时,CPython 并不是只看一个地方。object.__getattribute__()会按下面的顺序找:
- 先在类型里找有没有名叫
x的数据描述符(data descriptor,即定义了__set__或__delete__的对象); - 再找实例自己的
__dict__; - 再找类型里的非数据描述符和普通类属性,找不到就往类的基类(按 MRO 顺序)继续找;
- 全都没有,才轮到
__getattr__()(如果定义了)。 - 仍然没有,抛
AttributeError。
对绝大多数没有描述符的场景,可以把它简化成一句话:实例字典 → 类字典 → 基类字典。
# 适用于 Python 3.8+
class Base:
kind = "base"
class Child(Base):
pass
obj = Child()
print(obj.kind) # 推演输出:base —— Child 没有,往上找到 Base
print("kind" in Child.__dict__) # False —— 不在 Child 自己的字典里
print("kind" in Base.__dict__) # True
print("kind" in obj.__dict__) # False —— 实例字典里也没有上面这段说明:obj.kind取到的值,来源是Base,而不是obj,也不是Child。谁定义、谁拥有,一目了然。
| 访问写法 | 生效的查找范围 | 结果类型 |
|---|
obj.x(读) | 实例__dict__→ 类 → 基类 | 就近命中的那个 |
Cls.x(读) | 类 → 基类(不经过实例字典) | 类或基类里的值 |
obj.x = v(写) | 只写实例__dict__ | 实例属性 |
Cls.x = v(写) | 只写该类自己的__dict__ | 类属性 |
三、「读走类、写归实例」这条铁律
官方数据模型里有一句非常关键的话:属性赋值和删除只更新实例的字典,绝不会更新类的字典。这就是「读走类、写归实例」的出处。
# 适用于 Python 3.8+
class Circle:
pi = 3.14159
def __init__(self, r):
self.r = r
c1 = Circle(1.0)
c2 = Circle(2.0)
print(c1.pi, c2.pi, Circle.pi) # 推演输出:3.14159 3.14159 3.14159
c1.pi = 3.0 # 这一步不是「改类属性」
print(c1.pi, c2.pi, Circle.pi) # 推演输出:3.0 3.14159 3.14159
print("pi" in c1.__dict__) # True —— c1 自己长出了一个 pi
print("pi" in c2.__dict__) # False
Circle.pi = 3.14 # 这才是改类属性
print(c1.pi, c2.pi, Circle.pi) # 推演输出:3.0 3.14 3.14最后一行值得盯住:用类名改了pi之后,c1.pi依旧是3.0,因为c1已经在第一步就把自己那份pi写进了实例字典,从此查找在第一步就命中了,再也不会去碰类。而c2的实例字典里没有pi,它还老老实实地回退到类,于是看到了新值3.14。
这就是新手最常见的一类「改了不生效」或「改了影响一片」的报错来源。记住判断方法:写完之后立刻看obj.__dict__里有没有这个名字——有,就是实例属性;没有,就还在读类。
四、什么时候该用类属性
类属性适合放「对这个类的所有实例都成立、且不需要每个实例各存一份」的东西:
- 常量与枚举值,例如
HttpStatus.OK = 200; - 所有实例默认共享的配置,例如
default_encoding = "utf-8"; - 需要被所有实例共读的统计量(但写的时候要特别小心,见下节)。
反过来说,凡是每个实例应当各有一份的状态,都要在__init__里赋给self,不要图省事写成类属性。
# 适用于 Python 3.8+
class HttpStatus:
OK = 200
NOT_FOUND = 404
class Ticket:
total = 0 # 想要全局计数,就必须通过类名改
def __init__(self):
Ticket.total += 1 # 注意是 Ticket.total,不是 self.totalTicket.total += 1如果写成self.total += 1,就会变成「读的时候回退到类拿到 0,写的时候新建一个实例属性total」——类上的total永远是 0。这是本节最值钱的一句话。
常见坑点
1. 以为self.x一定读的是实例属性。
❌ 认为self.pi就是「我这个实例的 pi」,于是疑惑为什么c2.pi变了而c1.pi没变。 ✅ 记住读操作是回退查找:先实例、后类、再基类,就近命中才算数。
2. 以为实例属性能被别的实例共享。
❌c1.pi = 3.0之后期待c2.pi也是3.0。 ✅self.x = v只写当前实例的字典,别的实例看不到;要全体共享必须写Cls.x = v。
3. 以为Cls.x = v会改掉某个实例。
❌ 认为Circle.pi = 3.14能连带把c1已经写进实例字典的pi也更新掉。 ✅ 类字典和实例字典是两份数据;实例字典里一旦有同名项,就会挡住类属性。
4. 用hasattr或__dict__判断「类上有没有这个属性」。
❌if "kind" in obj.__dict__来判断类属性是否存在——实例字典里根本没有它,判断永远为假。 ✅ 判断类属性要走vars(Cls)或Cls.__dict__,判断实例属性才用vars(obj)。
5. 在方法里直接写裸名访问类属性。
❌ 在方法体里写return pi * r ** 2,运行时报NameError。 ✅ 方法的作用域不会自动带上类的命名空间,要写Circle.pi或type(self).pi。
6. 把类名写死,导致继承后读到的还是父类的值。
❌ 在需要跟随子类的场合硬写Circle.pi。 ✅ 想跟随实际类型时用type(self).pi,这样SubCircle只需覆盖一次即可。
7. 以为del obj.x删掉的是类属性。
❌del c2.pi之后以为Circle.pi会消失。 ✅del只作用于实例字典;实例里没有这个属性时直接抛AttributeError。要删类属性得写del Circle.pi。
总结
| 问题 | 结论 |
|---|
| 类属性存在哪里 | 类对象的__dict__(对外是只读的mappingproxy) |
| 查找顺序 | 实例__dict__→ 类 → 基类(描述符另有一段前置规则) |
obj.x读到谁 | 就近命中,通常是实例属性,其次类属性 |
obj.x = v写到哪里 | 只写实例__dict__,不碰类 |
Cls.x = v写到哪里 | 只写该类自己的__dict__,不改父类也不改实例 |
| 实例被谁「挡住」 | 一旦实例字典有同名项,类属性就再也不会被读到 |
一句话收尾:读是「一路向上找」,写是「只在原地放」。只要把这句话对应到__dict__这一层,类属性与实例属性的所有迷惑现象都能自洽解释。