鸭子类型要到调用时才报错,那时离犯错的地方已经很远。抽象基类和 Protocol 都能解决这个问题,只是方向正好相反。
Python 不会检查对象是否真有你要调用的方法。这就是鸭子类型。Python 代码之所以很容易拼装在一起,靠的就是它;拼写错误之所以会在莫名其妙的地方冒出来,也是因为它。
鸭子类型的痛处
class JsonExporter:
def export(self, rows): return f"json:{len(rows)}"
class CsvExporter:
def export(self, rows): return f"csv:{len(rows)}"
class Broken:
def exprot(self, rows): return "typo" # misspelled
def run(exporter, rows): return exporter.export(rows)
print(run(JsonExporter(), [1,2]), run(CsvExporter(), [1,2]))
try:
run(Broken(), [1,2])
except AttributeError as err:
print(type(err).__name__ + ':', err)
输出:
json:2 csv:2
AttributeError: 'Broken' object has no attribute 'export'
两个正常的类没有共同的基类也能工作,鸭子类型在这里物有所值。Broken 失败了,但失败发生在 run 内部、调用的那一刻,可能是在生产环境里,而且回溯信息(traceback)指向的是调用方,而不是那个拼错的类。
有两种工具能解决这个问题,从相反的两端入手。
抽象基类:类预先作出承诺
from abc import ABC, abstractmethod
class Exporter(ABC):
@abstractmethod
def export(self, rows): ...
def describe(self): return f"{type(self).__name__} exporter"
class GoodExporter(Exporter):
def export(self, rows): return f"good:{len(rows)}"
print(GoodExporter().describe(), GoodExporter().export([1,2]))
class BadExporter(Exporter):
pass
try:
BadExporter()
except TypeError as err:
print(type(err).__name__ + ':', str(err)[:80])
输出:
GoodExporter exporter good:2
TypeError: Can't instantiate abstract class BadExporter without an implementation for abstr
失败提前到了实例化的时候,早得多,也清楚得多。抽象基类还能附带共享的行为,比如这里的 describe,Protocol 做不到这一点。
代价是:类必须继承你的抽象基类。你又回到了继承关系,第 6 篇讲过的那些问题都会跟着回来。
Protocol:调用方说明自己需要什么
from typing import Protocol, runtime_checkable
@runtime_checkable
class SupportsExport(Protocol):
def export(self, rows) -> str: ...
print('JsonExporter matches :', isinstance(JsonExporter(), SupportsExport))
print('Broken matches :', isinstance(Broken(), SupportsExport))
print('JsonExporter subclasses SupportsExport?', issubclass(JsonExporter, SupportsExport))
输出:
JsonExporter matches : True
Broken matches : False
JsonExporter subclasses SupportsExport? True
JsonExporter 从没听说过 SupportsExport,却照样匹配,因为它有对的方法。这就是结构化类型:形状就是契约。类型检查器会在代码运行之前检查这一点;加上 @runtime_checkable,还能用 isinstance。
注意,运行时检查只确认方法存在,不检查签名。真正干活的是静态检查器。
起决定作用的区别
class ThirdParty: # imagine this is in a library
def export(self, rows): return f"vendor:{len(rows)}"
print('Protocol accepts it :', isinstance(ThirdParty(), SupportsExport))
print('ABC accepts it :', isinstance(ThirdParty(), Exporter))
输出:
Protocol accepts it : True
ABC accepts it : False
你没法让第三方的类继承你的抽象基类。但你完全可以写一个它本来就满足的 Protocol。
取舍就这么简单:
- 抽象基类:所有实现都归你管,而且除了要求接口,你还想共享行为。
- Protocol:实现来自别处,或者你只是想描述函数需要什么,而不要求任何人继承任何东西。
要点
- 鸭子类型在调用处才失败;两种工具都能让失败提前。
- 抽象基类在实例化时强制检查,能带共享代码,但要求继承。
- Protocol 是结构化的:形状对得上的都能匹配,包括你控制不了的代码。
@runtime_checkable只检查方法名是否存在,签名交给类型检查器。
函数参数优先用 Protocol:它说明你需要什么,却不限制谁来提供。当你在构建一组归自己管、而且确实共享行为的类时,再用抽象基类。