
前言「怎么访问类变量」「怎么访问实例变量」听起来像入门问题但真正让人翻车的不是「读」而是读写的方向不一致读的时候 Python 会沿着实例 → 类 → 基类一路向上找写的时候却只会往实例自己的字典里塞。你写的两个看起来对称的表达式可能一个改了全局、一个只改了眼前。更隐蔽的是可变类变量把一个list或dict直接写成类变量然后某个实例对它append结果所有实例都跟着变——因为那个list只有一份实例们共享的是同一个对象引用。本文只讲读写行为这一面读类变量与实例变量各自的写法、写操作到底落在谁身上、可变类变量为什么会被所有实例共享、以及__slots__与类变量如何相互影响。代码按 Python 3.8 给出。一、读三种写法去三个地方找读取时Python 的规则是「就近命中」先看实例字典没有就回退到类字典再没有就沿基类往上找。# 适用于 Python 3.8class Employee:company 示例科技 # 类变量workdays 5def __init__(self, name):self.name name # 实例变量e1 Employee(小明)e2 Employee(小红)print(e1.company) # 推演输出示例科技 —— 实例没有回退到类print(e2.company) # 推演输出示例科技print(Employee.company) # 推演输出示例科技 —— 直接查类print(e1.name, e2.name) # 推演输出小明 小红 —— 各是各的三种写法的差异可以用一张表看清楚写法查找范围适用场景self.company方法内实例字典 → 类 → 基类希望「实例可覆盖、否则用默认」Employee.company该类 → 基类明确要类上的那个值不接受实例覆盖e1.company外部实例字典 → 类 → 基类普通读取在方法内部尤其要注意如果你在父类的方法里写死Employee.company子类即使覆盖了company这个方法也还是读到父类的值。想让方法跟随实际类型就用type(self).company。二、写什么写法改类、什么写法建实例写操作只有一条规则赋值的对象决定写到哪里。# 适用于 Python 3.8e1 Employee(小明)e2 Employee(小红)# ① 通过类名写改的是类变量所有没被覆盖的实例都会看到Employee.company 新公司print(e1.company, e2.company, Employee.company) # 推演输出新公司 新公司 新公司# ② 通过实例写创建 / 修改的是实例变量不碰类e1.company 只属于小明的公司print(e1.company, e2.company, Employee.company) # 推演输出只属于小明的公司 新公司 新公司print(company in e1.__dict__) # 推演输出Trueprint(company in e2.__dict__) # 推演输出False第 ② 步之后e1的实例字典里多了一个company从此e1.company在第一步就命中再也看不到类上的值。这就是「读走类、写归实例」的完整表现。由此还引出一个经典错误# 适用于 Python 3.8class Counter:total 0def __init__(self):self.total 1 # ❌ 读类变量 → 写实例变量类上的 total 永远是 0Counter()Counter()print(Counter.total) # 推演输出0self.total 1会被拆成「读self.total从类拿到 0」和「写self.total 1写进实例字典」两步于是类上的计数器纹丝不动。要真的计数必须写Counter.total 1。三、可变类变量的共享陷阱如果类变量是不可变对象数字、字符串、元组「共享」通常无害——你改的时候是新建一个值影响范围自己说了算。但如果类变量是list、dict、set这类可变对象共享就会变成陷阱所有实例看到的是同一个对象。# 适用于 Python 3.8class Team:members [] # ❌ 可变的类变量a Team()b Team()a.members.append(小明) # 原地修改改的是类里那个 listprint(b.members) # 推演输出[小明] —— b 也看到了print(a.members is b.members) # 推演输出True —— 根本是同一个对象# ✅ 正确做法每个实例各建一份class Team2:def __init__(self):self.members [] # 实例变量各是各的x Team2()y Team2()x.members.append(小明)print(y.members) # 推演输出[]判断要点看你是「重新绑定」还是「原地修改」。self.members [...]是重新绑定写进实例字典各不影响self.members.append(...)是原地修改self.members先回退读到类上那个 list然后就地改它于是所有实例一起中招。同一个坑还有另一种形态用字典当「注册表」。如果它是类变量注册项会在不同的测试、不同的实例之间串味。凡是「每个实例应当各有一份」的可变状态一律在__init__里创建。四、__slots__ 与类变量的关系__slots__的作用是声明实例允许拥有的属性并阻止自动创建__dict__从而省下每个实例的一份字典开销。它和类变量的关系有两个必须记住的点。第一__slots__里列出的名字会变成类一级的描述符官方文档的说法是「__slots__通过为每个变量名创建描述符来实现」。因此你不能再用同名的类变量给这个实例属性设默认值——那样只会让类变量把描述符覆盖掉赋值行为随之失效。第二__slots__只约束「实例属性」类变量照常可以存在只要名字不与槽名冲突# 适用于 Python 3.8class Coord:__slots__ (x, y) # 只允许这两个实例属性dim 2 # 普通类变量dim 不在槽里没有任何冲突def __init__(self, x, y):self.x xself.y yp Coord(1, 2)print(p.x, p.dim) # 推演输出1 2print(hasattr(p, __dict__)) # 推演输出False —— 没有实例字典了try:p.z 3except AttributeError as exc:print(拒绝, exc) # 推演输出拒绝Coord object has no attribute z还要注意继承上的两个细节如果父类没有定义__slots__子类实例照样会有__dict__如果子类自己不定义__slots__它也会重新获得__dict__。想让整条继承链都保持「无字典」每一层都得声明__slots__。写法结果__slots__ (x, y)实例只允许x、y没有__dict____slots__ xy字符串被当作可迭代对象等价于两个槽x、y槽名同名再写类变量类变量覆盖描述符槽的赋值行为失效子类不写__slots__子类实例重新拥有__dict__类变量名不在槽里互不影响可以正常读写常见坑点1. 用实例名给类变量「赋值」以为改了全局。❌ 在方法里写self.total 1做全局计数。 ✅ 想改类变量就用类名Counter.total 1写self.total只会新建一个实例属性。2. 把可变对象直接写成类变量。❌members []当默认容器一处append处处见效。 ✅ 需要实例各一份的可变状态一律在__init__里self.members []。3. 用类名改类变量后疑惑为什么有的实例没变。❌Employee.company 新公司之后发现e1.company还是旧值。 ✅ 因为e1的实例字典里已有同名项挡住了类属性检查company in e1.__dict__即可确认。4.__slots__里同时写了槽名和同名类变量。❌__slots__ (x,)又写x 0想给默认值结果槽的赋值约束被覆盖。 ✅ 默认值应当在__init__里赋给self.x槽名不要再用类变量定义一次。5.__slots__写成了字符串。❌__slots__ (x)其实这不是元组而是字符串x多字符时会按字符逐个建槽。 ✅ 单元素元组要写逗号__slots__ (x,)或者直接用__slots__ [x, y]。6. 子类没写__slots__以为能保持无__dict__。❌ 父类有__slots__就以为整个继承链都省内存。 ✅ 子类会重新获得__dict__要么子类也声明__slots__只列新增字段要么接受字典开销。7. 对实例执行del以为删掉的是类变量。❌del e1.company之后以为类上的company消失了。 ✅del只作用于实例字典实例里没有该属性时会直接抛AttributeError。总结操作写法落点读类变量Cls.name或self.name未覆盖时类字典读实例变量self.name或obj.name实例字典改写类变量Cls.name v该类自己的字典新建实例变量self.name v该实例的字典原地改可变类变量self.name.append(v)类里的那个对象全实例共享声明实例属性集合__slots__生成类一级描述符去掉__dict__一句话收尾读是回退查找写是看「赋值给谁」而可变的类变量因为只有一份对象任何原地修改都会被所有实例看见——把这两条记牢类变量与实例变量的所有怪现象就都有了解释。