简介:《Python Object-Oriented Programming, 4th ed.》是Packt于2021年出版的Python面向对象编程第四版电子书,由Steven F. Lott与Dusty Phillips合著,面向具备Python基础、希望系统掌握OOP并构建健壮可维护应用与库的开发者。全书围绕封装、继承、多态等核心机制展开,结合类与实例、属性与方法、模块化设计、异常处理、单元测试、文档编写,以及版本控制、CI/CD、依赖管理和社区协作等工程实践,帮助读者把面向对象思想落到真实项目结构、代码质量与库维护流程中,对从脚本式写法转向工程化OOP的读者尤其有针对性,并理解可扩展设计中的取舍。资源包为1个PDF文件,大小约6.51MB,便于在电脑或阅读器上随查随学。已有43人学习下载,适合需要一本兼顾语法基础、设计思路与工程经验的初中级Python程序员,也可作为团队代码规范与可维护性建设的参考读物。
1. 会写 class 不等于懂对象:Python OOP 真正的分水岭在哪
很多人学 Python 面向对象,路径都差不多:定义一个类,写个__init__,self一挂,实例一创建,就觉得自己会了。直到某天遇到一个说不通的现象——子类明明重写了方法,父类的逻辑还是被执行了;实例上给属性赋了值,类上的同名属性却像幽灵一样还在;或者一个列表参数在两次调用之间莫名其妙被追加了两次数据。这些问题都不是语法层面的,它们全部指向 Python 对象模型里那几条不写在教程首页的隐藏规则:属性查找链、C3 线性化、描述符协议。这份第四版资料把这些规则从__dict__一路拆到元类,正好适合拿来把「会写类」升级成「能推理对象行为」。下面按机制层次往下走。
2. 属性查找:obj.x这一行背后到底发生了什么
2.1 一次属性读取的完整优先级
先给结论:Python 里实例属性、类属性、父类属性不是覆盖关系,而是按固定优先级顺序查找。当你写下obj.x,CPython 大致沿着type(obj).__mro__这条链,对每个类依次检查:这个类有没有名为x的数据描述符?有就调用它的__get__并直接返回;没有就去看obj.__dict__里有没有x;再没有才去看类字典里的普通属性(包括函数对象这种非数据描述符)。这个顺序里最反直觉的一点是:数据描述符的优先级高于实例字典。
class Desc: def __get__(self, obj, objtype=None): print("descriptor __get__ hit") return 42 class A: x = Desc() a = A() a.__dict__["x"] = 100 # 绕过 __setattr__,直接写实例字典 print(a.x) # 输出 42,不是 100上面这段代码里,a.__dict__中确实存了x=100,但a.x依然返回 42。原因就是Desc实现了__get__,被视为数据描述符,查找时先于实例字典被命中。很多「我明明赋值了为什么读出来还是旧值」的怪现象,根源都在这里。反过来,如果描述符只实现了__get__而没有__set__,它属于非数据描述符,优先级就排在实例字典后面——函数之所以能被实例属性覆盖,正是这个原因。
还有一个容易被混淆的点是__getattr__与__getattribute__。__getattribute__是每次属性访问都会走的总入口,__getattr__只在常规查找失败后才被调用。想给对象加「兜底默认值」,重写__getattr__就够了;重写__getattribute__则要非常小心,因为稍不注意就会写出无限递归。
2.2 property、classmethod 和普通方法共用同一套协议
理解了描述符,@property就不再是魔法。它本质上是内置的数据描述符:fget负责读,fset负责写,fdel负责删。类里定义的普通函数也是描述符,只是它实现的是非数据版本,__get__返回的是一个绑定了self的 method 对象。
class Temperature: def __init__(self, celsius: float) -> None: self._celsius = celsius @property def celsius(self) -> float: return self._celsius @celsius.setter def celsius(self, value: float) -> None: if value < -273.15: raise ValueError("below absolute zero") self._celsius = value这里的celsius是一个数据描述符,所以t.celsius = 10会走 setter,而不是往实例字典里塞一个同名属性。classmethod和staticmethod同样是描述符的不同实现:前者在__get__时把cls绑进去,后者直接返回原函数、不做绑定。把这三个东西看成「同一个协议的不同参数化形式」,比分别记忆它们的用法要牢靠得多。
2.3 封装在 Python 里是约定,不是语法
Python 没有private关键字。单下划线_name是给读代码的人看的约定;双下划线__name会触发名称改写(name mangling),在类体内被替换成_ClassName__name。它防的是子类意外覆盖,不是防外部访问。
class Counter: def __init__(self): self.__count = 0 def increment(self): self.__count += 1 c = Counter() c.increment() print(c._Counter__count) # 0 变 1,改写后的名字依然能访问真正影响对象行为的是__slots__:它让实例不再拥有__dict__,属性被固定在类级别描述符上,从而省掉每个实例一个字典的开销。这是内存优化手段,不是访问控制。把它和封装混为一谈,是初学者常见的认知错位。
3. 继承与 MRO:super()到底在找谁
3.1 C3 线性化:深度优先为什么不够用
多继承下最经典的结构是菱形:B 和 C 都继承 A,D 同时继承 B 和 C。如果按深度优先遍历,D 会先走完 B 再到 A,然后回头走 C,A 就被访问了两次,而且 B 中覆盖的方法可能压过 C 中更靠后的定义。Python 用的是 C3 线性化,规则可以概括为:每个类的 MRO 等于它自己,加上所有父类 MRO 的合并,合并时保持局部优先顺序,并且每个类只出现一次。
class A: pass class B(A): pass class C(A): pass class D(B, C): pass print([cls.__name__ for cls in D.__mro__]) # ['D', 'B', 'C', 'A', 'object']注意A出现在C之后而不是B之后。这个顺序保证了子类永远排在父类前面,同时尊重声明顺序。如果某个类层次无法满足这些约束,Python 会在定义类的时候直接抛TypeError,而不是等到运行时才出问题——这也是 C3 的一个好处:结构错误在导入阶段就暴露。
3.2super()不是父类,是 MRO 上的下一站
很多人把super()理解成「调用父类方法」,这在单继承里碰巧成立,在多继承里就会翻车。super()返回的是一个代理对象,它记住两件事:当前类和当前实例,然后在type(instance).__mro__里从当前类之后继续查找。换句话说,super().method()的语义是「沿 MRO 找下一个实现了 method 的类」。
class Base: def __init__(self, **kwargs): print("Base init") super().__init__(**kwargs) class LoggingMixin(Base): def __init__(self, **kwargs): print("LoggingMixin init") super().__init__(**kwargs) class ValidationMixin(Base): def __init__(self, **kwargs): print("ValidationMixin init") super().__init__(**kwargs) class Service(LoggingMixin, ValidationMixin): def __init__(self, **kwargs): super().__init__(**kwargs) Service() # LoggingMixin -> ValidationMixin -> Base这条链能跑通的前提是每一层都调用super().__init__(**kwargs)并把参数透传下去。只要中间某一层偷懒写成Base.__init__(self),链就断了,后面的 mixin 初始化全部被跳过。这是协作式多继承最典型的踩坑方式。
3.3 抽象基类在实例化时检查什么
abc模块的价值在于把「子类必须实现某方法」的约束从运行期提前到实例化期。ABCMeta在创建类时收集所有带__isabstractmethod__标记的方法,存进__abstractmethods__;当有人试图实例化一个还有抽象方法没实现的类,直接抛TypeError。
from abc import ABC, abstractmethod class Shape(ABC): @abstractmethod def area(self) -> float: ... class Circle(Shape): def __init__(self, r: float): self.r = r def area(self) -> float: return 3.14159 * self.r ** 2对比「父类里写raise NotImplementedError」的做法,ABC 的优势是失败时机更早、错误信息更明确,而且配合collections.abc里的Sequence、Mapping等接口,还能通过register()把不相关的类注册成虚拟子类,让isinstance判断成立。这是 Python 在「鸭子类型」和「显式契约」之间做的一个折中。
4. 数据容器与迭代协议:从 dataclass 到生成器状态机
4.1 三种数据载体的边界
NamedTuple、dataclass和普通类经常被混用,但它们的设计目标不同。NamedTuple是 tuple 的子类,天然可解包、可哈希、可比较,适合做轻量的值对象;dataclass自动生成__init__、__repr__、__eq__,适合承载有多个字段、需要可变或需要默认值的结构;普通类则留给有行为、有状态机、需要精细控制构造过程的场景。
| 特性 | NamedTuple | dataclass | 普通类 |
|---|---|---|---|
| 可变性 | 不可变 | 默认可变,frozen=True可改 | 自由控制 |
| 可解包 | 是 | 否 | 否 |
自动__eq__ | 是 | 是 | 否 |
| 内存开销 | 低 | 中(有__dict__) | 中高 |
| 适合场景 | 坐标、配置项 | DTO、领域模型 | 有行为的对象 |
from dataclasses import dataclass, field from typing import NamedTuple class Point(NamedTuple): x: float y: float @dataclass(frozen=True) class Range: low: float high: float tags: tuple[str, ...] = field(default_factory=tuple)frozen=True不只是「不能赋值」这么简单,它还会让 dataclass 生成__hash__,从而能放进 set 或作为字典键。这一点在需要把配置对象当缓存键时非常实用。
4.2for循环只认两个方法
迭代器协议短得惊人:__iter__返回迭代器自身(或另一个迭代器),__next__返回下一个元素,到底抛StopIteration。for语句做的事就是先调iter(),然后反复调next(),捕获StopIteration后静默结束。
class Countdown: def __init__(self, start: int): self.current = start def __iter__(self): return self def __next__(self) -> int: if self.current <= 0: raise StopIteration value = self.current self.current -= 1 return value这里有个容易忽略的细节:可迭代对象和迭代器是两个概念。列表是可迭代对象,每次iter()返回一个新的迭代器;而上面这个Countdown既是可迭代对象又是迭代器,只能遍历一次。把两者混在一起,会在「循环两遍」这种场景下直接翻车。
4.3 生成器是暂停的状态机
生成器函数调用后不会立刻执行,而是返回一个 generator 对象。每次next()执行到yield就暂停,局部变量、指令指针、异常处理状态全部被保存下来,下次next()从暂停点继续。这也是为什么生成器能做到「惰性求值」——它把计算过程切成了可恢复的片段。
def read_batches(rows, size): batch = [] for row in rows: batch.append(row) if len(batch) == size: yield batch batch = [] if batch: yield batch配合yield from,还能把子生成器的产出直接委托出去,形成生成器栈。这种结构在流式处理、分页读取、管道式数据清洗里非常常见,内存占用只和单批数据量有关,和总数据量无关。
5. 常见问题与排查:OOP 里最容易翻车的五处
5.1 可变默认参数被所有实例共享
现象:两个实例创建后,往其中一个的列表属性里追加元素,另一个也跟着变。
原因:默认参数在函数定义时求值一次,所有调用共享同一个对象。def __init__(self, items=[])里的[]只创建了一次。
解决:用None做哨兵,在函数体内再创建;dataclass 里用field(default_factory=list)。
@dataclass class Cart: items: list[str] = field(default_factory=list)5.2super()只调了一层,协作初始化断裂
现象:多继承下某个 mixin 的初始化逻辑完全没执行,日志里也没有报错。
原因:中间某一层没有调用super().__init__(),或者用了Base.__init__(self)绕开了 MRO。
解决:每一层统一写super().__init__(**kwargs),参数用关键字透传;排查时先打印type(self).__mro__,确认链路是否完整。
5.3 裸except吞掉异常,栈信息丢失
现象:线上报错只看到一句「出错了」,没有堆栈,定位不到具体行号。
原因:except:或except Exception: pass把异常连同 traceback 一起吞了。
解决:至少记录logging.exception(...);需要转换异常类型时用raise NewError(...) from e保留因果链。
5.4 dataclass 默认值直接写可变对象
现象:定义 dataclass 时直接报ValueError: mutable default ... is not allowed,或者绕过检查后出现共享状态。
原因:dataclass 在类创建阶段就检查字段默认值,防止所有实例共享同一个可变对象。
解决:一律用field(default_factory=...),工厂函数可以是list、dict、set,也可以是自定义函数。
5.5 用实例属性做计数器,多线程下数字对不上
现象:多线程累加同一个计数属性,最后结果小于预期。
原因:self.count += 1不是原子操作,它包含读、加、写三步,线程切换可能发生在任意一步之间。GIL 只保证单条字节码的原子性,不保证复合操作。
解决:用threading.Lock保护临界区;如果是多进程场景,各进程有独立内存空间,得用multiprocessing.Value或Queue做跨进程通信。
6. 设计取舍:什么时候该放下继承,改用组合
写了足够多的类之后会发现,继承的真正成本不在定义那一刻,而在后续每一次修改。父类加一个方法,所有子类都被动继承;父类改一个初始化参数,整条继承链都要跟着调整。常见的判断标准可以压成一张表:
| 判断维度 | 倾向继承 | 倾向组合 |
|---|---|---|
| 关系语义 | is-a | has-a |
| 复用方式 | 复用接口与默认实现 | 复用已有对象的能力 |
| 变更频率 | 层次稳定 | 组件独立演化 |
| 测试难度 | 需要构造完整父类链 | 可以单独替换组件 |
| 多继承 | 仅限 mixin,且必须协作式 | 不涉及 |
一个实用的验证方法是:把候选父类的方法全部列出来,问自己「子类是否真的需要其中每一个」。如果答案是否定的,那多半应该持有一个该类型的实例,把需要的方法转发出去,而不是继承它。转发多写几行代码,但换来的是更低的耦合和更清晰的测试边界。
测试层面也是同样的逻辑。pytest 的 fixture 本质上就是组合思想的体现:把依赖注入到测试函数里,而不是让测试类去继承一个庞大的基类。mock 则进一步把「外部依赖」变成可替换的组件。把这两样东西用顺,很多原本需要继承体系才能解决的问题,会自然退化成几个小对象的拼装。
并发模型的选择也是取舍。I/O 密集用线程或 asyncio,CPU 密集用多进程,共享状态少的时候优先选消息传递而不是共享内存。这些结论本身不难,难的是在写第一行代码之前就想清楚边界在哪。
从那以后我每写一个带继承的类,都会先把__mro__打印出来看一眼,再决定要不要真的用继承。这个习惯帮我省掉了不少事后排查的时间。希望帮到你。
本文还有配套的精品资源,点击获取