Python中用于重载的装饰器

19

我知道编写在意参数类型的函数不符合Pythonic,但有时无法忽略类型,因为它们会被不同地处理。

在函数中有一堆isinstance检查只是丑陋的;是否有可用的函数装饰器,可以启用函数重载?类似于这样:

@overload(str)
def func(val):
    print('This is a string')

@overload(int)
def func(val):
    print('This is an int')

更新:

这是我在David Zaslavsky's answer上留下的一些评论:

稍作修改后,这将非常适合我的用途。我注意到你的实现有一个限制,因为你使用func.__name__作为字典键,所以容易出现模块之间的名称冲突,这并不总是理想的。[续]

[续] 例如,如果我有一个模块重载func,而另一个完全不相关的模块也重载了func,这些重载将会发生冲突,因为函数分派字典是全局的。该字典应该变成局部的,以某种方式。而且不仅如此,它还应该支持某种形式的“继承”。[续]

[续] 所谓“继承”,是指:假设我有一个名为first的模块,其中包含一些重载。然后有两个没有关联但都导入first的模块;这两个模块都向已经存在的那些刚导入的重载中添加了新的重载。这两个模块应该能够使用first中的重载,但是它们刚添加的新重载之间不应该发生冲突。(现在我想想,这实际上相当难做到。)

通过对装饰器语法稍作修改,可能可以解决其中一些问题:

first.py

@overload(str, str)
def concatenate(a, b):
    return a + b

@concatenate.overload(int, int)
def concatenate(a, b):
    return str(a) + str(b)

second.py

from first import concatenate

@concatenate.overload(float, str)
def concatenate(a, b):
    return str(a) + b

嗯...在你的编辑中,first.py文件中的contatenate.overload是什么意思?按照现有的写法,这会尝试访问concatenate函数的overload属性,但在这个例子中该属性并不存在。 - David Z
@DavidZaslavsky 函数的第一个重载应该使用 @overload 进行修饰,这将返回一个可调用对象,该对象具有属性 overload。所有后续的重载都应该使用已经存在的对象 @object.overload 进行修饰,这样每个对象只有一个字典,而不是全局字典。(它的工作方式类似于标准的 @property。)我会编写一个实现,并在发布时通知您。 - Paul Manta
1
那将是一种违背传统重载语法的方式。但如果这正是你想要的,可以看看overload包(我即将将其编辑到我的答案中)。它基本上使用了那种方法。 - David Z
4个回答

29

从Python 3.4开始,functools模块支持@singledispatch装饰器。其用法如下:

from functools import singledispatch


@singledispatch
def func(val):
    raise NotImplementedError


@func.register
def _(val: str):
    print('This is a string')


@func.register
def _(val: int):
    print('This is an int')

用法

func("test") --> "This is a string"
func(1)      --> "This is an int"
func(None)   --> NotImplementedError

12
是的,typing库中有一个overload修饰器,可以帮助简化复杂类型提示。
from collections.abc import Sequence
from typing import overload


@overload
def double(input_: int) -> int:
    ...


@overload
def double(input_: Sequence[int]) -> list[int]:
    ...


def double(input_: int | Sequence[int]) -> int | list[int]:
    if isinstance(input_, Sequence):
        return [i * 2 for i in input_]
    return input_ * 2

请查看此链接以获取更多详细信息。

刚刚注意到这是一个11年前的问题,很抱歉再次提出。这是我的失误。


2
回答这个问题并不是错误,因为它已经过时了。相反,这很好,因为它已经过时了。你对它进行了很大的改进。 - PythonForEver

11

简短回答: 在 PyPI 上有一个 overload 包,它比我下面描述的更稳健,尽管使用了稍微不同的语法。它声明仅适用于 Python 3,但看起来只需要进行轻微修改(如果需要的话,我没有尝试)就可以让它与 Python 2 兼容。


详细回答: 在支持重载函数的编程语言中,当定义和调用函数时,函数名将(文字上或实际上)加上其类型签名信息。当编译器或解释器查找函数定义时,会使用声明的名称和参数类型来解析要访问的函数。因此,在Python中实现重载的逻辑方式是实现一个包装器,使用声明的名称和参数类型来解析函数。

这里是一个简单的实现:

from collections import defaultdict

def determine_types(args, kwargs):
    return tuple([type(a) for a in args]), \
           tuple([(k, type(v)) for k,v in kwargs.iteritems()])

function_table = defaultdict(dict)
def overload(arg_types=(), kwarg_types=()):
    def wrap(func):
        named_func = function_table[func.__name__]
        named_func[arg_types, kwarg_types] = func
        def call_function_by_signature(*args, **kwargs):
            return named_func[determine_types(args, kwargs)](*args, **kwargs)
        return call_function_by_signature
    return wrap

overload应该用两个可选参数调用,一个表示所有位置参数类型的元组,另一个表示所有关键字参数名称-类型映射的元组。这里是一个使用示例:

>>> @overload((str, int))
... def f(a, b):
...     return a * b

>>> @overload((int, int))
... def f(a, b):
...     return a + b

>>> print f('a', 2)
aa
>>> print f(4, 2)
6

>>> @overload((str,), (('foo', int), ('bar', float)))
... def g(a, foo, bar):
...     return foo*a + str(bar)

>>> @overload((str,), (('foo', float), ('bar', float)))
... def g(a, foo, bar):
...     return a + str(foo*bar)

>>> print g('a', foo=7, bar=4.4)
aaaaaaa4.4
>>> print g('b', foo=7., bar=4.4)
b30.8

这种方法的缺陷包括:

  • 它实际上并不检查装饰器所应用的函数是否与给定给装饰器的参数兼容。您可以编写以下代码:

@overload((str, int))
def h():
    return 0

当函数被调用时,你会收到一个错误。

  • 它不会优雅地处理没有对应于传递的参数类型的重载版本的情况(提供更详细的错误信息可能有所帮助)。

  • 它区分命名参数和位置参数,所以像下面这样的东西:

    g('a', 7, bar=4.4)
    

    不起作用。

  • 使用它涉及到许多嵌套的括号,例如g的定义。
  • 如评论中所述,这不能处理在不同模块中具有相同名称的函数。
  • 我认为通过足够的调整,所有这些问题都可以解决。特别是,命名冲突的问题可以通过将分派表作为从修饰器返回的函数的属性来轻松解决。但正如我所说,这只是一个简单的示例,旨在演示如何进行基本操作。


    1
    经过一些修改,这将非常适合我的需求。我注意到你的实现中还有一个限制,因为你使用 func.__name__ 作为字典键,所以在模块之间容易出现名称冲突,这并不总是理想的。[续] - Paul Manta
    例如,如果我有一个重载func的模块,以及另一个完全不相关的模块也重载了func,这些重载将会发生冲突,因为函数分派字典是全局的。该字典应该在某种程度上被限制在模块内部,并且还应该支持某种形式的“继承”。 - Paul Manta
    通过“继承”,我的意思是:假设我有一个模块first,其中包含一些重载。然后还有两个没有任何关系但都导入了first的模块;这两个模块中的每一个都会向已经存在的重载中添加新的重载。这两个模块应该能够使用first中的重载,但是它们刚刚添加的新内容不应该在模块之间发生冲突。(事实上,这样做确实非常困难,现在我想起来了。) - Paul Manta
    快速回答:嘿,谢谢,我不知道已经有这个实现了。 - Paul Manta
    @PaulManta:其实,我也是直到一个小时前才知道的;-)虽然我可能应该一开始就想着查一下。 - David Z

    0

    这并不是直接回答你的问题,但如果你真的想要有像重载函数一样针对不同类型操作且(非常正确地)不想使用isinstance,那我建议你尝试类似以下的做法:

    def func(int_val=None, str_val=None):
        if sum(x != None for x in (int_val, str_val)) != 1:
            #raise exception - exactly one value should be passed in
        if int_val is not None:
            print('This is an int')
        if str_val is not None:
            print('This is a string')
    

    在使用中,意图是显而易见的,甚至不需要不同的选项具有不同的类型。
    func(int_val=3)
    func(str_val="squirrel")
    

    如果你有很多可能性,那么你可以使用**kwargs,使其更具编程性。我在一个构造函数中使用了17个可接受的关键字,并且它对我来说运行良好。 - Scott Griffiths

    网页内容由stack overflow 提供, 点击上面的
    可以查看英文原文,
    原文链接