Python 类与面向对象
用 class 把状态与行为绑在类型上:实例化、self、属性查找、组合优于深继承,以及与协议/数据类的分工。
[!info] 关联笔记
Python 类与面向对象
这个概念为什么出现
当业务从“几个函数 + dict”长成有生命周期的实体时,只靠松散数据结构会失守:
- 余额、冻结标志散落在多个函数参数里,不变量靠调用方自觉
- 测试想替换“扣款通道”,却发现逻辑写死了具体实现细节
- 新人读代码找不到“账户允许做什么”的边界
Python 用 class 把状态与行为绑在类型上。它不是唯一组织方式(模块函数、闭包、dataclass、协议都很重要),但 class 是库、框架与领域模型的通用语言。
与 Java 式“先继承树再 implements”不同,Python 日常更强调:
- 实例持有状态
- 方法维护不变量
- 需要协作时靠协议/鸭子类型,而不是强制深继承
[!abstract] 一句话理解
class创建类型对象;调用类型得到实例;方法经绑定获得self,在实例命名空间上读写状态;属性查找沿实例 → 类 → 基类(MRO)进行。
最小可运行示例
先把示例放进业务场景,再看代码:
场景:会员账户要充值、消费、冻结,且不变量不能散落
积分商城的账户服务需要:
- 每个用户一份余额(分)
- 风控冻结后禁止出入账
- 扣款不足要失败,不能变成负数
- 单测时能直接构造账户,不连数据库
若只写 deposit(balance, amount) 全局函数,冻结标志与余额的一致性很容易在第三个调用点被忘掉。
# member_account_oop.py
# 业务意图:用类表达账户实体的最小状态机。
# 教学点:
# - __init__ 建立每实例状态;
# - 方法通过 self 读写并守不变量;
# - 组合点:扣款策略可后续替换,不必先上继承树。
from __future__ import annotations
class MemberAccount:
"""会员账户:状态在实例上,规则在方法里。"""
def __init__(self, user_id: str, balance_cents: int = 0) -> None:
# 每开一个账户,就有自己的一份状态(实例命名空间)。
if balance_cents < 0:
raise ValueError("initial balance must be >= 0")
self.user_id = user_id
self.balance_cents = balance_cents
self.frozen = False
def freeze(self) -> None:
# 风控动作:翻转标志;后续出入账统一读这个标志。
self.frozen = True
def deposit(self, amount_cents: int) -> None:
# 业务:充值必须为正,且账户未冻结。
if amount_cents <= 0:
raise ValueError("deposit must be positive")
if self.frozen:
raise RuntimeError("account frozen")
self.balance_cents += amount_cents
def charge(self, amount_cents: int) -> None:
# 业务:消费前检查冻结与余额,避免负余额。
if amount_cents <= 0:
raise ValueError("charge must be positive")
if self.frozen:
raise RuntimeError("account frozen")
if amount_cents > self.balance_cents:
raise ValueError("insufficient funds")
self.balance_cents -= amount_cents
def snapshot(self) -> dict[str, object]:
# 对外只读视图:便于日志/API,不直接暴露可变内部细节。
return {
"user_id": self.user_id,
"balance_cents": self.balance_cents,
"frozen": self.frozen,
}
def main() -> None:
acc = MemberAccount("u1")
acc.deposit(1000)
acc.charge(300)
print(acc.snapshot())
acc.freeze()
try:
acc.charge(10)
except RuntimeError as exc:
print("blocked:", exc)
# 类本身也是对象:可以打印类型、动态查方法。
print("type:", type(acc).__name__, "callable charge?", callable(acc.charge))
if __name__ == "__main__":
main()
建议运行:
python member_account_oop.py
期望输出:
{'user_id': 'u1', 'balance_cents': 700, 'frozen': False}
blocked: account frozen
type: MemberAccount callable charge? True
结合场景再看四个关注点
- 状态归属清晰:
balance_cents/frozen属于某个账户实例,不是“全局当前用户”。 - 不变量内聚:调用方不必在每个入口重复写冻结检查。
- 失败用异常表达(Python 习惯),调用方用
try/except映射 HTTP/错误码。 - 这是入门骨架:属性遮蔽、三种方法、MRO、dunder、dataclass 在后续专篇展开。
核心概念与准确模型
1. 类对象与实例对象
class 语句执行后产生类对象(可调用)。
MemberAccount("u1") 大致经历:
- 调用类型 → 通常
type.__call__ __new__创建实例(默认)__init__初始化实例命名空间
日常 95% 代码只关心 __init__;不可变单例、ORM 行映射等才会深挖 __new__。
2. self 是什么
- 实例方法的第一个参数,约定名
self - 调用
acc.charge(10)时,解释器做方法绑定,等价于MemberAccount.charge(acc, 10) self不是关键字,但是社区硬约定
3. 属性查找(简化模型)
读 acc.balance_cents:
flowchart LR
A["实例 __dict__"] -->|命中| R[返回]
A -->|未命中| B["类 __dict__"]
B -->|未命中| C["基类按 MRO"]
C -->|未命中| E[AttributeError]
写 acc.x = 1 通常写入实例 __dict__(描述符/slots 例外)。
类属性 vs 实例属性、可变类属性陷阱 → 类属性与实例属性。
4. 方法只是“待绑定的函数”
MemberAccount.charge # function
acc.charge # bound method
这也解释了 @staticmethod / @classmethod 的存在:改变绑定策略。详见 三种方法。
5. 组合、继承与协议
| 手法 | 解决什么 | 风险 |
|---|---|---|
| 组合 | “有一个”协作对象 | 装配稍微啰嗦 |
| 继承 | 差异化扩展已有类型 | 层次深、脆弱基类 |
| 鸭子/Protocol | 按能力协作 | 错误发现偏调用时/检查时 |
Python 工程默认偏好:浅继承 + 组合 + 小协议。
特殊方法让对象接入 len/with/for 等语言结构 → 特殊方法。
6. 与 dataclass 的分工
- 数据载体(字段多、行为少)→
@dataclass - 不变量/状态机强 → 手写类或 dataclass + 方法
- 只要记录不可变 →
frozen=True或 namedtuple
设计动机
Python 把 OOP 设计成“可选而完整”:
- 一切皆对象统一运行时模型(函数、类、模块都是对象)
- 显式
self让绑定可见,避免隐式 this 魔法过深 - 开放属性模型默认可读写,用约定与 property 收紧,而不是强制 private 关键字
- 协议优于名义继承,方便标准库与第三方对象无共同基类地协作
这换来的是:表达快、胶水强;代价是:边界要靠纪律、类型标注与测试补齐。
边界情况与反直觉行为
1. 类体外乱挂属性
acc.nickname = "x" # 默认允许
灵活,但会破坏“类型有哪些字段”的可预测性。公共模型应在 __init__/注解中声明字段;可用 __slots__ 限制(进阶)。
2. 可变类属性当默认列表
在类体写 tags = [] 会让所有实例共享同一列表。正确做法在 __init__ 创建新 list。见类属性篇。
3. __init__ 不是构造的全部
__new__ 负责创建;__init__ 负责初始化。若 __new__ 返回其他类型实例,__init__ 可能不按预期调用。
4. 方法里 self.x = ... 与局部名
self.x = 1 写实例属性;x = 1 只是方法局部名,不会改实例。
5. 哈希与可变对象
默认自定义类实例可哈希(按身份),但若你重载 __eq__ 而不处理 __hash__,可能变成不可哈希,不能进 set/dict 键。
常见误区
[!warning] 常见误区:没有 private 就不是 OOP 错误理解:必须
private关键字才算封装。
正确模型:_name约定 + 模块边界 + property;真隔离靠设计,不靠语法警察。
[!warning] 常见误区:先画五层继承再写业务 错误理解:OOP = 继承树。
正确模型:先实体与不变量,需要扩展再用浅继承或策略组合。
[!warning] 常见误区:把 dict 当对象用到项目后期 错误理解:
user["balance"]永远够用。
正确模型:边界 DTO 可用 dict/TypedDict;核心领域不变量适合类型化对象。
[!warning] 常见误区:一切逻辑塞进上帝类 错误理解:一个 Service 类包打天下。
正确模型:单一职责;账户、订单、通知拆开,用组合协作。
与相邻概念对比
| 概念 | 关系 | 何时用 |
|---|---|---|
| 模块级函数 | 无状态转换、纯计算 | 不需要实例状态时更简单 |
| 闭包 | 轻量携带状态 | 小范围工厂,不需要类型体系 |
| dataclass | 生成样板 | 数据为主 |
| 协议/鸭子类型 | 协作方式 | 跨类型能力,而不是共基类 |
| 继承/MRO | 扩展与混入 | 有清晰“是一种”或 mixin 协作时 |
文字说明:OOP 不是目的,边界清晰的状态与行为才是目的。能用函数说清的不必硬包成类。
工程实践
建模建议
- 先写“这个对象允许的操作”清单,再写字段。
- 不变量放方法或 property setter,不放调用点。
- 对外返回
snapshot()/DTO,避免调用方直接改内部 list。 - 可替换依赖(支付网关、时钟)通过构造注入,便于测试。
测试建议
- 表驱动测余额边界(0、正好扣光、超额)
- 冻结后出入账必须失败
- 不测私有名字,测可观察状态与异常类型
类型标注
def charge(self, amount_cents: int) -> None: ...
公共类补注解;与 类型标注入门、CI 类型检查一起用。
与 Web/服务层
- Handler 调领域对象/服务,不要在 handler 里堆账户规则
- 持久化后重建实体要注意不变量再次校验
可验证实验
实验 1:不变量
构造 MemberAccount("u", 100),charge(100) 后余额为 0;再 charge(1) 应得 ValueError。
实验 2:冻结优先于余额
余额充足但 freeze() 后 deposit/charge 都应 RuntimeError。
实验 3:方法绑定
打印 acc.charge 与 MemberAccount.charge 的 type,确认一个是 bound method、一个是 function。
实验 4(扩展):组合优于继承
新增 Ledger 记录流水,用 self.ledger = Ledger() 组合,而不是 class MemberAccount(Ledger)。
本节总结
- class 解决的是有生命周期的状态 + 行为边界,不是“更高级的语法”。
- 先掌握实例、
self、属性查找与不变量,再进入类属性、三种方法、继承与协议。 - Python OOP 与鸭子类型、模块化、dataclass 是互补工具箱。
自测题
概念题
acc.charge(10)与MemberAccount.charge(acc, 10)关系?- 属性读取时,实例 dict 与类 dict 谁优先?
代码推理题
- 若在
deposit里写balance_cents += amount(无self.),实例余额会变吗?
工程思考题
- 什么时候你应拒绝把逻辑做成类,继续用模块函数?
参考答案
- 经实例访问时发生方法绑定,解释器传入实例作为第一参;二者调用效果等价。
- 简化模型下实例 dict 优先(数据描述符等例外见描述符篇)。
- 不会;那是局部名,与实例属性无关(还可能 UnboundLocalError,取决于是否先读后写)。
- 无长期状态、无复杂不变量、无多态协作需求时——函数更直。
延伸阅读与资料来源
| 资料 | 类型 | 支撑内容 |
|---|---|---|
| Tutorial – Classes | 教程 | 类与实例入门 |
| Data model | 规范 | 对象、方法绑定、特殊方法 |
| Fluent Python(第 2 版) | 书 | 对象模型与协议(进阶) |
笔记元信息
- 角色:原子概念(OOP 总览入口,不替代属性/MRO/dunder 专篇)
- 深度:现象 + 规则 + 设计动机 + 工程边界
- 相关专篇:属性、三种方法、继承、特殊方法、dataclass