Python 类属性与实例属性
类属性与实例属性的存放位置、读取查找顺序与写入遮蔽;可变类属性共享陷阱,以及配置默认值与每请求状态的正确分层。
[!info] 关联笔记
Python 类属性与实例属性
这个概念为什么出现
服务代码里两类状态经常被混放:
- 所有实例共享的默认策略:超时秒数、服务名前缀、功能开关默认值
- 每个实例自己的状态:连接 ID、本请求标签、累计重试次数
放错层会出现经典事故:
- 你只想给客户端 A 加一个 tag,结果客户端 B 的 tag 列表也变了
- 你以为改了“默认超时”,其实只遮蔽了某一个实例上的名字
- 读属性时“有时读到类上的值,有时读到实例上的值”,调试像在抓鬼
根因不是语法难,而是没分清:名字写在谁的命名空间里,读的时候先看谁。
[!abstract] 一句话理解 类属性存在类对象命名空间、默认被实例共享;实例属性存在实例
__dict__(或 slots)。读取大致先实例后类;对实例赋值通常只遮蔽,不改类上的原值。可变对象作类属性时,原地修改会影响全部实例。
最小可运行示例
先把示例放进业务场景,再看代码:
场景:HTTP 客户端默认超时共享,每请求标签必须隔离
计费服务与搜索服务各自创建一个 ApiClient:
- 默认超时
3s应共享,改类默认应影响尚未遮蔽的实例 - 每个客户端的
request_tags必须独立 - 有人图省事在类体写了
shared_tags = [],这是生产级陷阱
# client_attrs_demo.py
# 业务意图:演示共享默认配置 vs 每实例状态 vs 可变类属性泄漏。
# 教学点:
# - 类属性共享读取;
# - 实例属性独立;
# - 可变类属性原地修改会串味;
# - 实例赋值遮蔽类属性。
from __future__ import annotations
class ApiClient:
# 类属性:共享默认策略(不可变/标量更安全)
default_timeout_s = 3
service_prefix = "api"
# 反例:可变类属性——所有实例共享同一 list 对象
shared_tags: list[str] = []
def __init__(self, name: str) -> None:
# 实例属性:每客户端独立
self.name = name
self.request_tags: list[str] = []
def label(self) -> str:
# 读取:name 在实例;default_timeout_s 通常落到类
return f"{self.service_prefix}:{self.name}:timeout={self.default_timeout_s}"
def main() -> None:
billing = ApiClient("billing")
search = ApiClient("search")
print("labels:", billing.label(), "|", search.label())
# 1) 每实例列表互不影响
billing.request_tags.append("req-billing")
print("billing tags:", billing.request_tags)
print("search tags:", search.request_tags)
# 2) 踩坑:改 shared_tags 影响全部实例
billing.shared_tags.append("leak")
print("search.shared_tags:", search.shared_tags)
print("same list object?", billing.shared_tags is search.shared_tags is ApiClient.shared_tags)
# 3) 实例赋值遮蔽类属性,不修改类上的默认值
billing.default_timeout_s = 10
print("billing timeout:", billing.default_timeout_s)
print("search timeout:", search.default_timeout_s)
print("class timeout:", ApiClient.default_timeout_s)
# 4) 真正改共享默认:写类
ApiClient.default_timeout_s = 5
print("after class change -> search:", search.default_timeout_s, "billing(shadowed):", billing.default_timeout_s)
if __name__ == "__main__":
main()
建议运行:
python client_attrs_demo.py
期望输出:
labels: api:billing:timeout=3 | api:search:timeout=3
billing tags: ['req-billing']
search tags: []
search.shared_tags: ['leak']
same list object? True
billing timeout: 10
search timeout: 3
class timeout: 3
after class change -> search: 5 billing(shadowed): 10
结合场景再看四个关注点
request_tags在__init__新建:每实例一壳。shared_tags在类体创建一次:全员共享,原地append串味。billing.default_timeout_s = 10:写的是 billing 自己的属性,类仍是 3(随后我们改类为 5)。- 已被遮蔽的实例不再跟随类默认变更。
核心概念与准确模型
1. 两个命名空间
| 位置 | 典型存放 | 生命周期 |
|---|---|---|
| 类对象 | 方法、默认配置、常量、共享统计(慎) | 随类 |
| 实例对象 | 每实体状态 | 随实例 |
类体执行时定义的名字进入类命名空间;__init__ 里 self.x = ... 进入实例命名空间。
2. 读取顺序(简化,日常够用)
对 obj.attr:
- 若
attr是数据描述符(如带__set__的 property)→ 走描述符 - 否则看实例
__dict__ - 再看类与基类(MRO)上的类属性/非数据描述符(函数在此变成绑定方法)
完整链路见 描述符与属性访问。
flowchart TD
R["读取 obj.attr"] --> D{数据描述符?}
D -->|是| G[描述符 __get__]
D -->|否| I{实例 dict 有 attr?}
I -->|是| V[返回实例值]
I -->|否| C[类/基类 MRO]
C -->|找到| V2[返回类属性或绑定方法]
C -->|没有| E[AttributeError]
3. 写入规则(简化)
obj.attr = value:通常写实例(若 attr 是数据描述符则走__set__)Cls.attr = value:写类命名空间obj.attr += x:先读后写;对可变类属性可能是原地改共享对象,对不可变则是遮蔽
4. 可变 vs 不可变类属性
| 类属性类型 | 风险 |
|---|---|
int/str/bool/None/tuple | 实例赋值会遮蔽;共享读通常安全 |
list/dict/set | 原地修改所有实例可见——高频 bug |
| 自定义可变对象 | 同上 |
这与“可变默认参数”是同一家族错误:定义时创建一次,后续共享。
5. 查看真相的工具
obj.__dict__ # 实例命名空间(无 slots 时)
Cls.__dict__["name"] # 类原始字典映射
type(obj).attr # 强制从类读
vars(obj)
设计动机
Python 选择开放、显式的命名空间模型:
- 类也是对象,天然能挂共享数据
- 不强制字段声明(除非 dataclass/slots/注解约定),换取快速建模
- 读取回落类属性,让默认值写法轻量
代价是:共享与遮蔽规则必须内化为肌肉记忆,否则动态性会反噬。
边界情况与反直觉行为
1. += 对 list 类属性
class A:
xs = []
a = A()
a.xs += [1] # 原地扩展共享 list,且可能同时写回实例名(实现细节敏感)
稳妥做法:不要用可变类属性;实例里创建新 list。
2. __slots__
启用 slots 的实例可能没有 __dict__,不能随意挂新属性;类属性仍在类上。
3. 与 property 叠加
@property 是数据描述符,读写都可能不走“普通实例 dict 优先”的朴素直觉。
4. 继承
子类会通过 MRO 看到父类属性;子类写同名类属性会遮蔽父类类属性。实例遮蔽仍只影响该实例。
5. 方法也是类属性
函数存在类上,经实例访问变成 bound method。所以“类属性”不只是数据字段。
常见误区
[!warning] 常见误区:在类体写
tags = []当每实例容器 错误理解:每个实例自动得到新 list。
正确模型:类体执行一次;应在__init__里self.tags = []。
[!warning] 常见误区:
obj.x = 1会改所有实例的默认 x 错误理解:改的是类配置。
正确模型:通常只遮蔽该实例;改共享默认应Cls.x = 1。
[!warning] 常见误区:用
is判断“是否还是默认值” 错误理解:身份比较配置很可靠。
正确模型:被遮蔽后身份/值都变了;应用显式字段或 sentinel。
[!warning] 常见误区:把请求级可变状态放类属性“方便统计” 错误理解:类上计数器最省事。
正确模型:并发下要锁/原子;更常见是 metrics 组件注入,而不是裸类属性。
与相邻概念对比
| 概念 | 相似点 | 差别 |
|---|---|---|
| 可变默认参数 | 定义时创建一次 | 发生在函数默认值,不是类体 |
| 模块全局变量 | 共享状态 | 模块级;类属性绑定类型 |
| 单例配置对象 | 共享策略 | 更可测试、可替换 |
| dataclass 字段 | 声明实例字段 | 默认生成到实例,不靠类体可变默认 |
工程实践
放哪里:决策表
| 数据 | 放置 |
|---|---|
| 常量、默认超时、类型级策略 | 类属性(优先不可变) |
| 每连接/每请求/每用户状态 | 实例属性(__init__) |
| 进程级客户端/连接池 | 组合注入或模块单例(有生命周期管理) |
| 需校验的字段 | property / 描述符 |
推荐写法
class ApiClient:
default_timeout_s = 3
def __init__(self, name: str) -> None:
self.name = name
self.request_tags: list[str] = []
测试建议
- 两实例互不污染可变状态
- 实例遮蔽后不受类默认更新影响
- 禁止回归:类体不得出现
= []/= {}业务状态
排查口诀
- 串味?先
is看是不是同一可变对象 - 改了 A 不影响 B?可能只是遮蔽
- 读到旧默认?看实例是否已有同名键
可验证实验
实验 1:独立 list
billing.request_tags.append 后 search.request_tags 仍为空。
实验 2:共享 list
billing.shared_tags.append("x") 后 ApiClient.shared_tags 含 "x"。
实验 3:遮蔽
billing.default_timeout_s = 10 后改 ApiClient.default_timeout_s = 1,billing 仍 10,search 变 1。
实验 4:__dict__
打印 billing.__dict__ 与 ApiClient.__dict__["default_timeout_s"](或 vars),对照遮蔽前后差异。
实验 5(扩展):property
把 timeout 改成 property,观察赋值是否仍“自由遮蔽”。
本节总结
- 类属性服务共享默认;实例属性服务个体状态。
- 读有回落,写常遮蔽;可变类属性原地改是第一杀手。
- 会看
__dict__/类命名空间,属性 bug 会从玄学变成机械步骤。
自测题
概念题
- 为什么
search.shared_tags能看到billing的 append? - 实例赋值与类赋值在“改默认策略”上有何不同?
代码推理题
a.x先读类属性x=1,执行a.x = 2后,b= A(); b.x是多少?
工程思考题
- 你要在类上放一个“全局请求计数”,在多线程 Web 下有什么问题?更好做法?
参考答案
- 它们与类上的
shared_tags是同一 list 对象。 - 实例赋值只遮蔽自己;类赋值改变未遮蔽实例读到的默认。
b.x仍是1(除非你也改了类或 b 自己)。- 竞态与锁;应使用 metrics 组件/原子计数/请求上下文,而不是裸共享可变状态。
延伸阅读与资料来源
| 资料 | 类型 | 支撑内容 |
|---|---|---|
| Tutorial – Classes | 教程 | 类与实例命名空间 |
| Data model | 规范 | 属性访问与描述符插入点 |
| Descriptor HowTo | HOWTO | 完整查找顺序 |
笔记元信息
- 角色:原子概念(OOP 心智关键件)
- 必做实验:可变类属性串味 + 遮蔽
- 后续:描述符解释 property/方法绑定的底层