AI 技术博客
返回首页
Python 基础 18 分钟阅读

多态:鸭子类型与统一接口

Python面向对象多态鸭子类型ABC

引言

第八章的面向对象之旅走到第六篇,最后一个核心特征是多态(polymorphism)。前五篇打下的基础在此汇聚:第 3 篇的方法与 self 让我们知道「方法总是绑定在具体对象上」;第 5 篇的继承让我们知道「子类可以覆盖父类方法」。把两者结合起来,就出现了一个极其重要的局面:同一个方法名,在不同类型的对象上,做出不同的行为——这就是多态。

你可能已经在不知不觉中使用过它:len("python") 与 len([1, 2, 3]) 都能用,for x in obj 能遍历列表、字符串、字典——它们类型完全不同,语法却完全一致。本篇要把这种「理所当然」讲成「原理」:多态的定义、Python 特有且贯穿全语言的鸭子类型(duck typing)、isinstance/issubclass 类型检查的适用边界,以及抽象基类(ABC)如何把「接口契约」写进代码。阅读前提:已掌握前五篇(尤其继承与覆盖);魔法方法在语法层面的细节留给第 7 篇,本篇只借用 len()、迭代等「你已会用」的现象。实战建模在第 8、9 篇。

概念与原理

多态:同一接口,不同实现

多态一词来自希腊语 poly(多)+ morph(形态),字面意思就是「多种形态」。在 OOP 语境里,它的标准定义是:调用方只依赖「接口」——即一组方法名与参数约定——而不依赖对象的具体类。同一个调用 shape.area(),圆形算 πr²,矩形算长乘宽;同一个 account.withdraw(200),普通账户直接扣,信用账户可能还要检查透支额度。变的是实现,不变的是接口。

多态的价值在于解耦。假设要写一个汇总函数:把一堆图形面积加起来。如果没有多态,你不得不写 if isinstance(s, Circle): ... elif isinstance(s, Rect): ...,每加一种新图形就要改这个函数——函数依赖了每一个具体类。有了多态,函数只写一遍 s.area(),新图形来了不用改旧代码。这就是软件工程里「对扩展开放、对修改关闭」(开闭原则)在语言机制上的支撑。静态类型语言(Java、C++)靠继承与虚函数实现多态,而 Python 是动态类型语言,实现方式更宽松——就是下面要讲的鸭子类型。

鸭子类型:只要会走,就当它是鸭子

「当一只鸟走起来像鸭子、游泳像鸭子、叫起来像鸭子,那它就是鸭子。」这句谚语被 Python 圈奉为经典,它描述的策略是:不检查对象是什么类型,只检查它能不能响应你要调用的方法。Python 的调用 obj.fly() 只要求 obj 有 fly 方法——至于它是 Bird 还是 Plane,解释器不在乎。这是动态语言的天然属性:类型错误在运行时才暴露,而不是编译期。

鸭子类型出现在你天天用的每一个角落,这里举两个「已学过」的真实协议(protocol)作证。

len() 协议:len(x) 之所以对字符串、列表、字典、元组全部有效,是因为它们都实现了名为 __len__ 的方法。CPython 对 Python 定义的类会经由类型上的长度槽位调用 type(x).__len__(x)(内置的 list、str 等走 C 层等价的长度槽位,效果一致)——只要对象实现了 __len__ 协议,len() 就认识它。这与类型无关,只与「具备什么能力」有关。

迭代协议:for x in obj 能遍历列表、字符串、字典键——第三章学过 for 循环时你就已经用了多态。凡是「可迭代」的对象(有 __iter__ 或 __getitem__),for 一律通吃。注意字典迭代产出的是键而不是值,这是初学者最容易惊讶的默认行为,与多态无关,但值得记下。

鸭子类型是一把双刃剑:它让代码极度灵活、复用性极强;代价是错误延迟到运行时、报错信息可能出现在距离真正「肇事者」很远的地方。于是有了两种「补救与约束」的手段——isinstance/issubclass 与 ABC。

isinstance / issubclass:何时才需要类型检查

既然提倡「不查类型」,为什么 Python 还要提供 isinstance(obj, cls)(实例是否属于某类)与 issubclass(child, parent)(类是否为子类)?因为现实中的确存在必须查的情形:

  1. 异构数据的正确分派:解析 JSON 后拿到的值可能是 int、str、list、dict,后续处理路径完全不同,此时必须用 isinstance 区分——这不是「拒绝鸭子」,而是「鸭子无法自述用途」;
  2. 前置条件校验:除法的除数必须是数字、打开文件前必须确认路径是 str 或 Path——用 isinstance 在入口把明显错误拦下,错误信息清晰,比运行时随机 AttributeError 友好得多;
  3. 与抽象基类挂钩:isinstance(x, numbers.Number) 一类检查配合 ABC 使用(见下文),检查的是「能力」而非「具体类」。

反面的教训是:能用行为表达的判断,不要用类型表达。看到 if type(x) is Dog 这种精确类型比较,多半是把动态语言的灵活拱手让了出去(它连子类都拒绝)。记住 Python 的一个细节:bool 是 int 的子类,isinstance(True, int) 为 True,True + True 等于 2——做类型判断时别被布尔值钻了空子。

ABC:把契约写进代码

鸭子类型默认是「口头契约」——你说你的对象支持 area(),但没人强制。想让契约强制生效,用抽象基类(Abstract Base Class, ABC):从标准库 abc 模块导入 ABC 与 @abstractmethod(这是 Python 3 的标准做法,abc 模块自 Python 2.6 起就存在)。规则有两条,均为本机实测过的事实:

  • 抽象类本身不能实例化——强上会抛 TypeError;
  • 子类必须实现全部抽象方法,少实现一个,子类也不能实例化(错误会精确点名缺哪个方法)。

于是「忘实现」从「运行到那行才崩」提前成「创建对象时立刻崩」——这就是工程上常说的快速失败(fail fast)。顺带一提:Python 标准库里内置了不少现成的抽象基类,如 collections.abc 的 Sequence、Mapping,它们常作为 isinstance 的检查目标;第 7 篇讲魔法方法时会再次碰到「协议」这个词,届时你会看到 ABC 与魔法方法如何相互呼应。

操作与实现

统一接口,各自实现

先看教科书式多态:一个约定接口 Shape,两个不同实现,一个不关心具体类型的汇总循环。

import math

class Shape:
    """约定所有几何形状都有 area()。"""
    def area(self):
        raise NotImplementedError("子类必须实现 area()")

class Circle(Shape):
    def __init__(self, radius):
        self.radius = radius

    def area(self):
        return math.pi * self.radius ** 2

class Rect(Shape):
    def __init__(self, width, height):
        self.width, self.height = width, height

    def area(self):
        return self.width * self.height

shapes = [Circle(2), Rect(3, 4)]
for s in shapes:
    print(round(s.area(), 2))
12.57
12

注意 for 循环里只有 s.area():Circle 与 Rect 谁也没被检查,循环把两种对象混在一个列表里遍历——一句 if 都没有。以后加 Triangle,这个循环一行不用改。Shape.area 里 raise NotImplementedError 是「口头契约」的兜底:万一哪个子类漏实现,调用到那一刻才报错。

鸭子类型实测

把「会飞」当作协议,鸟和飞机都能飞,鱼不能——倒逼看鸭子类型的容错与代价:

class Bird:
    def fly(self):
        print("鸟在飞")

class Plane:
    def fly(self):
        print("飞机在飞")

class Fish:
    def swim(self):
        print("鱼在游")

def let_it_fly(obj):
    """只管调用,不检查类型。"""
    obj.fly()

let_it_fly(Bird())
let_it_fly(Plane())
try:
    let_it_fly(Fish())
except AttributeError as e:
    print("拦截:", e)
鸟在飞
飞机在飞
拦截: 'Fish' object has no attribute 'fly'

let_it_fly 不知道、也不想知道自己是鸟还是飞机——鱼因为「没有 fly 这个能力」在运行时被拦下。这就是鸭子类型的全部逻辑:能力决定资格,类型只是噪音。

len() 协议现场

写一个不挑食的「长度描述」函数,它对任何实现了 __len__ 协议的对象生效:

def describe(obj):
    """任何实现了 __len__ 协议的对象都能被 len() 处理。"""
    print(f"{type(obj).__name__} 类型,共 {len(obj)} 个元素")

describe([10, 20, 30])
describe("python")
describe({"语文": 92, "数学": 88})
list 类型,共 3 个元素
str 类型,共 6 个元素
dict 类型,共 2 个元素

三个调用共用同一句 len(obj);list、str、dict 各自实现的 __len__ 被透明地调度——这是「内置函数 + 协议」式的多态,标准库自己在用。(__len__ 这类魔法方法的语法细节,第 7 篇统一展开。)

迭代协议现场

for 循环(第三章学的)本身就是多态协议的应用——「可迭代」资格由对象自证:

def show_items(collection):
    """不关心具体类型,只要支持 for 循环迭代。"""
    print([str(x) for x in collection])

show_items([1, 2, 3])
show_items("abc")
show_items({"a": 1, "b": 2})
['1', '2', '3']
['a', 'b', 'c']
['a', 'b']

同样三行「一模一样」的循环语法,输出却各归各型。最后一行值得留意:字典的迭代产出了键 'a'、'b' 而不是值——字典的迭代协议语义如此,初学者常在这里踩坑。

类型检查现场

再看「必要时刻」的 isinstance/issubclass 检查:判关系、查集合、做入口校验。

class Animal:
    pass

class Dog(Animal):
    pass

print(isinstance(Dog(), Animal))          # 实例与类的关系
print(isinstance(Dog(), Dog))
print(issubclass(Dog, Animal))            # 类与类的关系
print(isinstance("hello", (str, bytes)))  # 元组形式:任一匹配即可

def safe_divide(a, b):
    if not isinstance(b, (int, float)):
        raise TypeError("除数必须是数字")
    return a / b

print(safe_divide(10, 4))
try:
    safe_divide(10, "4")
except TypeError as e:
    print("拦截:", e)
True
True
True
True
2.5
拦截: 除数必须是数字

把「除数必须是数字」的前置条件收进函数入口,比让 10 / "4" 在深处抛一个费解的 TypeError 更负责任——这是 isinstance 的正经用途:校验「能力与前置条件」,而不是「拒绝新类型」。

ABC:契约的强制版

最后把同一份「形状契约」升级为强制版:abc 模块的 ABC + @abstractmethod。

from abc import ABC, abstractmethod

class Shape(ABC):
    @abstractmethod
    def area(self):
        """子类必须实现此方法。"""

class Circle(Shape):
    def __init__(self, radius):
        self.radius = radius
    # 故意不实现 area()

try:
    Circle()
except TypeError as e:
    print("拦截:", e)

class Square(Shape):
    def __init__(self, side):
        self.side = side

    def area(self):
        return self.side ** 2

print("正方形面积:", Square(5).area())
print(issubclass(Square, Shape))
拦截: Can't instantiate abstract class Circle without an implementation for abstract method 'area'
正方形面积: 25
True

对比前文口头契约的 Shape:那里漏实现要等到 s.area() 被调用的瞬间才报错;这里 Circle() 刚被创建就被拒,错误信息还精确点名缺了 area——抽象类把「写了签名却没写实现」的隐患消灭在创建前。Square 完整实现了契约,立刻可以正常实例化,且 issubclass(Square, Shape) 为 True,类型关系依旧成立。这就是 ABC 的定位:给鸭子类型装上「可选的安全带」。

易错点与陷阱

易错点一:用 type(x) is X 代替 isinstance。 type(d) is Animal 只认「精确类型」——Dog 的实例会被判 False,哪怕 Dog 是 Animal 的子类。

class Animal:
    pass

class Dog(Animal):
    pass

d = Dog()
print(type(d) is Animal)      # 精确类型比较
print(isinstance(d, Animal))  # 属于继承体系即可
print(isinstance(True, int))  # bool 是 int 的子类
print(True == 1, True + True)
False
True
True
True 2

前两行对比鲜明:type is 拒绝了子类,isinstance 拥抱了继承体系。第三、四行是另一个隐性坑:bool 是 int 的子类,isinstance(True, int) 为 True——若你的校验想排除布尔值,得写成 type(b) in (int, float) 或专门检查 isinstance(b, bool)。

易错点二:滥用 isinstance 分支杀死多态。 把一个多态方法改写成 if isinstance(x, Dog): ... elif isinstance(x, Cat): ... 的链条,等于把「扩展点」搬回每个调用处:每加一种动物,链条就要加一行,改一处忘一处。正确姿势是让对象自己回答问题(x.speak()),调用方保持无知。判断标准:你在检查「能力」就可以解决的需求吗?尽力用协议;只有「不同类型必须走不同处理流程」的异构场景才用 isinstance。

易错点三:过分乐观的鸭子。 不查类型意味着错误延迟到运行时,且报错位置往往远离真正的问题源头——let_it_fly(Fish()) 的 AttributeError 出现在调用处,但「把鱼传进来」的逻辑错误在更上游。缓解手段:(a) 入口用 isinstance 做前置校验;(b) 用 ABC 把契约强制化(见前文);(c) docstring 里写明参数要求。三者都是「把口头契约变成可执行契约」。

易错点四:抽象类漏实现「部分」抽象方法。 子类少了任意一个抽象方法,实例化都会失败——错误信息会列出缺的方法名。多人协作时常见「写完了 3 个方法,漏了第 4 个」的尴尬,好在 Python 会把缺的名单直接告诉你;记住抽象类的规则是「全部实现,缺一不可」。

小结

多态让「同一接口、不同实现」成为可能,调用方只依赖接口不依赖具体类,从而对扩展开放。Python 的动态类型把多态推向极致——鸭子类型:不查类型、直接调方法,能力决定资格;len() 协议与 for 迭代协议每天都在演示它。isinstance/issubclass 用于异构分派与前置校验,但别用类型检查替代应有的多态设计(type is 会拒绝子类,bool 是 int 的子类尤其要防)。抽象基类(abc.ABC + @abstractmethod)把口头契约变成强制契约:抽象类与漏实现的子类都无法实例化,错误精确提前。三种手段按需选用:默认鸭子,入口校验用 isinstance,关键契约用 ABC。

练习与思考题

  1. 实现三个「会叫」的类 Dog(汪汪)、Cat(喵喵)、AlarmClock(叮铃铃),以及一个 let_it_ring(obj) 函数直接调用 obj.ring();把三个实例依次传入并运行,体会「对象仅供参考,方法才是王道」。
  2. 用 ABC 重写练习 1:定义抽象基类 Ringable,用 @abstractmethod 声明 ring,让三个类继承它。分别尝试:a) 实例化 Ringable();b) 定义缺 ring 的子类并实例化——记录两类 TypeError 的错误文本,与文中实测输出对照。
  3. 思考题:len(x) 对 list 是 O(1),对 str 也是 O(1)——为什么?提示:__len__ 的返回值由对象自己提供,不一定需要遍历;对比「手动 for 计数」的代价,体会协议设计对性能的意义。
  4. 场景判断:写一个 JSON 配置加载函数,返回的值可能是 int、str、list、dict。对每种类型给出各自的「展示方式」,应该用 isinstance 分支还是强迫对象提供方法?结合本文讲的是否要处理分歧的理由作答。