前言
「怎么访问类变量」「怎么访问实例变量」听起来像入门问题,但真正让人翻车的不是「读」,而是读写的方向不一致:读的时候 Python 会沿着实例 → 类 → 基类一路向上找,写的时候却只会往实例自己的字典里塞。你写的两个看起来对称的表达式,可能一个改了全局、一个只改了眼前。
更隐蔽的是可变类变量:把一个list或dict直接写成类变量,然后某个实例对它append,结果所有实例都跟着变——因为那个list只有一份,实例们共享的是同一个对象引用。
本文只讲读写行为这一面:读类变量与实例变量各自的写法、写操作到底落在谁身上、可变类变量为什么会被所有实例共享、以及__slots__与类变量如何相互影响。代码按 Python 3.8+ 给出。
一、读:三种写法,去三个地方找
读取时,Python 的规则是「就近命中」:先看实例字典,没有就回退到类字典,再没有就沿基类往上找。
# 适用于 Python 3.8+
class Employee:
company = "示例科技" # 类变量
workdays = 5
def __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.8+
e1 = 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__) # 推演输出:True
print("company" in e2.__dict__) # 推演输出:False第 ② 步之后,e1的实例字典里多了一个company,从此e1.company在第一步就命中,再也看不到类上的值。这就是「读走类、写归实例」的完整表现。
由此还引出一个经典错误:
# 适用于 Python 3.8+
class Counter:
total = 0
def __init__(self):
self.total += 1 # ❌ 读类变量 → 写实例变量,类上的 total 永远是 0
Counter()
Counter()
print(Counter.total) # 推演输出:0self.total += 1会被拆成「读self.total(从类拿到 0)」和「写self.total = 1(写进实例字典)」两步,于是类上的计数器纹丝不动。要真的计数,必须写Counter.total += 1。
三、可变类变量的共享陷阱
如果类变量是不可变对象(数字、字符串、元组),「共享」通常无害——你改的时候是新建一个值,影响范围自己说了算。但如果类变量是list、dict、set这类可变对象,共享就会变成陷阱:所有实例看到的是同一个对象。
# 适用于 Python 3.8+
class Team:
members = [] # ❌ 可变的类变量
a = Team()
b = Team()
a.members.append("小明") # 原地修改,改的是类里那个 list
print(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.8+
class Coord:
__slots__ = ("x", "y") # 只允许这两个实例属性
dim = 2 # 普通类变量,dim 不在槽里,没有任何冲突
def __init__(self, x, y):
self.x = x
self.y = y
p = Coord(1, 2)
print(p.x, p.dim) # 推演输出:1 2
print(hasattr(p, "__dict__")) # 推演输出:False —— 没有实例字典了
try:
p.z = 3
except 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__ |
一句话收尾:读是回退查找,写是看「赋值给谁」;而可变的类变量因为只有一份对象,任何原地修改都会被所有实例看见——把这两条记牢,类变量与实例变量的所有怪现象就都有了解释。