1. 继承很容易维护很难先从写代码的日常痛点说起做Python开发这些年我接手过不少继承体系庞大的项目也亲手维护过那种六层深、十几个父类的代码。说实话刚开始写继承的时候感觉特别爽子类里只需要写自己的差异部分父类的方法直接拿来用代码量肉眼可见地少。但等到项目跑了大半年需求开始频繁改动继承带来的麻烦就一件接一件地冒出来了。最典型的一个场景你有一个BaseHandler里面定义了process()、validate()、notify()三个方法下面十几个子类各自实现差异逻辑。某天产品说要改通知方式你在基类里动了notify()的默认行为结果线上突然出现了三四个子类的异常——因为有些子类早就重写了notify()还有的子类在process()里间接依赖了notify()的旧行为。这种牵一发而动全身的体验写过的人都知道有多崩溃。这也是为什么在Python社区里协议优于继承Protocols over Inheritance成了比较经典的设计思想。它不是说不让你用继承而是提醒你很多场景下你真正需要的是行为约定而不是类型归属。Python里有一套成熟的协议机制比如迭代器协议、上下文管理器协议、描述符协议它们定义的是你只要实现了哪些方法就具备什么能力而不是你必须从哪个类派生才能被当成什么用。这篇文章我就围绕这个思想把协议和继承的取舍逻辑、典型改造方案、以及我在实操中踩过的坑一次性讲清楚。这个主题特别适合已经写过一阵子Python、开始思考代码结构的人也适合正在从其他语言转Python的人。如果你只熟悉Python的基础语法不太清楚协议到底是个什么东西下文我会从最底层的鸭子类型讲起保证你看完能直接用。2. 协议到底是什么先抛开类继承这套思维很多人第一次听到协议这个词第一反应是网络协议、通信协议。其实在Python的面向对象语境里协议的意思类似它就是一套行为上的约定。HTTP协议约定客户端发什么格式的请求、服务端回什么格式的响应谁都不需要是HTTP的儿子只要遵守这套约定互相就能协作。Python的协议也是这个逻辑它约定的是你只要实现了某些特殊方法那么别人就可以按约定方式来使用你。2.1 鸭子类型是协议的思想土壤在Python里有个很经典的比喻如果一只鸟走起来像鸭子、游泳像鸭子、叫起来像鸭子那它就是鸭子。放在代码层面就是说一个对象能不能被使用不取决于它是不是继承自某个特定类而取决于它有没有实现所需的方法。我举一个最简单的例子比如说求和函数def calc_sum(items): total 0 for item in items: total item return total这个calc_sum函数根本不关心你传进来的items是list、tuple、range还是自定义的集合类。只要它支持迭代也就是实现了__iter__()方法就能正常运行。从传统Java那种接口继承类型约束的角度看list、tuple、range之间并没有共同的父类但它们都会迭代所以它们都是可迭代的。这就是鸭子类型而协议就是鸭子类型在标准库层面的一种规范化表达与其说这个东西是某某类的实例不如说这个东西具有某某能力。这种思维方式一旦扭转过来你会发现很多代码设计上的死结都能解开。2.2 Python里常见的协议有哪些很多新手接触协议是从with语句开始的。with open(...) as f:这个写法每个人都用过但只有一部分人知道背后是上下文管理器协议在起作用。其实Python的协议远不止这一个我把日常开发中会用到的整理成一张表协议名称需要实现的关键方法典型使用场景迭代器协议__iter__()、__next__()自定义可迭代对象支持for循环上下文管理器协议__enter__()、__exit__()资源管理配合with语句序列协议__len__()、__getitem__()让自定义对象支持切片、索引、in判断可哈希协议__hash__()、__eq__()对象作为dict的key或放入set描述符协议__get__()、__set__()、__delete__()属性访问控制常用于ORM框架可调用协议__call__()让实例像函数一样被调用数值运算协议__add__()、__mul__()、__lt__()等自定义运算行为常用于数值型的业务对象以序列协议为例如果你实现了一个类里面定义了__len__()和__getitem__()那么你不需要继承list这个类的实例就已经可以支持len()、obj[0]、for x in obj、x in obj这些常见操作了。Python的解释器会去查你有没有这些方法而不是查你是谁的子类。这是协议和继承最本质的区别继承是你先告诉我你是谁我再决定你能不能做这件事协议是你先把这件事做了我就认定你能做这件事。放在复杂的业务系统里后者比前者灵活得多因为你不必为了某个能力去硬造一个空洞的继承链。3. 为什么协议优于继承三个角度拆解设计取舍说了这么多概念接下来进入正题具体从哪几个角度来说协议优于继承。这不是一个抽象的口号而是有着非常具体的代码结构和维护成本上的考量。我分别从耦合度、扩展性和语义清晰度三个角度展开。3.1 继承会让能力和身份强行绑定继承建立的是一个is-a的关系Dog继承Animal意思是狗是一种动物。这个关系在真实世界里很自然但在软件系统里经常被用错。举个例子你有一个业务系统里面有Order订单类、Product商品类、Refund退款单类。这三个业务对象都需要把数据导出成JSON字符串。如果用继承来设计你可能想建一个BaseModel基类里面放一个to_json()方法然后让三个类都继承它。表面上看没问题但如果哪一天来了一个ShippingLog物流日志对象它不需要所有BaseModel的方法只需要导出JSON这个能力你怎么办再让它继承BaseModel然后它平白多了一堆用不上的属性方法继承链也越来越杂乱。用协议方案就简单了定义to_json()这个协议需要的方法谁需要这个能力谁就实现to_json()。不需要的时候不实现就是了。关键在于Order、Product、Refund之间也许完全没有公共父类的必要但它们在可JSON序列化这件事上达成了共识这就够了。一旦把能力从身份里拆出来你的代码就不需要那些长得像面条一样纠缠不清的继承结构了。我看到很多项目里的基类越滚越大里面堆了几十个方法但没有任何一个子类用得上全部方法——这种上帝基类基本就是继承用歪了的典型症状。3.2 修改成本协议改起来比继承安全得多继承有个我很不喜欢的特点子类会自动拿到父类的所有公开方法而且更重要的是父类方法内部对子类会怎么重写是一无所知的。一旦父类方法的行为变化所有直接或间接重写、组合这些方法的子类都可能被波及。我用一段简化的代码来演示这种风险。假设你有一个基类PayService以及两个子类class PayService: def __init__(self): self.fee 100 self.discount 0.9 def final_fee(self): return self.fee * self.discount class VipPay(PayService): def final_fee(self): return super().final_fee() 10 class StormPay(PayService): def final_fee(self): return self.fee * 0.5如果某天你为了修复一个优惠叠加的Bug把基类里的discount从固定的0.9改成了根据用户等级动态计算那么这两个子类的行为都会被影响但StormPay其实根本不该受到这个影响因为它自己重写了final_fee()压根不需要基类的折扣。这种隐性破坏排查起来真的够你加班一整晚。如果改成协议方案就不存在这个问题了。每个实现了final_fee()的类都是自包含的你自己的方法自己做主不依赖一个看不见的父类内部状态。改动一个类不会顺着继承链传导到其他地方。3.3 语义表达阅读代码时心智负担更小还有一个很容易被忽略的点就是读代码时的理解成本。继承体系读起来有个问题你想看懂一个子类的方法往往得顺着父类、祖类一层层往上翻看super()到底调到哪一层去了。特别是三层以上的继承每多一层你的工作记忆就要多占一份。协议方式读起来直接得多一个类实现了__iter__、__next__好它支持迭代实现了__enter__、__exit__好它能配合with用。不需要去追查它继承的某个父类的某个祖先类是否定义了某个钩子方法。Python语言本身其实也是这么设计的标准库里有大量基于协议而非继承的抽象比如collections.abc里那些抽象基类本质上是协议的规范描述而不是让你硬套的继承模板。4. 实操指南把继承改造成协议驱动的三个典型场景现在到了最有实操价值的部分。我从三个最常遇到的场景出发完整演示怎么把继承体系重构成协议驱动。每个场景我都会给出改动前后的代码、思路对比以及改造过程中需要关注的细节。4.1 场景一用上下文管理器替代模板方法模板方法是继承设计模式里的经典套路父类定义流程骨架子类实现钩子方法。但这种设计在Python里很多时候是不必要的。举个例子你写了一个文件处理的基类class FileProcessor: def run(self, path): f open(path, r) try: data f.read() return self._process(data) finally: f.close() def _process(self, data): raise NotImplementedError子类继承后重写_process()。发现问题了吗这个run()方法根本不需要继承就能实现。Python的上下文管理器协议专门就是解决打开资源、处理、关闭资源这个问题的。用contextlib配套改造完会是这样的效果from contextlib import contextmanager contextmanager def open_file(path): f open(path, r) try: yield f finally: f.close() def process_data(path, handler): with open_file(path) as f: data f.read() return handler(data)你发现改动后的关键变化没有handler是一个普通的函数或可调用对象任何callable都可以传进来不再需要通过继承来实现某个特定的_process。你想处理什么逻辑直接传对应的函数就能搞定。这就是协议思想with语句只关心你有没有实现上下文管理器协议的方法handler只关心你是不是可调用的。这个改造对测试也特别友好。原来测试某个子类的时候要先构造一个类实例再调用run()现在直接传一个普通的函数进去测不需要为每种处理逻辑都新建一个类测试代码能少写一大截。4.2 场景二用迭代协议替代一棵对象树面向对象设计里有个著名的问题组合优于继承。而组合和协议搭配起来能解决更复杂的一类问题。我这里说的是树形结构的业务场景比如一个部门、子部门、成员的三级组织架构。如果你用继承来设计可能会想到一个Node基类然后DepartmentNode继承它、MemberNode继承它遍历的时候用isinstance来判断类型再递归遍历子节点。这样的代码要写不少if isinstance分支而且每加一种新节点类型就要改遍历逻辑。用协议的思路就不一样。定义一个可迭代的协议让部门对象返回遍历子节点的迭代器而具体的部门类型、成员类型如何组织是它们自己的事。树形遍历完全交给for循环来处理业务代码里不再出现isinstance判断。class Department: def __init__(self, name, childrenNone): self.name name self.children children or [] def __iter__(self): return iter(self.children) def walk(node): if isinstance(node, str): return yield node.name for child in node: yield from walk(child)这段代码里Department没有继承任何抽象基类但只要你给它塞进去的children是可迭代的这棵树就能正常遍历。如果你把str成员和Department成员混着放walk函数也能正确区分叶子节点和分支节点根本不需要它们有同一个父类。4.3 场景三用singledispatch让重载变优雅有一种很常见的用继承的理由针对不同类型做不同处理。很多人天然写出一堆子类重写同一个方法。其实Python标准库里的functools.singledispatch就已经把根据类型分派这件事做成了协议式的。看一个实际例子。你有一个导出系统要把订单数据、用户数据、库存数据分别导成Excelfrom functools import singledispatch singledispatch def export_to_excel(data): raise TypeError(f不支持的导出类型: {type(data)}) export_to_excel.register def _(data: dict): return f导出字典数据: {data} export_to_excel.register def _(data: list): return f导出列表数据: {data}这里完全不需要建一个Exporter基类也不需要每个数据类型对应一个子类。你只需要为每种数据类型注册一个处理函数分派逻辑完全交给语言层面的协议机制去完成。新增一种类型的时候你不需要动已有的任何代码只需要再注册一个函数就行。这在扩展性上是压倒性的优势。这里有个细节值得注意singledispatch匹配的是类型而不是继承关系。如果你传进来一个自定义的OrderDict对象只要它真的继承自dictsingledispatch也会优先找到dict的处理函数。但如果你用协议思维去理解它查找的是最匹配的处理函数而不是你是不是某个类的子类。实际使用中我还经常把singledispatch跟协议方法结合定义一个__as_export_rows__()方法来让每个业务对象自己决定如何导出导出引擎只认这个协议方法。这样做的好处是数据类不需要知道Excel格式的细节导出引擎也不需要知道业务类的细节两者通过一个协议方法解耦。5. 常见问题与排查技巧实录我踩过的那些坑前面讲了很多协议的好处但并不代表协议实践一路顺风。我在真正把项目往协议方向改造的时候碰到的坑一个接一个。这里面有些是Python语法本身的坑有些是设计思路上容易摇摆的点我把典型问题整理成一份排查速查表顺便把我自己摸索出来的经验一起写出来。5.1 协议方法容易被魔法方法的命名束缚很多人第一次尝试协议首先会遇到一个不习惯的点协议要求的特殊方法名比如__iter__、__enter__都是有固定语法地位的。你不能把一个协议方法像普通方法一样随意调用比如直接调obj.__iter__()不是不行但写起来很奇怪而且有些协议方法在特定语境下是解释器自动调用的你调用得太频繁反而容易混乱。最常见的困惑是__getitem__一旦定义了obj[0]这种语法就走这个协议。如果你业务里恰好有一个叫获取第几个子项的业务方法也叫这个名字语义上会非常冲突。我的经验是不要让协议方法承担过多业务语义协议方法的职责就限定在让这个对象具备某种语言级能力。如果你需要业务上的方法另起一个普通方法名内部再调用协议方法的实现而不是反过来。5.2 协议改造不能一步到位我见过一些同事兴致勃勃地把一个大型继承体系改成协议驱动结果改到一半发现某些子类在别处被isinstance(obj, BaseClass)判断依赖了一改立刻爆出一片TypeError。这就是改造前没做依赖扫描的后果。我在实操中的做法是先用pylint或者简单的IDE引用扫描找出所有isinstance判断的地方评估这些判断是不是真的需要类型归属。如果只是用某个能力就改成能力检测——Python里最朴素的检测方式是hasattr(obj, 某个方法名)更规范的方式是检查collections.abc里的抽象基类比如isinstance(obj, Iterable)。把判断逻辑从你是什么改成你能做什么之后再逐步移除继承关系。然后还要注意不要在同一个类里既保留基类继承又实现一堆协议方法这样很容易让人看不懂这个类到底走的是哪一套逻辑。我推荐的节奏是分三步走。第一步先把需要的行为方法从基类方法里拆出来写成独立的函数或者混入式的协议方法。第二步把调用方的isinstance判断全部替换成能力检测。第三步把所有子类的继承基类完全摘掉让每个类都变成独立类只保留协议实现。每一步之间都要跑一遍完整测试不要指望一步到位。5.3hasattr和EAFP风格的选择协议思维下经常需要判断对象是否支持某个协议。除了hasattr以外Python还有一个更地道的风格EAFP也就是先假设能行不行再处理异常。比如# 用hasattr判断 if hasattr(obj, __iter__): for item in obj: pass # 用EAFP风格 try: for item in obj: pass except TypeError: pass两种写法都能用但我更推荐在性能敏感或者代码简洁性优先的场景下用EAFP。因为for循环本身就在尝试迭代如果对象不可迭代它会直接抛TypeError你只需要处理一个异常而不需要做一次额外的属性查找。在异常开销可接受的场景里这种写法读起来流畅得多。当然如果你的逻辑分支比较重比如不可迭代时要走一套完全不同的流程那用hasattr或者isinstance先判断会更明确一些。风格的取舍没有绝对标准但有一条原则我很认同让代码读起来像在描述业务行为而不是在描述类型判断。5.4 测试的时候注意力容易放错地方还有一个容易踩的坑就是测试协议实现的时候只看方法存在而不看行为正确。比如有人写测试用例断言一个对象实现了__iter__然后只测它能不能被迭代但从来不测迭代的顺序、边界条件。协议方法看起来简单其实容易出错的地方很多尤其是自定义迭代器__next__必须记得在合适的时候抛StopIteration否则就是死循环或者异常。我的建议是不要直接测协议方法本身而是测那些使用协议的语法。比如你想验证一个对象是否实现了上下文管理器协议不要直接调__enter__而是写一个with语句去测资源释放的行为。你想验证序列协议就用切片、len、布尔判断这些语法去测。语法走通了协议才算真正实现对了。这样做还有一个额外的好处就是测试用例读起来更像真实使用场景别人维护起来也更容易理解。5.5 别为了协议而协议最后说一个心态上的问题。协议优于继承这是针对大多数场景的经验总结但并不是说继承一无是处。有些场景继承依然是很好的选择比如多个子类确实共享一大段公共状态和公共行为而且这些行为短期内不会变化那用继承减少重复代码是合理的。如果子类和父类之间有明确的is-a逻辑关系且整个继承链的层级不超过两层继承的维护成本也不高。但一旦你发现基类里的方法开始出现有的子类要、有的子类不要的情况或者基类改一行代码需要花很久去排查影响面那就该考虑协议方案了。用一句直白的话说通用能力用协议身份归属用继承。两者的区分点在于你是在描述这个对象能做什么还是在描述这个对象本质上是什么。能做什么的事交给协议本质上是什么的事才值得用继承。6. 一些实战体会协议思维是长期回报型投资在我把这些想法落到实际项目之后最直观的感受是代码的做手术能力变得更强了。以前要给一个基类加功能心里总会打鼓担心波及太多子类现在每个类都是独立个体只是共用一个行为约定改哪个类就只影响哪个类排查问题的范围瞬间缩小。还有一个感受比较深的地方新同学上手代码的速度明显快了。继承体系下新同学要梳理一整条继承树才能搞清楚每个对象的行为来源而协议驱动的代码每个类就摆在眼前方法名直白地告诉你能做什么——看到__iter__就知道可以迭代看到__enter__就知道要用with包起来理解成本非常低。如果你刚接触这个思想建议先从一个小模块开始试水比如挑一个只有两层继承、两三个子类的模块试着把它改成协议驱动。感受一下不再需要关心父类状态是什么体验然后逐步把更大的模块纳入改造范围。这个过程中请务必做好补丁级别的测试每改一步就验证一步因为协议改造不像重构算法那样影响范围集中它改的是对象之间的协作方式出问题往往是跨模块的。最后聊一个我最近实践的技巧在团队协作里把常用的协议方法列成一份内部约定文档写清楚什么对象应该实现什么协议方法、这些协议方法应该表现出什么行为。这份文档不需要多正式重点是让大家在新增对象的时候先问一句这个对象进入系统之后需要支持哪些语法操作而不是一上来就想它应该继承谁。这一问的区别往往就是代码长期维护质量的分水岭。我自己做完这个转变之后最常说的一句话是在Python里你不需要成为谁的子类才能获得谁的能力。能力写在方法里约定写在协议里这就够了。
