类变量与实例变量:一个共享列表引发的线上事故

「Python 进阶之路」系列 Day11

写在前面

模块二函数式编程系列写完,今天开始模块三面向对象进阶。第一篇讲一个看起来很基础、但几乎人人都踩过的坑:类变量和实例变量分不清楚,尤其是用可变类型(list/dict)当类变量时,本意是"每个实例一份独立数据",结果却是所有实例共享同一份------这跟 Day01 讲的可变默认参数陷阱其实是同一类问题的两种变体。


一、是什么:两种属性,两种归属

  • 类变量(class variable) :直接写在类体内、__init__ 之外的属性,归类本身所有,所有实例共享同一份数据
  • 实例变量(instance variable) :在 __init__(或其他方法)里通过 self.attr = ... 赋值创建的属性,归每个实例自己所有,互不干扰
python 复制代码
class Employee:
    company = "ByteDance"      # 类变量:所有 Employee 实例共享

    def __init__(self, name):
        self.name = name        # 实例变量:每个实例独立一份

二、为什么:属性查找顺序决定了共享关系

instance.attr 的查找顺序很明确:先查实例自己的 __dict__,没找到再沿着类的 MRO 往上查类变量(以及父类变量) ,都没找到才抛 AttributeError。这个顺序解释了类变量"共享"的本质------它只在类的 __dict__ 里存了一份,所有没有同名实例变量的实例,查到的都是这同一份。

类变量存在的意义很直接:给所有实例共享配置、常量、计数器这类"不需要每个实例各存一份"的数据,省内存也表达了语义上的"这是类级别的东西"。问题出在可变类型 上:如果把 list/dict 当类变量、又用原地修改的方式操作它(比如 self.items.append(x)),并不会触发"实例遮蔽"的赋值分支,所有实例修改的其实是同一个底层对象------这正是很多"线上事故"的起点。

flowchart LR A[访问instance.attr] --> B{实例字典里<br/>有该属性吗} B -- 有 --> C[返回实例变量] B -- 没有 --> D[沿着MRO查找<br/>类字典] D --> E[找到类变量就<br/>返回] D --> F[都没找到就报<br/>AttributeError]

三、怎么用

1. 共享与遮蔽:修改类变量 vs 修改实例变量

python 复制代码
e1 = Employee("Alice")
e2 = Employee("Bob")
print(e1.company, e2.company)   # ByteDance ByteDance

Employee.company = "TikTok"       # 修改类变量本身
print(e1.company, e2.company)   # TikTok TikTok ------ 两个实例都变了

e1.company = "Alice's own"        # 注意:这不是"修改"类变量,是给 e1.__dict__ 新增一个同名实例变量
print(e1.__dict__)                # {'name': 'Alice', 'company': "Alice's own"}
print(e1.company, e2.company)   # Alice's own TikTok ------ e1 有了自己的 company,e2 仍读类变量
print(Employee.company)           # TikTok ------ 类变量本身没被 e1 那行改动

instance.attr = value 永远只会操作实例自己的 __dict__,绝不会修改类变量------这也是为什么它叫"遮蔽"而不是"修改"。真正想改类变量,必须通过类名 ClassName.attr = value

2. 事故现场:可变类变量被当成"每个实例独立"来用

python 复制代码
class ShoppingCart:
    items = []              # 本意:给每个购物车一个空列表;实际:所有购物车共享同一个 list 对象

    def add(self, item):
        self.items.append(item)   # 原地修改,不是赋值,不会创建实例变量

cart1 = ShoppingCart()
cart2 = ShoppingCart()
cart1.add("苹果")
print(cart2.items)   # ['苹果'] ------ cart2 什么都没做,购物车里却多了个苹果

关键在 self.items.append(item) 这一行:它是对 items 指向的同一个 list 对象原地修改,self.items 本身没有发生赋值动作,所以查找顺序里"实例遮蔽"那一支根本没被触发------cart1cart2 从头到尾都在读写类变量 ShoppingCart.items 这同一个对象。这跟 Day01 讲的"可变默认参数被多次调用共享"是一模一样的坑,只是共享的载体从函数参数换成了类属性。

修复方式也一样:在 __init__ 里显式赋值,把它变成真正的实例变量:

python 复制代码
class ShoppingCart:
    def __init__(self):
        self.items = []          # 显式在 __init__ 里创建,每个实例都会执行一次,各自独立

    def add(self, item):
        self.items.append(item)

3. 计数器场景:self.count += 1 为什么会废掉计数

类变量做实例计数器是常见的合理用法,但一个很容易犯的错误是用 self 去累加:

python 复制代码
class Employee:
    count = 0

    def __init__(self, name):
        self.name = name
        self.count += 1   # 错误写法

e1 = Employee("Alice")
e2 = Employee("Bob")
print(Employee.count)   # 0 ------ 计数器完全没工作
print(e1.count, e2.count)   # 1 1 ------ 但两个实例上各自"看起来"计数正确

self.count += 1 等价于 self.count = self.count + 1:右边读取时,实例自己没有 count,走查找顺序找到类变量(当时是 0),算出 1;但左边赋值会在当前实例__dict__ 里新建一个 count = 1 的实例变量。从这一行开始,这个实例的 count 就和类变量彻底脱钩了,后续对它的任何 += 1 都只会更新它自己那份,类变量 Employee.count 永远停在 0。正确写法必须显式用类名:

python 复制代码
class Employee:
    count = 0

    def __init__(self, name):
        self.name = name
        Employee.count += 1   # 显式操作类变量,不给实例遮蔽的机会

print(Employee.count)   # 2

4. 怎么快速判断一个属性到底是类变量还是实例变量

python 复制代码
print(e1.__dict__)          # 只包含实例变量
print(type(e1).__dict__)   # 包含类变量、方法等类级别的东西

四、面试追问

Q1:instance.attr 的查找顺序是什么?

先查实例自己的 __dict__,没找到再沿着类的 MRO 往上查类变量及父类变量,都没找到才抛 AttributeError。而 instance.attr = value 这种赋值操作永远只作用于实例的 __dict__,不会修改类变量,哪怕同名的类变量已经存在。

Q2:为什么用可变类型做类变量、又通过原地修改操作它,特别容易出问题?

因为原地修改(list.appenddict[k]=v 等)不涉及对 self.attr 的赋值动作,不会触发查找顺序里"给实例创建同名条目"的那一支,所有实例操作的始终是类变量指向的同一个底层对象。这和 Day01 讲的可变默认参数陷阱是同一类问题的两种变体:本该每个使用者独立一份的可变对象,被无意中共享了。

Q3:计数器场景下 self.count += 1 错在哪,正确写法是什么?

+= 分读和写两步:读的时候走查找顺序拿到类变量的值,但写的时候会在当前实例的 __dict__ 里新建一个同名实例变量,导致这个实例的 count 从此和类变量脱钩,后续操作都只更新它自己那份,类变量本身永远不变。正确写法是显式用类名 ClassName.count += 1,绕开实例遮蔽。

Q4:怎么在代码里快速确认一个属性到底是类变量还是实例变量?

instance.__dict__(只含实例变量)和 type(instance).__dict__(含类变量、方法等类级别成员),二者都没有才会继续往父类的 __dict__ 查(对应 MRO 链)。


下一篇预告

Day12 讲 __new__ vs __init__------对象创建其实分两个阶段,__new__ 负责真正"造出"这个对象,__init__ 只负责初始化,这个区分在单例模式、不可变对象场景里会变得很关键。

相关推荐
Python私教1 小时前
一次性批准令牌:挡住旧授权误发新版本的工程设计
python·安全
Scene2161 小时前
AgentScope 2.0:2. 快速上手 从零构建生产级智能体
后端
前端一课1 小时前
用 TRAE Work 把项目踩坑经验沉淀成「团队可复用工程规范」,新人再也不重复掉坑
前端·后端
for_ever_love__1 小时前
python基础语法学习: 数据容器
windows·python·学习
神奇小汤圆1 小时前
一文吃透 Spring 框架:原理、实践与面试全解析
后端
站大爷IP1 小时前
Python 的切片把我坑惨了,原来 `[:]` 是浅拷贝,而 `copy.deepcopy` 才是我的救命稻草
后端
神奇小汤圆1 小时前
Java 万字长文:从零基础到高级应用的完整教程——把面向对象讲透
后端
aiqianji1 小时前
文风接近真人的AI生成短篇小说软件有哪些?
人工智能·python
孓最求完美1 小时前
实战经验:JT808/JT809 车联网高并发服务端性能优化指南
后端