「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)),并不会触发"实例遮蔽"的赋值分支,所有实例修改的其实是同一个底层对象------这正是很多"线上事故"的起点。
三、怎么用
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 本身没有发生赋值动作,所以查找顺序里"实例遮蔽"那一支根本没被触发------cart1、cart2 从头到尾都在读写类变量 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.append、dict[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__ 只负责初始化,这个区分在单例模式、不可变对象场景里会变得很关键。