简介:《Python Object-Oriented Programming, 4th ed.》是Packt于2021年推出的面向对象编程进阶读物,由Steven F. Lott与Dusty Phillips合著,适合已掌握Python基础语法、希望系统理解OOP设计思想并提升工程化能力的中级开发者与高校学生阅读。全书围绕封装、继承、多态三大机制展开,结合类与实例、属性与方法的具体用法,进一步延伸到模块化拆分、异常处理、单元测试与文档编写等构建健壮应用的实践,并讨论了版本控制、持续集成、依赖管理与社区协作等库维护要点,帮助读者把零散语法组织成可维护、可扩展的代码结构。资源为单个PDF文件,压缩包约6.51MB,含完整正文与版权、作者及贡献者信息,便于离线阅读和按章节检索。目前已有45人学习,适合作为课程参考或日常查阅的案头资料。
1. 为什么“私有属性”在 Python 里根本不存在——从一次属性覆盖说起
多数人学面向对象,记住的是封装、继承、多态三句口诀;等真正去继承别人的基类,才发现让自己翻车的从来不是这三个词,而是一条没写进语法的隐式规则:属性查找顺序。obj.x这一行究竟命中实例字典、类字典还是某个父类字典,直接决定了 property 会不会被实例属性盖住、super()会不会跳过某一层、加一行__slots__会不会让老代码当场报错。这份第四版把类定义、类型提示、异常、抽象基类、数据结构与设计模式串成一条线,并用同一个案例反复改写,恰好可以用来还原这些规则背后的机制:Python 的面向对象不是靠关键字约束出来的,而是靠一套可推导的查找协议与描述符协议撑起来的。
2. 属性查找链:一次 obj.attr 到底命中了几层
写obj.attr时,你看到的是一个点号,解释器看到的是一次带优先级的多级回退。理解这条链路之后,很多“玄学”现象会立刻变得可解释:为什么给实例塞一个同名字段盖不住 property,为什么给类打补丁能影响已创建的实例,为什么__slots__能把内存砍下去一截。
2.1 从 type(obj).mro到 obj.dict的优先级顺序
CPython 里这条链路由_PyObject_GenericGetAttrWithDict实现,顺序可以粗暴地记成四级回退。第一级,沿着type(obj).__mro__找,看类字典里有没有同名的数据描述符(同时定义了__get__和__set__,或定义了__delete__);第二级,查obj.__dict__;第三级,仍然沿 MRO 找同名属性,此时命中的非数据描述符或普通类属性直接返回;第四级,走到__getattr__兜底,还没有就抛AttributeError。
| 优先级 | 查找位置 | 典型对象 |
|---|---|---|
| 1 | MRO 上的数据描述符 | property、自定义带__set__的描述符 |
| 2 | 实例的__dict__ | self.x = 1写进去的普通字段 |
| 3 | MRO 上的非数据描述符与类属性 | 方法、classmethod、类常量 |
| 4 | __getattr__ | 动态属性、代理对象 |
关键在于第 1 级与第 2 级的先后:数据描述符压过实例字典,非数据描述符则被实例字典压过。这就是为什么property定义的只读属性无法用obj.__dict__["x"] = 1绕开,而一个普通方法名却可以被实例字段遮住。同一条规则还解释了方法补丁为什么生效:给类赋值只是改了第 3 级的来源,所有实例的下一次查找都会看到新值。
2.2 描述符协议:function、property、classmethod 凭什么自动绑定 self
方法为什么不需要显式传 self,是描述符协议最日常的一次应用。函数对象本身就是非数据描述符,它的__get__在被访问时现造一个绑定方法,把实例塞进第一个参数:
class Counter: def bump(self, step=1): # 函数对象存在于 Counter.__dict__ return step c = Counter() print(Counter.bump) # <function Counter.bump> __get__(None, Counter) 返回函数本身 print(c.bump) # <bound method Counter.bump of ...> __get__(c, Counter) 完成绑定 print(c.bump is c.bump) # False —— 每次访问都新建绑定方法对象第三行的结果值得记一下:绑定方法不是缓存下来的,而是每次属性访问临时构造。所以把obj.method存进列表再调用没问题,但比较两次取到的对象不会相等。classmethod与staticmethod同样是描述符,只是__get__的包装方式不同,前者把类塞进第一个参数,后者原样返回函数。
自定义描述符的价值在于把校验逻辑挂到赋值路径上,而不是散落在每个调用点:
class Positive: """数据描述符:同时定义 __get__ 和 __set__,优先级高于实例字典""" def __set_name__(self, owner, name): self.public = name self.private = "_" + name # 类创建时由 type.__new__ 回调,拿到真实字段名 def __get__(self, obj, objtype=None): if obj is None: # 通过类访问 C.amount,返回描述符自身 return self return getattr(obj, self.private, 0) def __set__(self, obj, value): if value < 0: raise ValueError(f"{self.public} 不能为负: {value}") setattr(obj, self.private, value) class Order: amount = Positive() def __init__(self, amount): self.amount = amount # 走 __set__,而不是写进实例字典 o = Order(10) o.amount = -1 # ValueError: amount 不能为负: -1__set_name__是这里容易被忽略的一环:它在类对象构造完成时被批量调用,让描述符自己知道被挂在哪个名字上,省掉手写字段名的重复劳动。__get__里obj is None的分支也必须写,否则Order.amount这种类级访问会直接抛异常。真正的落点写进_amount,所以实例字典里存的是私有名,公开名始终被描述符接管——这就是“读走查找链、写走赋值链”的具体形状。
2.3slots省下的内存,和它拿走的东西
__slots__不是性能开关,它改的是实例的内存布局。声明之后,类创建时会给每个槽位生成成员描述符,实例不再分配__dict__,字段存进固定偏移:
class Point: __slots__ = ("x", "y") def __init__(self, x, y): self.x, self.y = x, y p = Point(1, 2) p.z = 3 # AttributeError: 'Point' object has no attribute 'z'代价是三条硬约束。其一,实例不能动态加属性,任何依赖setattr打补丁的代码(序列化框架、mock 库的部分行为)会失效。其二,继承时若父类没有__slots__,子类实例照样有__dict__,优化白做;反过来,两个都有非空__slots__的父类做多继承会触发TypeError: multiple bases have instance lay-out conflict,因为两块固定布局无法合并到同一个对象头里。其三,弱引用和__dict__描述符需要显式在槽位里加上"__weakref__"和"__dict__",否则依赖弱引用的缓存层会直接报错。
注意:给已有类补
__slots__属于破坏性变更。字段集合一旦固定,任何obj.new_attr = ...的外部代码都会从“静默成功”变成异常。
3. 继承的真实开销:MRO、super() 与协作式多继承
继承在 Python 里不是编译期的类型声明,而是一张在类创建时算好、之后只读的线性表。理解了这张表怎么算、super()怎么用它,多继承就从“不敢碰”变成“可以推导”。
3.1 C3 线性化:菱形继承里那个“只调用一次”的保证
每个类都有__mro__,由 C3 算法合并父类的线性化结果得到:取第一个父类的 MRO,与其余父类的 MRO 以及父类列表本身做归并,每次挑出“不出现在任何其他列表尾部”的头元素。菱形结构App(Logger, Store)、两者都继承Base的情况下,结果是App → Logger → Store → Base → object,Base只出现一次。
这个“只出现一次”不是巧合,而是算法本身的约束:一个类若同时出现在多个父类的 MRO 里,它必须排在所有子类之后,且不能重复。归并失败时 CPython 直接抛TypeError: Cannot create a consistent method resolution order,通常发生在父类顺序写反,比如class A(B, C)而B本身是C的子类。
多继承还有一条容易踩的硬限制:不同参数集的__init__无法靠位置参数对齐。所有参与协作的类都改成关键字参数接收,才能真正拼起来。
3.2 super() 不是父类指针,而是 MRO 上的游标
super()常被理解成“调用父类”,这个理解在多继承下会立刻失效。它的真实语义是:以当前实例的 MRO 为序,从“当前类之后的那一项”继续查找。看这段协作式多继承:
class Base: def __init__(self, **kwargs): super().__init__(**kwargs) # 链尾是 object,必须能吞掉全部剩余关键字 class Logger(Base): def __init__(self, level="INFO", **kwargs): self.level = level super().__init__(**kwargs) class Store(Base): def __init__(self, path, **kwargs): self.path = path super().__init__(**kwargs) class App(Logger, Store): def __init__(self, **kwargs): super().__init__(**kwargs) app = App(level="DEBUG", path="/tmp/out") print([c.__name__ for c in App.__mro__]) # ['App', 'Logger', 'Store', 'Base', 'object']Logger.__init__里的super()拿到的是App实例,游标落在Store上,而不是Base。参数通过**kwargs一路传递,level被Logger消费,path被Store消费,剩下的空字典交给object。若某个中间类忘了写super().__init__(**kwargs),链就断在那里,后面所有类的状态都不会初始化——现象往往是“某个字段莫名是默认值”,而不是报错,排查成本极高。
提示:调试这类问题最省事的做法是在每个
__init__头部打印type(self).__mro__与kwargs,一次运行就能看清谁吃了哪个参数。
3.3 重载内置类型时,eq与hash的连带契约
继承list、dict这类内置类型是合法的,代价是要接住它们隐含的协议。最典型的是__eq__与__hash__的联动:一旦类里定义了__eq__,Python 会自动把__hash__置为None,实例立刻不可哈希,放进set或当字典键会抛TypeError: unhashable type。可变对象本就不该做键,这个默认行为是保护而非刁难。
需要可哈希的“值对象”时,显式给出__hash__,且必须与__eq__用的字段保持一致,否则两个相等对象的哈希不同,字典会出现“查不到自己”的诡异现象。冻结的dataclass(eq=True, frozen=True)会自动生成一致的__hash__,这也是我在写值对象时优先选它的原因。继承dict时还有另一个口子__missing__,它只在__getitem__找不到键时被调用,是实现默认值、计数、缓存最干净的入口——比在__getitem__里包一层 try 更贴近原语。
4. 协议驱动的设计:异常层次、抽象基类与迭代器
Python 的抽象不是靠编译器强制的,而是靠一批“约定 + 运行时钩子”维系的:异常定义错误的形状,抽象基类定义接口的形状,迭代器定义遍历的形状。三者的机制不一样,但设计意图一致——把约定变成可执行的检查。
4.1 异常不是错误码:异常层次与 raise ... from 的因果链
内置异常树形如BaseException → Exception → LookupError/ValueError/OSError/...,KeyboardInterrupt和SystemExit挂在BaseException下而不是Exception下,这是它们不会被except Exception顺手吞掉的原因。捕获时的粒度原则很朴素:只捕你真正知道怎么处理的类型,让其余异常带着原始 traceback 往上冒。
自定义异常的价值在携带上下文,而不是在名字好看:
class SampleError(Exception): """领域异常基类:只带结构化上下文,不在异常里拼长文案""" def __init__(self, message, *, row=None): super().__init__(message) self.row = row class InvalidLabel(SampleError): pass def parse_row(raw): try: label = int(raw["label"]) except (KeyError, ValueError) as exc: raise InvalidLabel("标签字段非法", row=raw) from exc # 设置 __cause__ return labelraise ... from exc写入__cause__,traceback 会打印 “The above exception was the direct cause of”,原始异常与转换后的异常都留在报告里;不写from时,__context__仍会记录隐式因果,但展示上弱一档。except (KeyError, ValueError)这种精确列举,比except Exception多花的几秒,会在某天救你一次——宽泛捕获会把StopIteration、AttributeError这类“其实是 bug”的信号一起吞成“数据有问题”。
4.2subclasshook:isinstance 背后那个可以自己写的钩子
抽象基类的作用是把“必须实现哪些方法”变成实例化时的检查:ABCMeta在__call__里比对__abstractmethods__,缺一个就抛TypeError: Can't instantiate abstract class。但它真正有意思的地方在__subclasshook__——isinstance与issubclass会先问这个钩子,允许结构匹配,即不显式继承也算子类:
from abc import ABCMeta, abstractmethod class Sample(metaclass=ABCMeta): @abstractmethod def label(self): ... @classmethod def __subclasshook__(cls, other): if cls is Sample and any("label" in base.__dict__ for base in other.__mro__): return True # 结构上具备 label,就认它 return NotImplemented # 交回默认流程,继续看显式继承关系返回值有三态:True直接判定成立,False直接否定,NotImplemented表示“我不表态”。这个机制支撑了collections.abc里Iterable、Sized的判定——一个自定义容器只要实现了__len__,isinstance(x, Sized)就为真,完全不需要继承。
有一个必须知道的实现细节:判定结果会缓存在 ABCMeta 的缓存里,所以运行时给类动态打补丁改变结构,isinstance不一定立刻刷新。另外,typing.Protocol提供的是给静态检查器用的结构化子类型,运行时默认不做校验,二者不可混用。
4.3 for 循环到底调了什么:迭代器协议与生成器的惰性边界
for语句的执行过程是:先调iter(obj),也就是obj.__iter__(),拿到一个迭代器;之后反复next(),直到抛出StopIteration收尾。协议只有两个方法,__iter__返回迭代器自身,__next__返回下一个值或抛停。生成器函数只是让这套协议写起来更省事:
class Batches: """按固定大小切分,一次只产出一批,内存占用与总行数无关""" def __init__(self, rows, size): self._rows, self._size = rows, size def __iter__(self): for start in range(0, len(self._rows), self._size): yield self._rows[start:start + self._size] # 生成器对象本身就是迭代器 for batch in Batches(list(range(10)), size=4): print(batch) # [0,1,2,3] [4,5,6,7] [8,9]生成器的关键语义是惰性:函数体在第一次next()才开始执行,yield处挂起并保存局部状态。这带来两个容易误解的边界。一是生成器只能消费一次,第二次for什么也拿不到,需要重放就得重新构造对象或改用列表。二是return value不会返回给调用者,而是塞进StopIteration.value,只有yield from或显式捕获才能读到。PEP 479 之后,生成器内部意外冒出的StopIteration会被转换成RuntimeError,避免它被外层误认为“正常迭代结束”——这是宽泛捕获最典型的受害者场景。
5. 排查清单:面向对象代码里最容易翻车的四个地方
这一节按“现象 → 原因 → 解决”记录我遇到频率最高的四类问题。它们的共同点是:静态检查很难发现,运行起来也不一定立刻报错,往往在数据量变化或继承层次变深之后才浮现。
5.1 两个实例互相污染,列表越跑越长
现象:给一个对象追加数据,另一个对象的数据也跟着变;或者反复创建对象后,某个列表里的元素数量呈倍数增长。
原因:状态被挂在了类命名空间上。类体只在class语句执行时求值一次,class A: items = []里的列表是全体实例共享的同一个对象,self.items.append(x)命中的是类属性。同源的坑还有函数默认值def f(acc=[])——默认值在函数对象创建时求值一次,之后每次调用复用同一个可变对象。
解决:实例状态一律在__init__里绑定self.items = [];数据类场景用field(default_factory=list),让工厂每次调用产出新对象。静态检查工具对后者有专门告警,前者只能靠纪律。
5.2 property 的 setter 里出现 RecursionError
现象:赋值时报RecursionError: maximum recursion depth exceeded,栈里全是同一个 setter。
原因:属性赋值走的是描述符的__set__路径,不是写实例字典。setter 内部再写self.amount = value,等于又触发一次__set__,形成自递归。名字相同是唯一的触发条件,改个名字就消失。
解决:内部一律存私有名self._amount = value,公开名只留给描述符。__init__里也一样——想走校验就赋公开名,不想走校验就直接赋私有名,但两者必须一致,否则会出现“初始化绕过校验”的漏洞。
5.3 多加一个 mixin 就 TypeError,报错行指向最后一个父类
现象:继承链上新增一个类之后,实例化抛TypeError: __init__() got an unexpected keyword argument,报错位置看起来和调用处毫无关系。
原因:协作式多继承的**kwargs链断了。MRO 顺序因新父类插入而改变,某个中间类的__init__只声明了固定参数、没有**kwargs收尾,于是上游传下来的多余关键字全部砸在它身上。报错行指向它,只是因为它是最先拒绝接收的那一环。
解决:链上每个__init__的签名都写成(self, 自己的参数..., **kwargs),并在末尾调super().__init__(**kwargs);链尾的object.__init__不接受多余参数,所以最靠近根的那一层要确保 kwargs 已被消费干净。
5.4 迭代悄悄提前结束,程序不报错但少了一半数据
现象:遍历一个有数据的集合,循环体只执行了很少几次就正常退出,没有任何异常。
原因:try块的范围画大了,把next()调用或yield包了进去,里面抛出的StopIteration被except Exception当成普通错误吞掉,外层循环误以为迭代正常结束。生成器内部则会被 PEP 479 转成RuntimeError,表现成“偶发崩溃”。
解决:把try收缩到只包住真正会失败的那一个表达式,并列举具体异常类型;遍历逻辑与异常处理分开写。定位这类问题时,在循环体入口打印一行计数,往往比读代码更快。
6. 组合还是继承:判断标准、验证方式与并发模型的选型
我判断要不要用继承,只看一条:子类型是否真的可以替换父类型。若只是为了复用几行实现,组合或函数注入更安全——继承会把父类的全部接口连同未来的变更一起绑定进来,父类改一个方法签名,所有子类跟着遭殃;组合只暴露你主动转发的那几个方法,边界由你控制。抽象基类和Protocol的价值也在这里:它们定义的是调用方需要的最小接口,而不是实现继承的树。
验证方式上,我更相信测试而不是类图。用pytest的 fixture 构造对象、用 mock 隔离 I/O 边界、把“这个子类能否替换父类”写成一条参数化用例,跑一遍比讨论一小时有效。第四版里测试与并发各占一章,位置放得很讲究:设计模式写得再漂亮,没有测试兜底就只是纸上结构。
并发模型的选型可以直接按瓶颈归类:
| 场景 | 首选 | 原因 |
|---|---|---|
| 阻塞式 I/O(网络、文件) | asyncio | 单线程内挂起等待,切换成本低 |
| 少量阻塞调用、已有同步代码 | 线程池 | 改动小,但要接受 GIL 下的切换开销 |
| CPU 密集计算 | 多进程 | 每个进程独立解释器,绕开 GIL |
| 生产者与消费者解耦 | 队列 + 进程/线程 | 用消息传递替代共享内存 |
那张表背后是同一条教训:共享可变状态是并发 bug 的源头。用队列传数据、用不可变对象做消息体、用join()等所有任务收尾,比事后加锁便宜得多。
从那以后我每次写继承,都会先在 REPL 里打印一遍Cls.__mro__,确认每个__init__都能接住**kwargs,再动手写业务。这个习惯拦下的问题,比任何代码规范都多。希望帮到你。
本文还有配套的精品资源,点击获取