Python 继承的两条路:ABC 与 Protocol,到底差在哪?
写 Python 的人迟早会碰到同一个问题:我想定义一套「契约」——规定某些类必须实现某些方法,然后让别的代码依赖这套契约而不是具体实现。从 Python 3.0 的 `abc` 模块,到 Python 3.8 引入的 `typing.Protocol`,官方前后给了两套机制。但很多人用了很久,仍说不清:为什么有时候要 `class Foo(ABC)`,有时候只要写个 `Protocol` 就够了?选错方案,轻则代码耦合、测试难写,重则和第三方库「明明方法都对得上却报错」。这篇文章把两者的本质差异拆开讲清楚。
一、第一条分水岭:名义继承 vs 结构类型
ABC(Abstract Base Class)走的是“名义继承(nominal)”路线。一个类想「算作」某种接口,必须显式地继承它——把名字写进括号里。只要你漏掉了 `class Dog(Animal)` 这一句,`Dog` 跟 `Animal` 在类型系统里就毫无关系。
from abc import ABC, abstractmethod
class Animal(ABC):
@abstractmethod
def speak(self) -> str: ...
class Dog(Animal): # 必须显式继承,名字写进括号
def speak(self) -> str:
return "Woof"
d = Dog()
print(isinstance(d, Animal)) # True
Protocol 走的是**结构类型(structural)**路线,也就是「鸭子类型」的正规化。只要一个类拥有约定好的方法和属性,它就自动满足这个协议——**不需要继承,也不需要 import**。
from typing import Protocol
class Greeter(Protocol):
def greet(self) -> str: ...
class Cat: # 没有继承 Greeter,也没 import 它
def greet(self) -> str:
return "Meow"
def say_hi(g: Greeter) -> None:
print(g.greet())
say_hi(Cat()) # Meow,照样能跑
这就是最本质的区别:ABC 看「你是谁的儿子」,Protocol 看「你会不会叫」。前者把类硬塞进一条继承树,后者只看行为对不对得上。一个要「认祖归宗」,一个讲「英雄不问出处」——后者对第三方库尤其友好,因为你没法改人家的源码去加个 `(ABC)`。
二、第二条分水岭:运行时强制 vs 静态检查
ABC 的约束是**运行时**生效的。漏写一个抽象方法,解释器在你 `new` 的那一刻就抛 `TypeError`,程序根本起不来。这个强制力是「焊死」的。
class BadDog(Animal):
pass
# BadDog() # TypeError: Can't instantiate abstract class BadDog
Protocol 的约束**只对类型检查器(mypy / pyright)可见**,运行时完全被「擦除」。下面这段代码 Python 绝不会报错,只有跑类型检查时才会提示 `Empty` 不符合 `Broken`——而且前提是你的类型检查器配置正确、且真的去检查了。
class Broken(Protocol):
def greet(self) -> str: ...
class Empty:
pass
e: Broken = Empty() # 运行时:什么事都没有
# 类型检查:Incompatible types (assignment)
一强一弱,恰好对应两种需求:ABC 适合「我必须保证没人能绕过约束」的框架核心;Protocol 适合「我只想让代码更好读、让编辑器帮我抓低级错误」的协作场景。
三、第三条分水岭:耦合与 isinstance 的代价
名义继承的副作用是**强耦合**。凡是想满足 ABC 的类,都得 import 它、继承它——哪怕这个类来自你控制不了的第三方库。想让外部类「沾上关系」,只能走 `register()` 这种后门式注册,别扭且容易忘。
class ThirdPartyRobot:
def speak(self) -> str:
return "Beep"
Animal.register(ThirdPartyRobot) # 手动登记
print(isinstance(ThirdPartyRobot(), Animal)) # True
Protocol 反过来,把耦合降到零:第三方类一行都不用改,方法齐了就被自动识别。代价是 `isinstance` **默认用不了**——因为 Protocol 在运行时不存在了。想用,得加 `@runtime_checkable`,但它**只检查方法名存不存在,不校验签名和返回类型**:
from typing import Protocol, runtime_checkable
@runtime_checkable
class Greeter(Protocol):
def greet(self) -> str: ...
class Wrong:
def greet(self, name: str) -> str: # 签名其实不对
return "hi"
print(isinstance(Wrong(), Greeter)) # True,但类型其实不匹配
Protocol 配 @runtime_checkable 保留了 isinstance 的运行时检查能力,但它只校验方法名存在,不校验签名和返回类型,我们将这部分校验委托给 mypy strict 模式在 CI 阶段完成。这是两层分工——运行时做轻量的鸭子类型检查保证灵活度,静态期做完整的签名校验保证类型安全,把校验从运行时移到了静态期,换来了零耦合的同时不牺牲类型安全。
四、实战怎么选
把三条分水岭合起来,选型其实清晰:
用 ABC,当:你在写框架 / 插件系统,要强制所有实现者遵守契约;或者你需要可靠的 `isinstance` 与运行时分发;或者你想把相关类在继承树上分门别类、一眼看清归属。
用 Protocol,当:你在适配第三方库、写鸭子类型代码、做依赖解耦;或者你只想获得静态类型检查的好处,又不想污染别人的类层级;或者你写的是函数参数 / 返回值的「形态标注」。
结尾
我的两条判断:第一,新项目里凡是「只为类型检查、不强制运行时」的接口,优先用 Protocol,它能把依赖降到最低、把测试写得最爽;第二,只有当你确实需要「焊死」约束、或在运行期做 `isinstance` 分发时,才上 ABC,别为了「显得规范」而滥用继承——继承树越长,重构越疼。
更多推荐
所有评论(0)