Python 类属性与实例属性

类属性与实例属性的存放位置、读取查找顺序与写入遮蔽;可变类属性共享陷阱,以及配置默认值与每请求状态的正确分层。

#type / concept #status / growing #tech / dev #resource / python #tech / lang / 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

结合场景再看四个关注点

  1. request_tags__init__ 新建:每实例一壳。
  2. shared_tags 在类体创建一次:全员共享,原地 append 串味。
  3. billing.default_timeout_s = 10:写的是 billing 自己的属性,类仍是 3(随后我们改类为 5)。
  4. 已被遮蔽的实例不再跟随类默认变更。

核心概念与准确模型

1. 两个命名空间

位置典型存放生命周期
类对象方法、默认配置、常量、共享统计(慎)随类
实例对象每实体状态随实例

类体执行时定义的名字进入类命名空间__init__self.x = ... 进入实例命名空间

2. 读取顺序(简化,日常够用)

obj.attr

  1. attr数据描述符(如带 __set__ 的 property)→ 走描述符
  2. 否则看实例 __dict__
  3. 再看类与基类(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 选择开放、显式的命名空间模型:

  1. 类也是对象,天然能挂共享数据
  2. 不强制字段声明(除非 dataclass/slots/注解约定),换取快速建模
  3. 读取回落类属性,让默认值写法轻量

代价是:共享与遮蔽规则必须内化为肌肉记忆,否则动态性会反噬。

边界情况与反直觉行为

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] = []

测试建议

  1. 两实例互不污染可变状态
  2. 实例遮蔽后不受类默认更新影响
  3. 禁止回归:类体不得出现 = [] / = {} 业务状态

排查口诀

  • 串味?先 is 看是不是同一可变对象
  • 改了 A 不影响 B?可能只是遮蔽
  • 读到旧默认?看实例是否已有同名键

可验证实验

实验 1:独立 list

billing.request_tags.appendsearch.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 会从玄学变成机械步骤。

自测题

概念题

  1. 为什么 search.shared_tags 能看到 billing 的 append?
  2. 实例赋值与类赋值在“改默认策略”上有何不同?

代码推理题

  1. a.x 先读类属性 x=1,执行 a.x = 2 后,b= A(); b.x 是多少?

工程思考题

  1. 你要在类上放一个“全局请求计数”,在多线程 Web 下有什么问题?更好做法?
参考答案
  1. 它们与类上的 shared_tags 是同一 list 对象。
  2. 实例赋值只遮蔽自己;类赋值改变未遮蔽实例读到的默认。
  3. b.x 仍是 1(除非你也改了类或 b 自己)。
  4. 竞态与锁;应使用 metrics 组件/原子计数/请求上下文,而不是裸共享可变状态。

延伸阅读与资料来源

资料类型支撑内容
Tutorial – Classes教程类与实例命名空间
Data model规范属性访问与描述符插入点
Descriptor HowToHOWTO完整查找顺序

笔记元信息

  • 角色:原子概念(OOP 心智关键件)
  • 必做实验:可变类属性串味 + 遮蔽
  • 后续:描述符解释 property/方法绑定的底层
创建于 2026/5/20 更新于 2026/7/15