我们在第1章中已经介绍过多态。这是一个花哨的名词,但描述了一个简单的概念:同一个方法的不同子类会产生不同行为,不需要明确知道使用的是哪个子类。这有时也被称为Liskov替换原则,以表彰Barbara Liskov对面向对象编程的贡献。我们应该能够用任何子类来代替它的超类。
举个例子,想象一个播放音频文件的程序。多媒体播放器可能需要加载AudioFile对象并播放它。我们为对象添加一个play()方法,负责解压缩或提取音频信息,并将其传递给声卡或音箱。播放AudioFile对象的动作可以简单写作:
然而,对于不同类型的文件,解压缩和提取音频文件的过程可能是不同的。.wav文件存储的是未压缩过的信息,而.mp3、.wma和.ogg文件则均使用了不同的压缩算法。
我们可以用多态继承来简化设计。每种类型的文件都使用AudioFile不同的子类表示,例如WavFile、MP3File。它们都有play()方法,但是这些方法针对不同的文件实现方式也不同,以确保使用正确的提取流程。多媒体播放器对象永远不需要知道指向的是AudioFile的哪个子类,而只需要调用play()方法并多态地让对象自己处理实际播放过程中的细节。让我们用一个快速的框架来展示如何做到这一点:
所有的音频文件都会检查以确保在初始化之前有正确的后缀名。如果文件名的后缀名不对,将会抛出异常(关于异常将会在第4章中详细介绍)。
但是你注意到父类的__init__()方法是如何访问不同子类的类变量ext的了吗?这就是多态在起作用。AudioFile父类中只有一个类型提示告诉 mypy 会有一个名为ext的属性,它并没有实际存储ext属性的引用。但当方法被子类继承并被子类调用时,它可以访问子类的ext属性。如果子类忘记给ext赋值,AudioFile中的类型提示可以帮助 mypy 发现问题。
除此之外,AudioFile的每个子类都以不同的方式实现了play()方法(这个示例不会真的播放音乐,音频压缩算法需要另外一整本书来介绍)。这也是多态的实际应用。无论文件类型是什么,多媒体播放器可以用同样的代码播放文件,而不需要考虑用的是AudioFile的哪个子类。解压缩音频文件的细节被封装了。如果我们测试这个示例,它应该会像我们设想的一样运行:
看到AudioFile.__init__()是如何在不需要知道具体子类的情况下检查文件类型的了吗?
多态实际上是面向对象编程中最酷的概念之一,并且它让一些早期范式不可能实现的编程设计变得显而易见。然而,Python的鸭子类型让多态变得不那么酷了。Python的鸭子类型让我们可以使用任何提供了必要方法的对象,而不一定非得是子类。Python的动态本质让这一点变得易于实现和非常平常。下面的示例没有继承AudioFile,但是也可以用完全相同的接口进行交互:
我们的多媒体播放器可以用同样的方式播放FlacFile类,就像播放其他AudioFile的子类一样。
在很多面向对象的场景中,多态是使用继承关系最重要的原因之一。由于任何提供了正确接口的对象都可以在Python中互换使用,因此减少了对共有的多态超类的需求。继承仍然可以用于重用代码,但是如果需要重用的只是公共接口,那么用鸭子类型已经满足要求了。
减少使用继承关系,进而可以减少使用多重继承的需求。通常,看起来需要使用多重继承,但我们可以用鸭子类型来模拟其中一个超类。
我们可以使用typing.Protocol来定义鸭子类型的协议。为了让 mypy 知道我们的期望,我们通常会在正式的 协议 中定义几个方法或属性(或二者皆有)。这可以帮助澄清类之间的关系。比如,我们可以用这种方式定义FlacFile类和AudioFile类层级之间的共同特性:
当然,仅仅因为对象满足特定的协议(通过提供必需的方法或属性),并不意味着它能够在所有情况下正确运行。它对接口的实现必须符合整个系统的要求。仅仅因为某个对象提供了play()方法,并不意味着它就自动适用于多媒体播放器。这些方法除了满足接口的语法要求,还必须实现接口所要求的含义或功能。
鸭子类型另一个有用的特性是,鸭子类型的对象只需要提供真正被访问的方法和属性。例如,我们想要创建一个假的文件对象用于读取数据,可以创建一个新的拥有read()方法的对象,如果与这个假对象交互的代码只需要从文件中读取,我们不需要重写write()方法。简单来说,鸭子类型不需要提供所需对象的整个接口,而只需要满足实际被使用的协议。