ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

什么是python类属性

什么是python类属性

前言


很多人学 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']

要点有两条:



  1. pi在Circle.__dict__里,不在c.__dict__里。类属性「属于类」。

  2. r在c.__dict__里。实例属性「属于实例」,每 new 一个实例就有一份新的。


__dict__是一个映射(mapping),Python 3 里它对外暴露成mappingproxy,对类来说只读——你不能直接Circle.__dict__["pi"] = 3去改它,要用Circle.pi = 3。这一点很重要:改类属性要走「属性赋值」这条通道,而不是「改字典」这条通道。


二、属性查找顺序:实例 → 类 → 基类


当写下obj.x时,CPython 并不是只看一个地方。object.__getattribute__()会按下面的顺序找:



  1. 先在类型里找有没有名叫x的数据描述符(data descriptor,即定义了__set__或__delete__的对象);

  2. 再找实例自己的__dict__;

  3. 再找类型里的非数据描述符和普通类属性,找不到就往类的基类(按 MRO 顺序)继续找;

  4. 全都没有,才轮到__getattr__()(如果定义了)。

  5. 仍然没有,抛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.total

Ticket.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__这一层,类属性与实例属性的所有迷惑现象都能自洽解释。




RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进