Python 的接口设计有两条常见路径:一条是靠鸭子类型和约定形成的“非正式接口”,另一条是用 abc 模块声明的“正式接口”。这类知识很容易停留在概念层面,但真正写业务代码时,接口决定了模块边界、测试替身、插件扩展和错误暴露的时机。
Python 接口不是只有 interface 关键字
很多从 Java、C# 转到 Python 的开发者会先找 interface 关键字,然后发现 Python 没有这个东西。原因不是 Python 不需要接口,而是它把“对象能做什么”放在了“对象是什么”前面。
典型例子是文件对象。很多函数并不关心参数是不是 io.TextIOWrapper,只要它有 .read() 方法就能工作:
from io import StringIO
def count_words(reader):
text = reader.read()
return len(text.split())
print(count_words(StringIO("Python interfaces are practical")))
这里的 reader 就遵守了一个非正式接口:它应该提供 read()。没有继承,没有显式声明,但代码能跑。这就是鸭子类型的味道:如果它像文件一样能读,那就把它当作可读对象使用。
这种方式轻、快、灵活,适合小范围协作和内部工具。但代价也明显:如果传错对象,错误通常会在运行到 .read() 那一行时才出现。
非正式接口:靠约定,也要靠测试兜底
非正式接口不是“随便写”。它依赖清晰命名、文档、类型标注和测试来维持约定。
可以这样实践:用类型协议描述你期望的方法,即使运行时不强制,也能让编辑器和类型检查工具提前发现问题。
from typing import Protocol
class Reader(Protocol):
def read(self) -> str:
...
def preview(reader: Reader, limit: int = 10) -> str:
return reader.read()[:limit]
class MemoryReader:
def __init__(self, text: str):
self.text = text
def read(self) -> str:
return self.text
print(preview(MemoryReader("interface by behavior")))
运行:
python reader_protocol.py
这里的 Protocol 属于静态类型层面的接口表达。它仍然保留了鸭子类型的灵活性:MemoryReader 不需要继承 Reader,只要方法形状匹配即可。
正式接口:用 abc 把错误提前
当接口是插件系统、支付通道、存储后端、消息发布器这类关键边界时,只靠约定可能太松。Python 的 abc 模块可以定义抽象基类,把“必须实现哪些方法”写进类定义里。
下面是一个可直接运行的例子:
from abc import ABC, abstractmethod
class NotificationSender(ABC):
@abstractmethod
def send(self, recipient: str, message: str) -> None:
raise NotImplementedError
class EmailSender(NotificationSender):
def send(self, recipient: str, message: str) -> None:
print(f"Email to {recipient}: {message}")
class BrokenSender(NotificationSender):
pass
sender = EmailSender()
sender.send("dev@example.com", "Build finished")
try:
BrokenSender()
except TypeError as exc:
print(f"Cannot instantiate BrokenSender: {exc}")
保存为 abc_interface_demo.py 后运行:
python abc_interface_demo.py
你会看到 EmailSender 正常工作,而 BrokenSender 在实例化阶段就失败。这个失败时机很重要:系统启动、插件加载或测试初始化时就能发现缺失实现,而不是等到线上某条路径调用时才爆炸。
什么时候选鸭子类型,什么时候选 abc
两种接口风格没有绝对高下,关键看边界的重量。
适合非正式接口的场景:
- 函数只需要一两个简单方法,比如
.read()、.write()、.close()。 - 对象生命周期短,调用链清楚。
- 你希望测试替身很容易构造。
- 团队已经用类型标注和测试覆盖接口行为。
适合 abc 的场景:
- 多个实现类必须长期保持一致,比如
S3Storage、LocalStorage、MemoryStorage。 - 插件由不同团队或外部用户编写。
- 缺失方法应该在启动或实例化时尽早失败。
- 抽象基类本身还要提供共享模板方法或默认行为。
可以把选择标准压缩成一句话:越靠近核心边界,越值得显式;越靠近局部协作,越可以保持轻量。
落地检查清单
给 Python 项目设计接口时,可以按下面几项过一遍:
- 这个接口是局部约定,还是跨模块、跨团队的契约?
- 调用方真正需要哪些方法?不要为了“完整”塞进暂时用不到的能力。
- 错误应该在类型检查、实例化、还是调用时暴露?
- 是否需要
Protocol帮编辑器和 CI 做静态检查? - 是否需要
abc阻止不完整实现进入运行时? - 测试是否覆盖了所有实现类的共同语义,而不只是方法存在?
Python 的接口设计不追求仪式感。好的接口应该让对象替换更容易,让错误更早、更清楚地出现,也让调用方少知道一点实现细节。鸭子类型给你速度,abc 给你边界;成熟的代码库通常会同时使用两者。