Python 类与面向对象

用 class 把状态与行为绑在类型上:实例化、self、属性查找、组合优于深继承,以及与协议/数据类的分工。

#type / concept #status / growing #tech / dev #resource / python #tech / lang / python

[!info] 关联笔记

Python 类与面向对象

这个概念为什么出现

当业务从“几个函数 + dict”长成有生命周期的实体时,只靠松散数据结构会失守:

  • 余额、冻结标志散落在多个函数参数里,不变量靠调用方自觉
  • 测试想替换“扣款通道”,却发现逻辑写死了具体实现细节
  • 新人读代码找不到“账户允许做什么”的边界

Python 用 class 把状态与行为绑在类型上。它不是唯一组织方式(模块函数、闭包、dataclass、协议都很重要),但 class 是库、框架与领域模型的通用语言。

与 Java 式“先继承树再 implements”不同,Python 日常更强调:

  1. 实例持有状态
  2. 方法维护不变量
  3. 需要协作时靠协议/鸭子类型,而不是强制深继承

[!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

结合场景再看四个关注点

  1. 状态归属清晰balance_cents/frozen 属于某个账户实例,不是“全局当前用户”。
  2. 不变量内聚:调用方不必在每个入口重复写冻结检查。
  3. 失败用异常表达(Python 习惯),调用方用 try/except 映射 HTTP/错误码。
  4. 这是入门骨架:属性遮蔽、三种方法、MRO、dunder、dataclass 在后续专篇展开。

核心概念与准确模型

1. 类对象与实例对象

class 语句执行后产生类对象(可调用)。
MemberAccount("u1") 大致经历:

  1. 调用类型 → 通常 type.__call__
  2. __new__ 创建实例(默认)
  3. __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

dataclass 与命名元组

设计动机

Python 把 OOP 设计成“可选而完整”:

  1. 一切皆对象统一运行时模型(函数、类、模块都是对象)
  2. 显式 self 让绑定可见,避免隐式 this 魔法过深
  3. 开放属性模型默认可读写,用约定与 property 收紧,而不是强制 private 关键字
  4. 协议优于名义继承,方便标准库与第三方对象无共同基类地协作

这换来的是:表达快、胶水强;代价是:边界要靠纪律、类型标注与测试补齐。

边界情况与反直觉行为

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 不是目的,边界清晰的状态与行为才是目的。能用函数说清的不必硬包成类。

工程实践

建模建议

  1. 先写“这个对象允许的操作”清单,再写字段。
  2. 不变量放方法或 property setter,不放调用点。
  3. 对外返回 snapshot()/DTO,避免调用方直接改内部 list。
  4. 可替换依赖(支付网关、时钟)通过构造注入,便于测试。

测试建议

  • 表驱动测余额边界(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.chargeMemberAccount.chargetype,确认一个是 bound method、一个是 function。

实验 4(扩展):组合优于继承

新增 Ledger 记录流水,用 self.ledger = Ledger() 组合,而不是 class MemberAccount(Ledger)

本节总结

  • class 解决的是有生命周期的状态 + 行为边界,不是“更高级的语法”。
  • 先掌握实例、self、属性查找与不变量,再进入类属性、三种方法、继承与协议。
  • Python OOP 与鸭子类型、模块化、dataclass 是互补工具箱。

自测题

概念题

  1. acc.charge(10)MemberAccount.charge(acc, 10) 关系?
  2. 属性读取时,实例 dict 与类 dict 谁优先?

代码推理题

  1. 若在 deposit 里写 balance_cents += amount(无 self.),实例余额会变吗?

工程思考题

  1. 什么时候你应拒绝把逻辑做成类,继续用模块函数?
参考答案
  1. 经实例访问时发生方法绑定,解释器传入实例作为第一参;二者调用效果等价。
  2. 简化模型下实例 dict 优先(数据描述符等例外见描述符篇)。
  3. 不会;那是局部名,与实例属性无关(还可能 UnboundLocalError,取决于是否先读后写)。
  4. 无长期状态、无复杂不变量、无多态协作需求时——函数更直。

延伸阅读与资料来源

资料类型支撑内容
Tutorial – Classes教程类与实例入门
Data model规范对象、方法绑定、特殊方法
Fluent Python(第 2 版)对象模型与协议(进阶)

笔记元信息

  • 角色:原子概念(OOP 总览入口,不替代属性/MRO/dunder 专篇)
  • 深度:现象 + 规则 + 设计动机 + 工程边界
  • 相关专篇:属性、三种方法、继承、特殊方法、dataclass
创建于 2026/3/24 更新于 2026/7/15