写 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,别为了「显得规范」而滥用继承——继承树越长,重构越疼。

更多推荐