网站搜索

Python 中的鸭子类型:编写灵活且解耦的代码


Python 广泛使用了称为鸭子类型的类型系统。该系统基于对象的行为和接口。许多内置类和工具都支持这种类型系统,这使得它们非常灵活和解耦。

鸭子类型是 Python 中的一个核心概念。了解该主题将帮助您了解该语言的工作原理,更重要的是,了解如何在您自己的代码中使用这种方法。

在本教程中,您将学习:

  • 什么是鸭子类型及其优缺点
  • Python 的类和工具如何利用鸭子类型
  • 特殊方法协议如何支持鸭子类型
  • 在 Python 中,有哪些鸭子类型的替代方案

为了充分利用本教程,您应该熟悉几个 Python 概念,包括面向对象编程、类、特殊方法、继承和接口。

了解 Python 中的鸭子类型

在面向对象编程中,类的主要目的是封装数据和行为。遵循这个想法,如果替换提供相同的行为,您可以将任何对象替换为另一个对象。即使底层行为的实现完全不同也是如此。

无论哪个对象提供这些行为,使用这些行为的代码都将起作用。这一原则是称为“鸭子类型”的类型系统的基础。

鸭子打字:像鸭子一样行事

你会发现鸭子打字有许多不同的定义。从本质上讲,这种编码风格基于一句众所周知的谚语:

如果它走路像鸭子,叫起来像鸭子,那么它一定是鸭子。

将这一点外推到编程中,您可以让对象像鸭子一样嘎嘎并且像鸭子一样行走,而不是询问这些对象是否是鸭子。在这种情况下,嘎嘎行走代表特定的行为,它们是对象公共接口(API)的一部分。

鸭子类型是一种类型系统,如果对象具有该类型所需的所有方法和属性,则该对象被认为与给定类型兼容。该类型系统支持在特定上下文中使用独立和解耦类的对象的能力,只要它们遵守某些公共接口即可。

鸭子类型在 Python 中非常流行。语言文档定义了鸭子类型,如下所示:

一种编程风格,不查看对象的类型来确定它是否具有正确的接口;相反,方法或属性只是被简单地调用或使用(“如果它看起来像鸭子并且嘎嘎叫起来像鸭子,那么它一定是鸭子。”)通过强调接口而不是特定类型,精心设计的代码通过允许多态性取代。

鸭子类型避免使用 type()isinstance() 进行测试。 (但请注意,鸭子类型可以用抽象基类来补充。)相反,它通常采用 hasattr() 测试或 EAFP 编程。 (来源)

这是一个涉及可以游泳和飞行的鸟类的简单示例:

class Duck:
    def swim(self):
        print("The duck is swimming")

    def fly(self):
        print("The duck is flying")

class Swan:
    def swim(self):
        print("The swan is swimming")

    def fly(self):
        print("The swan is flying")

class Albatross:
    def swim(self):
        print("The albatross is swimming")

    def fly(self):
        print("The albatross is flying")

在此示例中,您的三只鸟可以游泳和飞行。然而,它们是完全独立的类。由于它们共享相同的接口,因此您可以灵活地使用它们:

>>> from birds_v1 import Duck, Swan, Albatross

>>> birds = [Duck(), Swan(), Albatross()]

>>> for bird in birds:
...     bird.fly()
...     bird.swim()
...
The duck is flying
The duck is swimming
The swan is flying
The swan is swimming
The albatross is flying
The albatross is swimming

Python 并不关心bird 在给定时间持有什么对象。它只是调用预期的方法。如果对象提供了该方法,那么代码就可以正常工作而不会中断。这就是鸭子类型提供的灵活性。

鸭子类型系统在 Python 中非常流行。在大多数情况下,您不必担心确保对象的类型适合在特定代码段中使用它。相反,您可以依赖像鸭子一样嘎嘎叫和像鸭子一样行走的对象。

鸭子类型和多态性

在面向对象编程中,多态性允许您将不同类型的对象视为相同的通用类型。多态性旨在使代码能够通过统一的接口(API)处理各种类型的对象,这有助于您编写更通用和可重用的代码。

您会在面向对象编程中发现不同形式的多态性。鸭子打字就是其中之一。

鸭子类型支持多态性,您可以互换使用不同类型的对象,只要它们实现某些行为(也称为接口)。这种类型的多态性的一个基本特征是对象不必从公共超类继承,这使得代码不那么严格并且更容易改变。

在 Python 中,鸭子类型是支持多态性的一种非常流行的方式。您只需要决定特定类具有哪些方法和属性。由于 Python 是一种动态类型语言,因此没有类型检查限制。

了解鸭子类型的优点和缺点

鸭子类型为程序员提供了很大的灵活性,主要是因为您不必考虑复杂的概念,例如继承、类层次结构以及类之间的关系。它在 Python 中如此受欢迎是有原因的!以下是它的一些优点:

  • 灵活性:您可以根据对象的行为互换使用不同的对象,而不必担心它们的类型。这可以提高代码的模块化和可扩展性。
  • 简单性:您可以通过专注于所需的行为而不是考虑特定类型、类以及它们之间的关系来简化代码。这使得代码更加简洁和富有表现力。
  • 代码重用:您可以在其他应用中重用一个或多个类,而无需导出复杂的类层次结构以使这些类正常工作。这有利于代码重用。
  • 更简单的原型设计:您可以快速创建表现出必要行为的对象,而无需复杂的类型定义。这在开发的初始阶段非常有用,此时您可能还没有完全充实类层次结构或接口。

然而,并非一切都是完美的。鸭子类型并不总是您的代码的正确选择。以下是鸭子类型的一些缺点:

  • 潜在的运行时错误:您可能会遇到与缺少方法或属性相关的错误,这些错误可能只在运行时出现。如果对象不符合预期行为,这可能会导致意外行为或崩溃。
  • 缺乏明确性:您可能会使代码不那么明确并且更难以理解。缺乏显式接口定义可能会使掌握对象必须表现出的行为变得更加困难。
  • 潜在的维护问题:您可能在跟踪哪些对象必须表现出特定行为时遇到问题。某些对象的行为更改可能会影响代码的其他部分,从而使其更难以维护、推理和调试。

当您考虑在代码中使用鸭子类型时,您应该权衡这些利弊。根据项目的上下文和要求,鸭子类型可以提供灵活性,但它也可能会引入可能需要仔细评估的潜在问题。

探索 Python 内置工具中的鸭子类型

鸭子类型是 Python 中的一个核心概念,它存在于该语言的核心组件中。这种打字方法使 Python 成为一种高度灵活的语言,不依赖于严格的类型检查,而是依赖于行为和功能。

Python 中有很多支持和使用鸭子类型的例子。最著名的事实之一是内置类型(例如列表、元组、字符串和字典)支持迭代、排序和反转等操作。

因为鸭子类型都是关于对象的行为,所以您会发现有一些通用行为在不止一种类型中有用。当谈到内置类型时,例如列表、元组、字符串、字典和集合,您很快就会意识到它们都支持迭代。您可以直接在 for 循环中使用它们:

>>> numbers = [1, 2, 3]
>>> person = ("Jane", 25, "Python Dev")
>>> letters = "abc"
>>> ordinals = {"one": "first", "two": "second", "three": "third"}
>>> even_digits = {2, 4, 6, 8}
>>> collections = [numbers, person, letters, ordinals, even_digits]

>>> for collection in collections:
...     for value in collection:
...         print(value)
...
1
2
3
Jane
25
Python Dev
a
b
c
one
two
three
8
2
4
6

在此代码片段中,您定义了一些保存不同内置集合类型的变量。然后,您在集合上启动 for 循环,并在每个集合中的数据上启动内部循环。尽管内置类型彼此之间存在显着差异,但它们都支持迭代。

以下是可以在内置集合上运行的一些常规操作的摘要:

Operation Lists Tuples Strings Ranges Dictionaries Sets
Iteration
Indexing
Slicing
Concatenating
Finding length
Reversing
Sorting

此表中列出的操作只是 Python 支持和利用鸭子类型的所有用例的示例。当您深入研究该语言时,您会发现更多示例,特别是如果您查看代表日常操作的 Python 内置函数。

支持自定义类中的鸭子类型

到目前为止,您已经了解到 Python 在内置类型中广泛使用鸭子类型。您可以使用两种不同的方法在自定义类中支持鸭子类型:

  1. 常规方法
  2. 特殊方法

在以下部分中,您将学习如何使用上面列出的方法在您自己的类中支持鸭子类型。

使用常规方法

您已经看到了如何通过常规方法支持鸭子类型的玩具示例。对于更详细的示例,假设您想要创建类来读取不同的文件格式。您需要用于读取文本、CSV 和 JSON 文件的类。

单击下面的可折叠部分以获取每个文件的示例内容:

John
25
Engineer
Jane
22
Designer
name,age,job
John,25,Engineer
Jane,22,Designer
[
    {
        "name": "John",
        "age": 25,
        "job": "Engineer"
    },
    {
        "name": "Jane",
        "age": 22,
        "job": "Designer"
    }
]

您可以通过在每个类中提供所需的行为来利用鸭子类型。以下是您的类的快速实现:

import csv
import json
from itertools import batched  # Python >= 3.12

class TextReader:
    def __init__(self, filename):
        self.filename = filename

    def read(self):
        with open(self.filename, encoding="utf-8") as file:
            return [
                {
                    "name": batch[0].strip(),
                    "age": batch[1].strip(),
                    "job": batch[2].strip(),
                }
                for batch in batched(file.readlines(), 3)
            ]

class CSVReader:
    def __init__(self, filename):
        self.filename = filename

    def read(self):
        with open(self.filename, encoding="utf-8", newline="") as file:
            return list(csv.DictReader(file))

class JSONReader:
    def __init__(self, filename):
        self.filename = filename

    def read(self):
        with open(self.filename, encoding="utf-8") as file:
            return json.load(file)

在此示例中,您有 TextReaderCSVReaderJSONReader 类。所有这些类都有一个 .filename 属性和一个 .read() 方法。您的类共享一个公共接口。这一特性使它们支持鸭子类型。因此,您可以互换使用它们:

>>> from readers import TextReader, CSVReader, JSONReader

>>> readers = [
...     TextReader("file.txt"),
...     CSVReader("file.csv"),
...     JSONReader("file.json"),
... ]

>>> for reader in readers:
...     print(reader.read())
...
[
    {'name': 'John', 'age': '25', 'job': 'Engineer'},
    {'name': 'Jane', 'age': '22', 'job': 'Designer'}
]
[
    {'name': 'John', 'age': '25', 'job': 'Engineer'},
    {'name': 'Jane', 'age': '22', 'job': 'Designer'}
]
[
    {'name': 'John', 'age': 25, 'job': 'Engineer'},
    {'name': 'Jane', 'age': 22, 'job': 'Designer'}
]

在此示例中,您对三个读取器对象运行 for 循环。每个对象都指向具有适当格式的特定文件。由于所有类都有 .read() 方法,因此您可以互换使用它们,并且您的代码将正常工作。

由于鸭子类型,您不必编写客户端代码的多个版本。这样,您的代码将变得灵活且可扩展。

使用特殊方法和协议

在自定义类中支持鸭子类型的第二种方法是使用特殊的方法和协议。特殊方法是那些名称以双下划线开头和结尾的方法。这些方法对于 Python 来说具有特殊的意义。它们是 Python 面向对象基础设施的基本组成部分。

协议是支持语言特定功能的特殊方法集,例如迭代器、上下文管理器和序列协议。协议是文档中定义的非正式接口。

得益于鸭子类型,遵循既定协议可以提高利用现有标准库和第三方代码的机会。

为了说明如何通过特殊方法和协议支持鸭子类型,假设您需要编写一个实现队列数据结构的类。您需要队列可迭代并支持内置的 len() 和 reversed() 函数。它还应该支持使用 in 运算符进行成员资格测试。

这是您的类的可能实现:

from collections import deque

class Queue:
    def __init__(self):
        self._elements = deque()

    def enqueue(self, element):
        self._elements.append(element)

    def dequeue(self):
        return self._elements.popleft()

    def __iter__(self):
        return iter(self._elements)

    def __len__(self):
        return len(self._elements)

    def __reversed__(self):
        return reversed(self._elements)

    def __contains__(self, element):
        return element in self._elements

您的 Queue 类使用 deque 对象来存储数据。 collections 模块中的 deque 类允许您创建高效的双端队列。

Queue 类具有经典的队列操作 enqueuedequeue 将元素追加到队列末尾以及从队列开头删除元素, 分别。

接下来,您有 .__iter__() 特殊方法,它允许您支持迭代。请注意,此方法使用内置的 iter() 函数返回队列中元素的迭代器。使用此方法,您可以确保您的类在 for 循环、推导式和类似构造中工作。

然后,您有 .__len__().__reverse__() 方法。它们允许您支持内置的 len()reversed() 函数。最后,您有 .__contains__(),您需要它来支持成员资格测试。

以下是您的班级在实践中的运作方式:

>>> from queues import Queue

>>> queue = Queue()
>>> queue.enqueue(1)
>>> queue.enqueue(2)
>>> queue.enqueue(3)

>>> [item for item in queue]
[1, 2, 3]

>>> len(queue)
3
>>> list(reversed(queue))
[3, 2, 1]

>>> 2 in queue
True
>>> 6 in queue
False

在此示例中,您首先使用三个元素填充 queue 对象。为了确认您的队列是可迭代的,您可以运行快速列表理解来迭代队列中的项目。

然后,以队列作为参数调用 len() 函数来获取队列中的元素数量。接下来,您使用 reversed() 函数获取队列中元素的反向迭代器。在这里,您使用 list() 构造函数来使用迭代器并将队列的内容显示为列表。

最后,您可以通过 in 运算符在成员资格测试中使用 queue 对象。这样,您就确认了该类支持所有必需的操作。这就是通过特殊方法和协议进行鸭子打字的魔力。

探索鸭子类型的替代方案

在实践中,您会发现鸭子类型并不是解决给定问题的正确方法的用例。在某些情况下,您可能需要更明确的接口,或者您可能想确保不会出现运行时错误或出现维护问题。

当显式接口定义优于鸭子类型时,您应该使用不同的东西。在下面的部分中,您将了解鸭子类型的几种替代方法。首先,您将从抽象基类开始。

使用抽象基类

抽象基类 (ABC) 定义其所有子类都必须实现的一组特定的公共方法和属性 (API)。它们提供了鸭子打字的绝佳替代品。当您的类必须遵守的接口需要额外的保护和保证时,推荐使用它们。

要在 Python 中定义抽象基类,您可以使用标准库中的 abc 模块。该模块提供了一些与 ABC 相关的工具,您可以使用它们来完成这项工作。

为了说明如何使用 ABC 而不是鸭子类型,假设您要创建类来表示不同的车辆。这些类需要具有 .start().stop().drive() 方法。

使用鸭子类型,您可以编写如下代码所示的类:

class Car:
    def __init__(self, make, model, color):
        self.make = make
        self.model = model
        self.color = color

    def start(self):
        print("The car is starting")

    def stop(self):
        print("The car is stopping")

    def drive(self):
        print("The car is driving")

class Truck:
    def __init__(self, make, model, color):
        self.make = make
        self.model = model
        self.color = color

    def start(self):
        print("The truck is starting")

    def stop(self):
        print("The truck is stopping")

    def drive(self):
        print("The truck is driving")

这些类共享预期的接口。它们彼此独立且解耦。他们不需要彼此才能正常工作。因此,您可以在鸭子类型上下文中互换使用它们:

>>> from vehicles_duck import Car, Truck

>>> vehicles = [
...     Car("Ford", "Mustang", "Red"),
...     Truck("Ford", "F-150", "Blue"),
... ]

>>> for vehicle in vehicles:
...     vehicle.start()
...     vehicle.drive()
...     vehicle.stop()
...
The car is starting
The car is driving
The car is stopping
The truck is starting
The truck is driving
The truck is stopping

您的类可以正常工作,因为它们具有相同的行为。他们支持鸭子打字。

如果您想创建一个也在该循环中工作的 Jeep 类怎么办?在这种情况下,您需要知道 Jeep 必须实现的接口。了解该接口需要查看 CarTruck 的代码或其文档。

在这个简短的示例中,任务相对简单。但是,当您的类很复杂并且它们的接口不完全匹配时,您将需要投入大量时间来确定像 Jeep 这样的新类的正确接口。

或者,您可以创建一个名为 Vehicle 的 ABC,它定义所需的接口并使所有车辆类都继承它。通过这种策略,您将依赖基类来强制执行必要的接口。 ABC会让你快速知道正确的界面。

执行此操作的方法如下:

from abc import ABC, abstractmethod

class Vehicle(ABC):
    def __init__(self, make, model, color):
        self.make = make
        self.model = model
        self.color = color

    @abstractmethod
    def start(self):
        raise NotImplementedError("start() must be implemented")

    @abstractmethod
    def stop(self):
        raise NotImplementedError("stop() must be implemented")

    @abstractmethod
    def drive(self):
        raise NotImplementedError("drive() must be implemented")

在此代码中,您创建了一个继承自 abc.ABCVehicle 类。在 Vehicle 中,您定义 .__init__() 方法来初始化所需的属性。将此方法移至基类可以使您避免在子类中重复编写代码。

然后,使用 @abstractmethod 装饰器将所需的方法定义为抽象方法。请注意,抽象方法不提供具体的实现。它们只是引发 NotImplementedError 异常。

你不能直接实例化 ABC。它们作为 API 模板供其他类遵循:

>>> from vehicles_abc import Vehicle

>>> Vehicle("Ford", "Mustang", "Red")
Traceback (most recent call last):
    ...
TypeError: Can't instantiate abstract class Vehicle
    with abstract methods 'drive', 'start', 'stop'

当您尝试实例化像 Vehicle 这样的抽象类时,您会收到 TypeError 异常。异常消息清楚地表明了这一点。所以,你只能对 ABC 进行子类化。

您有一个基类,它在其子类中强制执行给定的 API。你不能用鸭子打字来做到这一点。现在,您可以通过继承 Vehicle 并实现所有必需的方法来创建特定的车辆:

# ...

class Car(Vehicle):
    def start(self):
        print("The car is starting")

    def stop(self):
        print("The car is stopping")

    def drive(self):
        print("The car is driving")

class Truck(Vehicle):
    def start(self):
        print("The truck is starting")

    def stop(self):
        print("The truck is stopping")

    def drive(self):
        print("The truck is driving")

在此代码段中,您通过继承 Vehicle 重写 CarTruck 类。两者都实现了所需的方法。它们不需要拥有自己的 .__init__() 方法,因为它们从 Vehicle 继承此方法。

就是这样!你已经用 ABC 取代了鸭子打字。请注意,在这种情况下,您的类与基类耦合。这可能是一个限制,因为除非您还随身携带 Vehicle 类,否则您将无法在不同的项目中重用其中一个类。

最后,如果您尝试创建一个继承自 VehicleJeep 类而不提供所需的接口,会发生什么情况?下面的代码让你知道答案:

>>> from vehicles_abc import Vehicle

>>> class Jeep(Vehicle):
...     def start(self):
...         print("The jeep is starting")
...

>>> jeep = Jeep("Land Rover", "Defender", "Black")
Traceback (most recent call last):
    ...
TypeError: Can't instantiate abstract class Jeep
    with abstract methods 'drive', 'stop'

在此示例中,您尝试通过继承 Vehicle 来创建 Jeep 类。您只实现了 .start() 方法,它并没有实现完整的接口。当您尝试实例化此类时,您会收到 TypeError,因为所需的接口不完整。此行为是 ABC 如何强制执行特定接口的一部分。

显式检查类型

显式检查对象的类型是鸭子类型的另一种替代方法。这种替代方法的限制性更强,包括确保对象属于给定类型或具有所需的方法,然后才能在代码中使用它。

在 Python 中,要检查对象是否来自给定类,可以使用内置的 isinstance() 函数。要检查对象是否具有特定方法或属性,可以使用内置的 hasattr() 函数。

为了说明这些工具的工作原理,假设您有以下玩具类:

class Duck:
    def fly(self):
        print("The duck is flying")

    def swim(self):
        print("The duck is swimming")

class Pigeon:
    def fly(self):
        print("The pigeon is flying")

这些类部分支持鸭子类型。如果您只使用 .fly() 方法,它们可以互换使用。当您需要 .swim() 方法时,Pigeon 将失败并出现 AttributeError

>>> from birds_v2 import Duck, Pigeon

>>> birds = [Duck(), Pigeon()]

>>> for bird in birds:
...     bird.fly()
...
The duck is flying
The pigeon is flying

>>> for bird in birds:
...     bird.swim()
...
The duck is swimming
Traceback (most recent call last):
    ...
AttributeError: 'Pigeon' object has no attribute 'swim'

在此示例中,第一个循环有效,因为两个对象都具有 .fly() 方法。相反,第二个循环失败,因为 Pigeon 没有 .swim() 方法。

为了避免上述失败,您可以在调用 .swim() 方法之前检查对象的类型:

>>> for bird in birds:
...     if isinstance(bird, Duck):
...         bird.swim()
...
The duck is swimming

在此示例中,您使用内置的 isinstance() 函数来检查当前对象是否是提供 .swim 的类 Duck 的实例() 方法。

或者,您可以使用 hasattr() 函数显式检查当前对象是否具有所需的方法:

>>> for bird in birds:
...     if hasattr(bird, "swim"):
...         bird.swim()
...
The duck is swimming

hasattr() 函数允许使用比 isinstance() 更通用的方法。在这种情况下,您可以检查特定行为而不是类型,这在某些情况下可能很方便。

尽管在某些情况下在使用对象之前检查对象的类型可能是一个很好的解决方案,但这并不是最好的方法。 Python 的主要特点之一是它在类型方面的灵活性,而一直检查类型并不是一件非常灵活的事情。

使用鸭子打字和类型提示

正如您在本教程中了解到的,鸭子类型是 Python 中广泛使用的类型系统。它增加了语言的灵活性,并且它是一个非常常见的系统,您可以在内置类型、标准库和第三方代码中找到它。

然而,有时将鸭子类型和类型提示(这是 Python 提供类型上下文的方式)结合起来可能具有挑战性。

很高兴您可以编写一个可以处理不同类型对象的函数,前提是它们支持所需的行为。困难的部分可能是通过类型提示传达意图。在以下部分中,您将深入了解一些概念,这些概念可以帮助您更好地理解挑战并找出解决方法。

了解类型提示和静态鸭子类型

类型提示是在一段代码中表达对象类型的 Python 方式。 Python 的类型提示在过去几年中发生了显着的发展。类型检查在 Python 代码中变得越来越流行。大多数代码编辑器和 IDE(集成开发环境)都集成了类型检查器,它们使用类型提示来检测代码中可能存在的错误。

在代码中使用类型提示有几个相关的优点。除其他好处外,您还将获得以下好处:

  • 防止与类型相关的错误
  • 为自动类型检查器提供动力
  • 支持编辑器中的自动完成
  • 提供代码文档
  • 允许数据验证
  • 允许数据序列化

在Python类型提示的早期阶段,系统主要是名义型的。在名义类型系统中,如果另一个类是前者的子类,则一个类可以替换另一个类。这种方法对鸭子类型提出了挑战,鸭子类型不依赖于类型而是行为。

类型提示系统不断发展,现在它还支持结构类型系统。在这种系统中,如果两个类具有相同的结构,则一个类可以替换另一个类。例如,您可以将 len() 函数与定义 .__len__() 方法的所有类一起使用。该方法是类内部结构的一部分。

这称为结构子类型静态鸭子类型。这就是 Python 成功地使类型提示适合鸭子类型的方式。协议和抽象基类是实现这种兼容性的核心。

使用协议和 ABC

Python 3.8 在类型提示系统中引入了协议。协议指定类必须实现的一个或多个方法来支持给定的功能。因此,协议与类的内部结构有关。您已经听说过常见协议,例如迭代器、上下文管理器和序列协议。

协议和抽象基类填补了鸭子类型和类型提示之间的空白。它们帮助类型检查器捕获与类型相关的问题和潜在错误,这有助于使您的代码更加健壮。

作为鸭子类型如何与类型提示发生冲突的示例,假设您有以下函数:

>>> def mean(grades: list) -> float:
...     return sum(grades) / len(grades)
...

>>> mean([4, 3, 3, 4, 5])
3.8
>>> mean((4, 3, 3, 4, 5))
3.8

该功能工作正常。但是,grades 参数的类型提示仅限于 list 对象,并且与鸭子类型正面冲突。请注意,该函数适用于数字列表和元组。它甚至可以与设定的对象一起使用。

如何以适合鸭子类型的方式对此函数进行类型提示?您可以使用联合类型表达式,如下面的代码所示:

def mean(grades: list | tuple | set) -> float:
    return sum(grades) / len(grades)

这种类型提示有效。然而,这很麻烦。为了简化类型提示,您可以利用更通用的解决方案。它可以是定义所需接口的 ABC,其中包括支持迭代和 len() 函数。

在此示例中,输入对象应该是可迭代的并且支持len()。在collections.abc 模块中,您有一个名为Collection 的抽象基类,它定义了这两种行为。使用此类,您可以输入提示您的函数,如下面的代码所示:

from collections.abc import Collection

def mean(grades: Collection) -> float:
    return sum(grades) / len(grades)

这种类型提示更加简洁。如果您查看 Collection 的文档,您会注意到此 ABC 支持 .__iter__().__len__()方法。这样,您的类型检查器就知道输入对象必须支持这些协议而不是特定类型。

您会发现几个抽象基类,可用于提供适合鸭子类型的类型提示。这些类不定义类型,而是定义接口,这是鸭子类型的基础。

创建自定义协议对象

在某些情况下,您可能需要创建自定义类来在依赖于鸭子类型的代码中定义您自己的协议。为此,您可以使用 typing 模块中的 Protocol 类。此类是定义协议的基础。

为了说明这一点,假设您要创建一组形状类。您需要所有类都具有 .area().perimeter() 方法:

from math import pi

class Circle:
    def __init__(self, radius: float) -> None:
        self.radius = radius

    def area(self) -> float:
        return pi * self.radius**2

    def perimeter(self) -> float:
        return 2 * pi * self.radius

class Square:
    def __init__(self, side: float) -> None:
        self.side = side

    def area(self) -> float:
        return self.side**2

    def perimeter(self) -> float:
        return 4 * self.side

class Rectangle:
    def __init__(self, length: float, width: float) -> None:
        self.length = length
        self.width = width

    def area(self) -> float:
        return self.length * self.width

    def perimeter(self) -> float:
        return 2 * (self.length + self.width)

所有这些类都有所需的方法,因此它们支持鸭子类型。他们还使用类型提示来描述输入参数的类型和每个方法的返回值。到目前为止,您的类型检查器将会很高兴。

现在,假设您要编写一个以形状作为参数的函数,并创建输入形状的描述:

# ...

def describe_shape(shape):
    print(f"{type(shape).__name__}")
    print(f" Area: {shape.area():.2f}")
    print(f" Perimeter: {shape.perimeter():.2f}")

此函数将形状作为参数并打印包含形状名称、面积和周长的报告。以下是该函数如何处理不同的形状:

>>> from shapes import Circle, Rectangle, Square, describe_shape

>>> describe_shape(Circle(3))
Circle
 Area: 28.27
 Perimeter: 18.85

>>> describe_shape(Square(5))
Square
 Area: 25.00
 Perimeter: 20.00

>>> describe_shape(Rectangle(4, 5))
Rectangle
 Area: 20.00
 Perimeter: 18.00

由于您的形状类支持鸭子类型,因此无论您使用哪种形状作为参数,describe_shape() 函数都会按预期工作。现在,如何向 describe_shape() 添加类型提示?如何确保输入形状具有所需的接口?

在这种情况下,您可以创建一个 Protocol 子类来描述形状所需的一组方法:

from math import pi
from typing import Protocol

class Shape(Protocol):
    def area(self) -> float: ...

    def perimeter(self) -> float: ...

# ...

该类继承自Protocol并定义了所需的方法。请注意,这些方法没有正确的实现。他们只是使用省略号来定义协议的主体。或者,他们可以使用 pass 语句作为另一种方式来表达这些方法不执行任何操作。他们只是定义一个自定义协议。

现在您可以使用 Shape 类来提示您的 describe_shape() 函数,如下面的代码所示:

# ...

def describe_shape(shape: Shape) -> None:
    print(f"{type(shape).__name__}")
    print(f" Area: {shape.area():.2f}")
    print(f" Perimeter: {shape.perimeter():.2f}")

在此更新版本的 describe_shape() 中,您可以使用 Shape 协议为 shape 参数提供适当的类型提示。通过此更新,您的类型检查器将会很高兴,因为它会知道参数支持所需的方法。

结论

现在您知道 Python 在许多语言结构中使用鸭子类型。这种类型的多态性不依赖于继承。它仅依赖于对象的公共方法和属性(API)。许多 Python 内置类和工具都支持这种类型系统,这增加了该语言的灵活性和功能。

在本教程中,您学习了:

  • 什么是鸭子类型以及它的优点和缺点是什么
  • Python 的类和工具如何利用鸭子类型
  • 特殊方法协议如何支持鸭子类型
  • 在 Python 中,有哪些鸭子类型的替代方案

了解鸭子类型将帮助您更好地理解 Python 的工作原理。它还可以帮助您编写更多Pythonic、灵活、解耦的代码。