首页 / 资讯中心 / 文章详情

Python对象模型核心:class即对象、抽象基类与MRO避坑指南

Python对象模型核心:class即对象、抽象基类与MRO避坑指南 ★ FEATURED ARTICLE
简介《Python Object-Oriented Programming》第四版Steven F. Lott与Dusty Phillips合著Packt Publishing2021年是一本系统阐述Python面向对象编程的实用书籍面向希望提升OOP设计能力与代码质量的Python开发者。资源为PDF格式共1个文件压缩包大小约6.51MB单文件便于存储和离线阅读。书中从封装、继承、多态等核心概念讲起结合类、实例、属性、方法的实际用法逐步深入到异常处理、单元测试、模块化设计等应用实践并给出了版本控制、持续集成、依赖管理等维护Python库的工程建议。全书配有大量代码示例帮助读者深入理解面向对象设计原则同时强调了代码文档编写与可读性能够帮助读者构建健壮、可维护且易于扩展的应用和库对个人项目与团队协作均有参考价值。目前已有43人学习下载适合初学者系统入门也适合有经验者作为案头参考。1. 类型即对象为什么 class 在 Python 里是一段会执行的代码刚从 Java 转来的开发者看到 Python 类体能在 import 时直接 print通常会愣一下class 不是声明吗在 Python 里class 是一条运行时语句。解释器会逐行执行类体把 def 变成函数对象把赋值变成类属性最后把整个结果组装成一个新的类对象。也就是说你写下的每个类本身也是一个对象。这个反直觉事实决定了 Python 面向对象编程与静态语言的分岔继承要在运行时算顺序抽象基类要在运行时注册类型标注只是提示。Python Object-Oriented Programming 第四版前六章都在围绕这条主线展开先把对象模型讲透再讲封装、继承、组合怎么搭出可维护的系统。搞懂 class 为什么会被执行、执行完之后变成什么后面所有机制都能顺着同一根线索推出来。2. 抽象基类与 collections.abc把鸭子类型升级成运行时的契约2.1 鸭子类型的边界为什么「有方法就行」不总是够用Python 不检查类型只看行为。任何对象只要有iter就能被 for 遍历有getitem和len就能像序列那样切片。这种「有方法就行」的约定被叫做鸭子类型它让接口在 Python 里不是一张要继承的名单而是一组可选的协议。好处是灵活坏处是没法在文档和类型提示里精确表达「这个函数到底想要什么」。举个例子你写了一个函数入参声明为 list可函数内部只需要遍历。调用方看签名之后很可能把元组先转换成列表再传进来白白多一次拷贝而如果你把入参声明为 tuple另一方拿着生成器又来抱怨。真正的需求是「可迭代」既不是 list 也不是 tuple。更麻烦的是运行时校验isinstance(items, list) 会把一切自定义序列挡在门外因为判定看的是继承树不是行为。collections.abc 就是为了填补这块空白出现的它定义了一组内置容器的「契约形状」Iterable 要求iterSequence 要求getitem与lenMapping 要求 keys、getitem等一组方法。验证一下一个实现了序列特殊方法的自定义类在 isinstance 眼里到底是什么身份from collections.abc import Iterable, Sequence class TaggedList: def __init__(self, raw): self._data list(raw) def __getitem__(self, index): return self._data[index] def __len__(self): return len(self._data) print(isinstance(TaggedList([1, 2, 3]), Iterable)) # False print(isinstance(TaggedList([1, 2, 3]), Sequence)) # False两个判定都是 False因为 TaggedList 虽然实现了序列该有的特殊方法但它既没有继承 Sequence也没有被注册。这个输出说明了一个重要事实鸭子类型在 for 循环层面成立在 isinstance 层面并不成立。collections.abc 的作用就是把「行为契约」变成可以被显式声明、显式检查的东西而不是靠代码审查看运气。2.2 collections.abc 的虚拟子类isinstance 是怎么被动态「骗过」的为了让自定义类也能通过 isinstance 判定collections.abc 设计了一套虚拟子类机制。它有两种触发方式手动注册以及靠subclasshook自动识别。先看手动注册的路径from collections.abc import Sequence class ReadOnlyList: def __init__(self, raw): self._data list(raw) def __getitem__(self, index): return self._data[index] def __len__(self): return len(self._data) def __repr__(self): return repr(self._data) Sequence.register(ReadOnlyList) print(isinstance(ReadOnlyList([3, 1, 2]), Sequence)) # Trueregister 是在类型系统上打补丁从这一刻起ReadOnlyList 在 isinstance 和 issubclass 眼里和 Sequence 的亲生儿子没区别。但它是「虚拟」的——ReadOnlyList 身上并没有 Sequence 的任何实现方法调用 ReadOnlyList([3,1,2]).index(2) 依旧会抛 AttributeError。注册只影响检查结果不影响行为这堵墙值得记牢否则重构时很容易产生「isinstance 为真就有方法用」的错觉。比手动注册更常用的是subclasshook。Iterable 之所以能识别所有带iter的类靠的就是这个钩子from collections.abc import Iterable class MyIterable: def __iter__(self): return iter([1, 2, 3]) print(issubclass(MyIterable, Iterable)) # True无需注册Iterable.subclasshook的实现思路是在待检查类的整个 MRO 上找有没有iter定义找到就返回 True找不到返回 NotImplemented把决定权交回正常的继承检查。所以subclasshook把「结构匹配」翻译成了「类型匹配」被检查的类甚至不需要知道 Iterable 的存在。这是 Python 里最接近静态语言接口检查的机制只是它发生在运行时。这里有一个常见误读用 isinstance(data, Iterable) 判断「能不能下标访问」。字符串是 Iterable字典的迭代得到的是键Any 迭代器只能消费一次。Iterable 只承诺「能 for」不承诺「能索引」也不承诺「能重复遍历」。想要可索引语义得用 Sequence想要键值语义得用 Mapping。选错 ABC检查照样通过但行为完全不符合预期。2.3 自定义抽象基类约束一个接口要付出什么代价collections.abc 那组内置 ABC 不够用时可以用 abc 模块自己定义抽象基类写法比 Java 的 interface 更直白from abc import ABC, abstractmethod class Distance(ABC): abstractmethod def distance(self, point_a, point_b): ... class Euclidean(Distance): def distance(self, point_a, point_b): return sum((x - y) ** 2 for x, y in zip(point_a, point_b)) ** 0.5这里的 abstractmethod 起的作用是禁止实例化直接调用 Distance() 会抛 TypeError提示缺少抽象方法而 Euclidean 只要实现了 distance 就能正常实例化。它和 Java 接口有一个关键差别允许部分实现。中间层类可以只实现部分抽象方法只要不实例化就合法这给了你沿继承链逐步补全的自由。但我一般会把自定义 ABC 的门槛定得比较高。定义一个 ABC意味着要维护一组抽象方法要斟酌要不要自定义subclasshook还要在文档里写清楚它是契约而非实现。为「像性」在两个小类之间建一个 ABC多半是过度设计真正值得用抽象基类的场景是库的公开 API、插件协议、或者像这本书案例里为 k-NN 分类器定义可替换的距离计算和超参数接口让不同实现能通过同一套检查被装配进来。抽象基类在 Python 里的定位从来不是强制约束而是把「这个接口长什么样」变成一个运行时可验证、文档可引用的入口——一个准备好被检查、但平时不碍事的黑匣子。3. 继承与 MRO菱形继承里 super() 为什么还找得到对的实现3.1 继承与组合的分界面对一组相似行为先问什么这本书从第 1 章就开始强调一个判断继承表达 is-a 关系组合表达 has-a 关系。落到代码里最实用的检验法是问一句换掉其中一个子类调用方能不能无感工作。距离计算是全书案例里最典型的分岔路欧氏距离、曼哈顿距离、带权欧氏距离共享同一套接口——两个点向量进来一个标量出去——但它们之间没有任何一个是另一个的增强版。把它们组织成 Distance 抽象基类加若干子类其实是在把「行为差异」伪装成「类型差异」。如果只在意行为差异还有一条更便宜的路线直接用同一个类把距离函数作为参数传进去。这也是第 8、11 章反复出现的思路函数是一等对象行为可以被注入、被替换而不必动类继承树。我的经验是先列出候选类的公有方法如果候选类之间的差异只集中在某一个方法内部的算法继承往往不是最优解。反过来当你要表达「所有容器都必须支持迭代、取长、取值」并且希望类型检查层面的 isinstance 能关上门抽象基类和继承才是合适的工具。分界不在代码量而在你是否真的需要「它是它」的关系被运行时验证。3.2 C3 线性化mro到底怎么算出来的Python 多继承里每个类都有一个mro属性它是一条按优先级排好的祖先链。很多人以为它是深度优先其实 Python 用的是 C3 线性化它保证三件事每个祖先在链上只出现一次、直接父类的相对声明顺序不被打乱、解析结果保持单调一致。用代码能最直观地看清差别class Base: def who(self): print(Base) class Left(Base): def who(self): print(Left) super().who() class Right(Base): def who(self): print(Right) super().who() class Both(Left, Right): def who(self): print(Both) super().who() print(Both.__mro__) Both().who()输出结果(class __main__.Both, class __main__.Left, class __main__.Right, class __main__.Base, class object) Both Left Right Base看输出就明白了菱形结构里 Both 继承 Left 和 RightLeft 和 Right 都继承 Base。如果按深度优先会得到 Both→Left→Base→RightRight 的覆盖被跳过如果按普通广度优先又可能违反「父类声明顺序优先」的直觉。C3 把两条路合并成 Both→Left→Right→Base每个类恰好出现一次。这里的 Base 被排到 Right 之后是因为它同时是 Left 和 Right 的父类C3 取的是「尽量晚出现」的策略避免更深的祖先抢在浅层父类前面。亲手打印一次mro很多关于多继承的玄学当场就消失了。3.3 super() 沿 MRO 委派为什么每个父类都会被调一遍super() 在 Python 3 里不带参数时等价于 super(class, self)。class是定义当前方法时的那个类self 是实际调用者。关键在于 super() 并不是找「当前类的父类」而是在 self 的 MRO 上从class的下一个位置开始往下找。这导致菱形继承里一个反直觉现象Left 里的 super().who() 不去找它的父类 Base而是跳到了 Right。上面示例的输出已经演示了这一过程——这就是「协作式多继承」的设计目的让 MRO 链条上的每个类都参与一次。这种设计在初始化时尤其重要。如果不用 super() 而是直接写 Base.init(self)多继承就会漏掉 Right.init。正确写法通常是每层都接收 **kwargs 并向后传递class Base: def __init__(self, value, **kwargs): super().__init__(**kwargs) self.value value class Left(Base): def __init__(self, left, **kwargs): super().__init__(**kwargs) self.left left class Right(Base): def __init__(self, right, **kwargs): super().__init__(**kwargs) self.right right class Both(Left, Right): def __init__(self, left, right, value): super().__init__(leftleft, rightright, valuevalue)这里的关键是每一层都接收 **kwargs 并原样往下传最后一层显式用关键字参数命名。任何一层漏传MRO 后面的类就接不到参数而报 TypeError。这是多继承最容易翻车的点单独看 Left 和 Right 都无害一旦合并初始化顺序和参数名就成了协议的一部分。多继承不是不能写而是写之前必须想清楚整条 MRO 上的每个类愿不愿意配合协作。书里对「不同参数集」的处理用的就是这套约定——把公共参数抽出来把各自特有参数用关键字传递MRO 才能稳定走下去。4. 避坑对象模型里最容易翻车的四个设计4.1 可变默认值为什么两个实例会共享同一个列表现象类里写了 definit(self, features[])创建两个实例后分别往 features 里加数据发现两个实例的数据互相污染。原因Python 函数的默认参数在 def 语句执行时求值一次这个列表对象被挂在函数对象的defaults属性上。之后每次调用构造器只要没传 features拿到的都是同一个列表。本质上是所有实例共享同一个可变对象。解决用 None 哨兵替代可变默认值在方法体内重建class Sample: def __init__(self, name, features[]): # 坑 self.name name self.features features one Sample(a) two Sample(b) one.features.append(1) print(two.features) # [1]被波及 class FixedSample: def __init__(self, name, featuresNone): # 正确 self.name name self.features features if features is not None else [] one FixedSample(a) two FixedSample(b) one.features.append(1) print(two.features) # []None 不可变每次判断成本极低这是最通用的解法。注意不要写成 if features 来判断是否传参空列表会被误判为「没传参」而重建列表虽然大多数时候结果碰巧一致但遇到「故意传空列表来关闭某行为」的调用方就会出 bug。判断默认值是否被覆盖唯一可靠的手段是「是否等于 None」。4.2 异常层级捕获过宽数据验证时 bug 被悄悄吞掉现象解析一份样本文件try 里包了整段解析逻辑except ValueError 一律跳过。坏行确实被跳过了但真正的逻辑错误也一并当作脏数据过滤掉模型训练结果离谱却没人察觉。原因内置异常本身就是一条继承链ValueError 是 int()、float()、json.loads 等多个转换失败的共同父类。捕获父类等于吸收所有子类异常处理同样遵守多态。书里的案例场景就是用自定义异常区分「这条样本数据格式不合规」和「程序本身算错了」两者不该共享同一个 except 分支。解决自定义异常并继承精确的内置异常同时指定捕获粒度class SampleValueError(ValueError): 样本数据不符合格式约定 class EmptyFeatureError(SampleValueError): 特征列表为空 def parse_row(raw): try: value int(raw) except ValueError as exc: raise SampleValueError(坏行: repr(raw)) from exc这样调用方可以只捕获 EmptyFeatureError 做精细化处理也可以捕获 SampleValueError 处理整类样本问题还可以故意不捕获让真正的 TypeError 冒出来。自定义异常继承哪个内置异常不是随便选的它决定了你的异常能跟哪条内置捕获链兼容。验证捕获粒度的方法是在测试里故意抛一个深层子类确认外层 except 分支不会把不该接的错接走。注意raise ... from exc 会把原始异常挂到cause上保留完整的回溯链排查时不要省这一步。4.3eq与hash为什么「能比较」的对象反而进不了集合现象给类实现了eq让两个内容相同的对象比较相等逻辑一切正常把它们放进 set 或者当字典键直接报 TypeError: unhashable instance。原因Python 3 有一条隐式规则类里定义了eq而不定义hash解释器会把hash置为 None。动机是防止可变对象进哈希容器后字段改变导致哈希值改变、查找失效。所以「可以比较相等」的对象默认不再「可以哈希」。解决先想清楚对象是否可变再决定怎么处理哈希class Point: def __init__(self, x, y): self.x x self.y y def __eq__(self, other): return self.x other.x and self.y other.y def __hash__(self): return hash((self.x, self.y)) a, b Point(1, 2), Point(1, 2) print(a b) # True print(len({a, b})) # 1正常去重上面这个 hPoint 假设字段不可变如果字段可能在生命周期内被修改实现hash也没用——对象放进字典后再改字段哈希值变了dict 内部找不到原来的桶甚至可能把字典结构搞乱。可变对象要么别实现值相等的eq要么永远不进哈希容器。判断一个类能不能进集合顺序应该是「它可变吗」在先而不是「它相等吗」在先。4.4 dataclass 与 namedtuple选错容器之后的内存与可变性代价现象项目里同样一份「样本」数据有人用 NamedTuple 定义有人用 dataclass 定义打印出来差不多但前者不能赋值、后者能赋值于是代码里出现大量互相转换的兜底逻辑。原因两者实现机制完全不同。NamedTuple 生成的是 tuple 的子类实例没有dict因此更省内存、天生是序列能解包、能按索引取dataclass 是在类体执行完后注入init、repr、eq的普通类实例默认带dict字段可变、支持继承和自定义方法。选择标准不是「哪个更现代」而是这个对象是否真的需要被当成序列使用、是否需要在生命周期里改字段。快速对照两种写法from dataclasses import dataclass from typing import NamedTuple class SampleNT(NamedTuple): features: tuple target: str dataclass(frozenTrue) class SampleDC: features: tuple target: str这两个版本整体都不可变SampleNT 因为是元组子类支持索引和解包SampleDC 更适合承载自定义的校验逻辑。如果数据量是百万级别又不需要动态加属性可以给 dataclass 手动加slots () 或者直接用 NamedTuple内存占用差距在压测里非常明显。还有一个高频误解frozenTrue 只禁止字段重新赋值如果字段本身是 listlist 的内容依然可以修改——「深冻结」在 dataclass 里并不存在。需求方向推荐选型需要索引、解包、按位置访问NamedTuple需要频繁修改字段dataclass默认可变百万级实例、内存敏感NamedTuple 或带slots的 dataclass需要继承、校验、自定义方法dataclass5. 设计取舍为什么 Python 里「能组合就别继承」尤其成立在有编译器的语言里继承关系会被编译器反复校验错用继承的代价很大程度被拦在编译期之前。Python 没有这道闸门继承一旦铺开MRO 上的每个类都变成隐性协议改一个基类的签名影响面几乎只能靠人工推演想调整 super() 的参数得把整条链条上的类都翻一遍。相比之下Python 提供了异常便宜的组合手段函数是一等对象dataclass 十行就能定义一个值对象lambda 和 partial 可以随时把行为打包传进另一个对象。那些经典设计模式来到 Python 语境后几乎都退化成「传个函数就行」——Strategy 不需要一组类一个闭包足够Template 不需要模板方法骨架把变化点作为参数注入就是同一件事。我一般会用这本书的 k-NN 案例做验证实验先按继承方案定义 Distance 抽象基类和两个距离子类再改成把距离函数作为构造参数注入分类器跑同一组测试用例。结果通常很一致——组合版本少了一半的类测试用例不用变因为行为差异本来就该是函数差异。真正的继承需求依然存在但场景收敛到「接口契约 多态分发」这种类型层面的抽象比如自定义 ABC 要求所有插件实现必须实现某个方法。想继续验证这个边界的人可以顺着这套思路去读这份第四版资源里第 10 到 14 章的案例代码把设计模式和测试、并发放在一起看取舍会更清楚。从那以后我每次新建类都会先问一句这真的需要一个 is-a 关系还是仅仅一个行为差异只要答案偏向后者默认组合把继承的额度和mro的复杂度留给那些真正需要类型契约的公共接口。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站