Python 多分派机制,让你的代码更简洁更灵活

2022-12-30 13:58:57 浏览数 (1)

近期我们开源了一个跨框架算法评测库 MMEval。在 MMEval 中,我们使用了一种叫做多分派的技术,来支持不同框架实现的自动分发。

在日常代码的编写中使用多分派技术,可以避免大量重复的类型判断语句,让代码更加简单易懂,不仅如此,还可以让代码拥有更加灵活的扩展能力

本文将给大家主要介绍在 Python 中基于参数类型提示的多分派机制,快一起来学习下吧~

https://github.com/open-mmlab/mmeval

举个例子

让我们来看一下在日常 Python 代码编写中经常会遇到的情形:下面这段代码,其作用是根据输入数据的类型,返回对应的字符串。这是一段典型的多分支执行结构,并且是基于类型判断来确定执行路径。

代码语言:javascript复制
def check_type(x):
    if isinstance(x, int):
        return "this is int"
    elif isinstance(x, float):
        return "this is float"
    elif isinstance(x, str):
        return "this is str"
    else:
        return f"unknown type: {type(x)}"

这段代码在大部分时候是可接受的,但是在一些情况下,可能会需要拓展新的判断分支,而且上述代码块对类型的判断有大量重复的语句。

针对上述情况,我们可以使用另外一种叫做多分派的编程模式,将类型判断的部分隐藏起来,通过类型注释来声明分支执行条件。从而避免了大量重复的类型判断语句,并且拥有更加灵活的扩展能力。

假设我们已经实现了一个多分派的装饰器 dispatch,使用 dispatch 装饰函数之后,可以根据输入的数据类型自动调用相应的实现,我们可以用以下代码来实现上述功能:

代码语言:javascript复制
@dispatch
def check_type(x: int):
    return "this is int"

@dispatch
def check_type(x: float):
    return "this is float"
    
@dispatch
def check_type(x: str):
    return "this is float"

我们利用 dispatch 装饰器,将大量的 if - else 类型判断分支去除。在函数定义时,通过类型提示来声明当前实现所需要匹配的数据类型,在函数调用时,根据运行时的参数类型来分发具体的实现。

这样做不仅让代码更简洁易懂,还有利于新分支的扩展。

(有关于 Python 类型提示的文章可以点击查看:都快 Python 3.11 了,你还没有使用 Type Hints 吗?)

多分派介绍

在代码实现过程中,我们通常把某个具体处理逻辑封装成函数或者方法,有的时候我们需要有一些同名函数,处理不同类型的输入,由此可以引出我们刚刚例子里面提到的多分派机制。

根据维基百科介绍,多分派是某些编程语言的特性,它允许函数或者方法,在运行时基于它的实际参数类型,或在更一般的情况下的其他特性,来动态分发具体的函数实现。

多分派来自于单分派多态的推广,所谓单分派就是在函数调用时候,除了函数名之外,还有另外一个函数的参数会被作为分发具体实现的依据,一般是函数的第一个参数。

在一些面向对象的编程语言中,比如 C 和 JAVA ,这个参数一般是对象实例,在运行时会根据实际的类型来调用相应的方法,这种行为也叫做多态。

对于多分派来说,除了函数名之外,还要求函数的参数个数和类型都完全对应上,才能决定具体调用哪个函数实现。

目前很多语言都直接或者间接地支持了多分派机制,Julia 更是把多分派作为语言的核心特点,针对不同的输入数据,通过 JIT 生成对应数据类型的高效字节码。

C# 和 R 等语言也内建支持了多分派,另外像 C / C / Python 等语言则需要通过第三方扩展来支持实现多分派。

Python 中的多分派实现

Python 本身没有内建支持多分派机制,有两个可能的原因:

  • Python 作为动态类型的编程语言,要在运行时精确地获取其数据类型是一件开销很大的事情
  • Python 本身就是一种非常灵活的编程语言,即使是需要自己实现多分派,也不是一件难事

早在 2005 年,Python 作者 Guido 就给我们展示了如何在五分钟内为 Python 实现一个多分派机制:https://www.artima.com/weblogs/viewpost.jsp?thread=101605

在 Python3.4 的时候,标准库 functools 引入了一个 singledispatch 装饰器,将单分派机制引入 Python 中,它可以将函数转为单分派的泛函数,根据函数的第一个参数类型来分发具体的实现。如下是 singledispatch 的具体使用例子:

代码语言:javascript复制
from functools import singledispatch

@singledispatch
def fun(x: int):
    return "this is int"

@fun.register
def _(x: float):
    return "this is float"

@fun.register
def _(x: str):
    return "this is str"

functools.singledispatch 是 Python 官方支持的单分派机制,在这里可以把它理解为简化版的 Python 多分派,它仅能通过第一个参数的类型来决定函数分发。

coady/multimethod 是一个社区开发维护的多分派实现,使用方式与 functools.singledispatch 类似,能够支持通过所有位置参数决定的函数分发,相较于 functools.singledispatch 提供了更强大的功能。

除了 coady/multimethod 以外, 社区中还有另外一些多分派的实现,比如 mrocklin/multipledispatch ,wesselb/plum 和 morepath/reg,其中 morepath/reg 还支持基于谓词的分派。

上述的这几个多分派实现或多或少都存在一些问题,一个很重要的问题是他们大多都是仅支持内建数据类型的类型提示, coady/multimethod 和 wesselb/plum 除外。

coady/multimethod 能够支持任何满足 issubclass 的类型描述,包括 collections.abc 中的类型基类以及 typing 模块中的基类型。

而 wesselb/plum 则自己实现了一套简易的类型系统,能够将 Python 对象和类型提示转换成自己的类型描述,并且在此基础上求解最匹配的函数签名。

以下是 wesselb/plum 使用示例,相比 functools.singledispatch 在使用功能和使用方式上都有非常大的提升,已经与我们最开始假设的 dispatch 装饰器没有任何区别甚至更加强大:

代码语言:javascript复制
from numbers import Number
from plum import dispatch

@dispatch
def f(x: str):
    return "This is a str!"

@dispatch
def f(x: int):
    return "This is an int!"

@dispatch
def f(x: Number):
    return "This is a general number, but I don't know which type."
代码语言:javascript复制
>>> f("1")
'This is a str!'

>>> f(1)
'This is an int!'

>>> f(1.0)
"This is a number, but I don't know which type."

>>> f(object())
NotFoundLookupError: For function "f", signature Signature(builtins.object) could not be resolved.

Python 多分派存在的问题

Python 中基于参数类型提示的多分派,相较于多分支类型判断的结构,在代码可读性以及扩展性上都更具有优势。目前 Python 的多分派扩展实现也相当完善了,对于一些脚本的编写或者个人项目来说已经足够使用。但是要想将这种多分派机制应用到一些大的项目或者实际生产中,仍然有一些问题需要解决。

基于参数类型的多分派机制,需要解决的一个核心问题是类型判断与子类检查。而要做好类型判断与子类检查,首先就是需要获取到变量的数据类型,我们在前文提到,Python 作为动态类型的编程语言,要在运行时精确地获取变量数据类型是一件开销不小的事情。

如果我们在项目中把多分派机制作为基础组件,可能会引发性能问题。以 wesselb/plum 为例,当参数是一个百万级别列表时,确认该参数的类型可能会要花费数秒钟的时间,这作为一个基础组件的调度开销来说是不能接受的。

针对此问题,MMEval 中的多分派也做了一些相应的优化,可以查看我们的文档了解更多信息:https://mmeval.readthedocs.io/zh_CN/latest/design/multiple_dispatch.html

除此之外,缺乏一个十分完备强大的类型系统也是限制多分派机制应用的一大问题。自从 Python 3.5 引入 typing 模块后,Python 的类型提示变得越来越灵活强大,目前 coady/multimethod 与 wesselb/plum 对 typing 类型提示的支持都不太完善,例如 Set,TextIO 和 AnyStr 等都尚未支持。

可以看出,高效准确的类型判断与子类检查是阻碍多分派机制能够广泛使用的核心问题,目前在 Python 社区中,也有一些关于动态类型检查的工具,比如 beartype,能够做到非常快速的类型检查和子类判断,将此类工具应用到多分派中或许是一个不错的尝试。

参考链接:

  • https://github.com/open-mmlab/mmeval
  • wikipedia - 多分派 (https://zh.m.wikipedia.org/zh-hans/多分派)
  • Five-minute Multimethods in Python (https://www.artima.com/weblogs/viewpost.jsp?thread=101605)
  • 为什么要多重派发? (https://zhuanlan.zhihu.com/p/105953560)
  • https://docs.python.org/3/library/functools.html#functools.singledispatch
  • https://github.com/coady/multimethod
  • https://github.com/mrocklin/multipledispatch
  • https://github.com/morepath/reg
  • https://github.com/wesselb/plum
  • https://github.com/wesselb/plum/issues/53
  • https://github.com/beartype/beartype

0 人点赞