【问题标题】:Overloading (or alternatives) in Python API designPython API 设计中的重载(或替代方案)
【发布时间】:2014-08-16 02:46:22
【问题描述】:

我有一个现有的大型程序库,目前有一个 .NET 绑定,我正在考虑编写一个 Python 绑定。现有的 API 广泛使用了基于签名的重载。因此,我收集了大量静态函数,例如:

Circle(p1, p2, p3) -- Creates a circle through three points
Circle(p, r)       -- Creates a circle with given center point and radius
Circle(c1, c2, c3) -- Creates a circle tangent to three curves

在某些情况下,必须以不同的方式使用相同的输入,因此基于签名的重载不起作用,而我必须使用不同的函数名。例如

BezierCurve(p1,p2,p3,p4) -- Bezier curve using given points as control points
BezierCurveThroughPoints(p1,p2,p3,p4) -- Bezier curve passing through given points

我想第二种技术(使用不同的函数名)可以在 Python API 的任何地方使用。所以,我会的

CircleThroughThreePoints(p1, p2, p3)
CircleCenterRadius(p, r)
CircleTangentThreeCurves(c1, c2, c3)

但是这些名称看起来冗长令人不快(我不喜欢缩写),并且发明所有这些名称将是一个相当大的挑战,因为该库有数千个函数。

低优先级:
努力(就我而言)——我不在乎是否必须编写大量代码。
性能

高优先级:
调用者易于使用/理解(许多将是编程新手)。
我很容易写出好的文档。
简单——避免调用者代码中需要高级概念。

我确信我不是第一个希望在 Python 中实现基于签名的重载的人。人们通常使用哪些变通方法?

【问题讨论】:

  • 您可以在 Python 中以不同方式模拟基于签名的重载,但对于 BezierCurveBezierCurveThroughPoints 示例之类的情况,您将需要使用不同的函数名称或将参数添加到单个函数,用于区分它们并提供一种方法来确定其他相同参数的预期用途。
  • 明白。即使在 VB 和 C# 中,在这种情况下我也需要不同的函数名。问题是我的 Python API 是否应该在所有情况下都使用这种方法。

标签: python overloading


【解决方案1】:

一种选择是在构造函数中专门使用关键字参数,并包含逻辑来确定应该使用什么:

class Circle(object):
    def __init__(self, points=(), radius=None, curves=()):
        if radius and len(points) == 1:
            center_point = points[0]
            # Create from radius/center point
        elif curves and len(curves) == 3:
            # create from curves
        elif points and len(points) == 3:
            # create from points
        else:
            raise ValueError("Must provide a tuple of three points, a point and a radius, or a tuple of three curves)

您还可以使用类方法让 API 的用户更轻松:

class Circle(object):
    def __init__(self, points=(), radius=None, curves=()):
         # same as above

    @classmethod
    def from_points(p1, p2, p3):
        return cls(points=(p1, p2, p3))

    @classmethod
    def from_point_and_radius(cls, point, radius):
        return cls(points=(point,), radius=radius)

    @classmethod
    def from_curves(cls, c1, c2, c3):
        return cls(curves=(c1, c2, c3))

用法:

c = Circle.from_points(p1, p2, p3)
c = Circle.from_point_and_radius(p1, r)
c = Circle.from_curves(c1, c2, c3)

【讨论】:

  • from_points 和朋友被称为工厂方法,这是一个非常有用的模式。
  • 您对classmethod 替代构造函数的建议是一个很好的建议,但是对于重载的__init__ 方法,我建议使用*args 并检查类型,而不是要求关键字来区分选项。
  • @bubba 很明显,当您调用Circle.from_points 时返回的对象是Circle,但是在没有先查看文档的情况下CircleThroughThreePoints 将返回的内容有点模棱两可。它也是 Python 中相当常用的一个习语。例如,内置的datetime 模块使用它。
  • @bubba 仅提供带有*args 的构造函数/函数会使用户难以确定哪些输入是有效的,并且 使您更难弄清楚如果用户提供了有效的输入。用户知道什么输入是合法的唯一方法是阅读文档,这不是好的 API 设计。然后你必须在运行时进行大量的自省,以确定用户提供了什么以及它是否有效,这对你和用户来说都是容易出错的。更冗长的方法不太优雅,但可以自我记录并且不易受到错误的影响。
  • @bubba 是的,拥有真正的函数重载会很好。在 Python 3.x 中有 functools.singledispatch,但它非常有限(仅限顶级函数,重载严格基于第一个参数)。
【解决方案2】:

有几个选项。

您可以有一个构造函数来接受任意数量的参数(使用*args 和/或**varargs 语法),并根据参数的数量和类型执行不同的操作。

或者,您可以将辅助构造函数编写为类方法。这些被称为“工厂”方法。如果您有多个构造函数采用相同数量的相同类的对象(如在您的 BezierCurve 示例中),这可能是您唯一的选择。

如果您不介意重写__new__ 而不是__init__,您甚至可以同时使用这两种方法,__new__ 方法自己处理一种形式的参数,并将其他类型引用到工厂方法进行正则化。这是一个可能看起来像的示例,包括__new__ 的多个签名的文档字符串:

class Circle(object):
    """Circle(center, radius) -> Circle object
       Circle(point1, point2, point3) -> Circle object
       Circle(curve1, curve2, curve3) -> Circle object

       Return a Circle with the provided center and radius. If three points are given,
       the center and radius will be computed so that the circle will pass through each
       of the points. If three curves are given, the circle's center and radius will
       be chosen so that the circle will be tangent to each of them."""

    def __new__(cls, *args):
        if len(args) == 2:
            self = super(Circle, cls).__new__(cls)
            self.center, self.radius = args
            return self
        elif len(args) == 3:
            if all(isinstance(arg, Point) for arg in args):
                return Circle.through_points(*args)
            elif all(isinstance(arg, Curve) for arg in args):
                return Circle.tangent_to_curves(*args)
        raise TypeError("Invalid arguments to Circle()")

    @classmethod
    def through_points(cls, point1, point2, point3):
        """from_points(point1, point2, point3) -> Circle object

        Return a Circle that touches three points."""

        # compute center and radius from the points...
        # then call back to the main constructor:
        return cls(center, radius)

    @classmethod
    def tangent_to_curves(cls, curve1, curve2, curve3):
        """from_curves(curve1, curve2, curve3) -> Circle object

        Return a Circle that is tangent to three curves."""

        # here too, compute center and radius from curves ...
        # then call back to the main constructor:
        return cls(center, radius)

【讨论】:

  • 这是一个很好的模式(BDFL 自己在 Google App Engine 的 NDB API 中广泛使用),但是您有几个错误。您已经使用def 而不是class 来定义类,并且您的所有类方法都没有将cls 作为第一个参数。最后,用下划线作为“私有”方法(我用这个词很宽松)开头是一种很好的风格。
  • 感谢您指出我愚蠢的错别字。我已修复 def 并缺少 cls 参数。我留下的名称没有下划线,因为工厂方法可以是公共 API 的一部分(即使它们有些多余,考虑到主构造函数)。
  • @Blckknght 我认为您在__new__ 中缺少return self
【解决方案3】:

PyPI 中有许多模块可以帮助您进行基于签名的重载和调度:multipledispatchmultimethodsDispatching - 我没有真正的经验,但 multipledispatch 看起来像你想要并且它有据可查。以你的圈子为例:

from multipledispatch import dispatch

class Point(tuple):
    pass

class Curve(object):         
    pass

@dispatch(Point, Point, Point)
def Circle(point1, point2, point3):
    print "Circle(point1, point2, point3): point1 = %r, point2 = %r, point3 = %r" % (point1, point2, point3)

@dispatch(Point, int)
def Circle(centre, radius):
    print "Circle(centre, radius): centre = %r, radius = %r" % (centre, radius)

@dispatch(Curve, Curve, Curve)
def Circle(curve1, curve2, curve3):
    print "Circle(curve1, curve2, curve3): curve1 = %r, curve2 = %r, curve3 = %r" % (curve1, curve2, curve3)


>>> Circle(Point((10,10)), Point((20,20)), Point((30,30)))
Circle(point1, point2, point3): point1 = (10, 10), point2 = (20, 20), point3 = (30, 30)
>>> p1 = Point((25,10))
>>> p1
(10, 10)
>>> Circle(p1, 100)
Circle(centre, radius): centre = (25, 10), radius = 100

>>> Circle(*(Curve(),)*3)
Circle(curve1, curve2, curve3): curve1 = <__main__.Curve object at 0xa954d0>, curve2 = <__main__.Curve object at 0xa954d0>, curve3 = <__main__.Curve object at 0xa954d0>

>>> Circle()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/home/mhawke/virtualenvs/urllib3/lib/python2.7/site-packages/multipledispatch/dispatcher.py", line 143, in __call__
    func = self.resolve(types)
  File "/home/mhawke/virtualenvs/urllib3/lib/python2.7/site-packages/multipledispatch/dispatcher.py", line 184, in resolve
    (self.name, str_signature(types)))
NotImplementedError: Could not find signature for Circle: <>

也可以装饰实例方法,这样可以提供__init__()的多种实现,相当不错。如果您在类中实现任何实际行为,例如Circle.draw(),您需要一些逻辑来计算出可用于绘制圆的值(中心和半径、3 点等)。但是由于这只是为了提供一组绑定,您可能只需要调用正确的本机代码函数并传递参数即可:

from numbers import Number
from multipledispatch import dispatch

class Point(tuple):
    pass

class Curve(object):
    pass

class Circle(object):
    "A circle class"

    # dispatch(Point, (int, float, Decimal....))
    @dispatch(Point, Number)
    def __init__(self, centre, radius):
        """Circle(Point, Number): create a circle from a Point and radius."""

        print "Circle.__init__(): centre %r, radius %r" % (centre, radius)

    @dispatch(Point, Point, Point)
    def __init__(self, point1, point2, point3):
        """Circle(Point, Point, Point): create a circle from 3 points."""

        print "Circle.__init__(): point1 %r, point2 %r, point3 = %r" % (point1, point2, point3)

    @dispatch(Curve, Curve, Curve)
    def __init__(self, curve1, curve2, curve3):
        """Circle(Curve, Curve, Curve): create a circle from 3 curves."""

        print "Circle.__init__(): curve1 %r, curve2 %r, curve3 = %r" % (curve1, curve2, curve3)

    __doc__ = '' if __doc__ is None else '{}\n\n'.format(__doc__)
    __doc__ += '\n'.join(f.__doc__ for f in __init__.funcs.values())


>>> print Circle.__doc__
A circle class

Circle(Point, Number): create a circle from a Point and radius.
Circle(Point, Point, Point): create a circle from 3 points.
Circle(Curve, Curve, Curve): create a circle from 3 curves.

>>> for num in 10, 10.22, complex(10.22), True, Decimal(100):
...     Circle(Point((10,20)), num)
... 
Circle.__init__(): centre (10, 20), radius 10
<__main__.Circle object at 0x1d42fd0>
Circle.__init__(): centre (10, 20), radius 10.22
<__main__.Circle object at 0x1e3d890>
Circle.__init__(): centre (10, 20), radius (10.22+0j)
<__main__.Circle object at 0x1d42fd0>
Circle.__init__(): centre (10, 20), radius True
<__main__.Circle object at 0x1e3d890>
Circle.__init__(): centre (10, 20), radius Decimal('100')
<__main__.Circle object at 0x1d42fd0>

>>> Circle(Curve(), Curve(), Curve())
Circle.__init__(): curve1 <__main__.Curve object at 0x1e3db50>, curve2 <__main__.Curve object at 0x1d42fd0>, curve3 = <__main__.Curve object at 0x1d4b1d0>
<__main__.Circle object at 0x1d4b4d0>

>>> p1=Point((10,20))
>>> Circle(*(p1,)*3)
Circle.__init__(): point1 (10, 20), point2 (10, 20), point3 = (10, 20)
<__main__.Circle object at 0x1e3d890>

>>> Circle()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/home/mhawke/virtualenvs/urllib3/lib/python2.7/site-packages/multipledispatch/dispatcher.py", line 235, in __call__
    func = self.resolve(types)
  File "/home/mhawke/virtualenvs/urllib3/lib/python2.7/site-packages/multipledispatch/dispatcher.py", line 184, in resolve
    (self.name, str_signature(types)))
NotImplementedError: Could not find signature for __init__: <>

【讨论】:

  • 看起来很有用。你知道它是否支持多态参数吗?例如,如果BezierCurve 派生自Curve,那么Circle(bezier1, bezier2, bezier3) 是否仍会分派给__init__(self, curve1, curve2, curve3)
  • 谢谢。在这种方法中,文档在哪里?如果用户想知道如何调用 3 个(或更多)函数中的每一个来创建圆圈,他(或她)在哪里看?
  • @martineau -- 我是 Python 新手,所以也许这是一个愚蠢的问题。如果 Python 函数被设计为接收某些类型的对象作为输入,那么如果输入派生类型的对象,它难道不会自动工作吗??
  • @bubba:Python 函数并不是真正“设计”来接收任何特定类型的对象。唯一可以出现的方法是,如果在函数中的某个地方,代码试图对其中一个参数做一些非法的事情。只要它们都公开相同的方法,就没有关系。例如,如果一个函数只是为集合中的每个项目添加 1,那么您可以传递一个列表、元组、字典、文件对象,以及任何真正的迭代器。如果它像鸭子一样走路和鸭子一样嘎嘎......
  • @bubba:重新记录,我认为您需要在类级别记录,而不是 __init__() 的各个版本。然后help(Circle) 将显示类文档字符串(以及方法文档字符串)。 Circle.__init__.__doc__multipledispatch 设置。
【解决方案4】:

你可以像这样使用字典

Circle({'points':[p1,p2,p3]})
Circle({'radius':r})
Circle({'curves':[c1,c2,c3])

初始化器会说

def __init__(args):
  if len(args)>1:
    raise SomeError("only pass one of points, radius, curves")
  if 'points' in args: {blah}
  elsif 'radius' in args: {blahblah}
  elsif 'curves' in args: {evenmoreblah}
  else: raise SomeError("same as above")

【讨论】:

  • 谢谢。这是一种常见的方法,还是人们(尤其是新手)可能会觉得它很奇怪/异国情调/令人困惑?
  • 您可能应该使用**kwargs 而不是这样,因为您想强制使用关键字参数。
【解决方案5】:

一种方法是自己编写代码解析参数。那么您根本不必更改 API。你甚至可以编写一个装饰器,让它可以重用:

import functools

def overload(func):
  '''Creates a signature from the arguments passed to the decorated function and passes it as the first argument'''
  @functools.wraps(func)
  def inner(*args):
    signature = tuple(map(type, args))
    return func(signature, *args)
  return inner

def matches(collection, sig):
  '''Returns True if each item in collection is an instance of its respective item in signature'''
  if len(sig)!=len(collection): 
    return False
  return all(issubclass(i, j) for i,j in zip(collection, sig))

@overload
def Circle1(sig, *args):  
  if matches(sig, (Point,)*3):
    #do stuff with args
    print "3 points"
  elif matches(sig, (Point, float)):
    #as before
    print "point, float"
  elif matches(sig, (Curve,)*3):
    #and again
    print "3 curves"
  else:
    raise TypeError("Invalid argument signature")

# or even better
@overload
def Circle2(sig, *args):
  valid_sigs = {(Point,)*3: CircleThroughThreePoints,
                (Point, float): CircleCenterRadius,
                (Curve,)*3: CircleTangentThreeCurves
               }
  try:  
    return (f for s,f in valid_sigs.items() if matches(sig, s)).next()(*args)
  except StopIteration:
    raise TypeError("Invalid argument signature")

对 API 用户的看法:

这是最好的部分。对于 API 用户,他们只会看到以下内容:

>>> help(Circle)

Circle(*args)
  Whatever's in Circle's docstring. You should put info here about valid signatures.

他们可以像您在问题中显示的那样致电Circle

工作原理:

整个想法是对 API 隐藏签名匹配。这是通过使用decorator 创建一个签名来完成的,基本上是一个包含每个参数类型的元组,并将其作为第一个参数传递给函数。

重载:

当您使用@overload 装饰函数时,会使用该函数作为参数调用overload。无论返回什么(在本例中为 inner)都会替换修饰函数。 functools.wraps 确保新函数具有相同的名称、文档字符串等。

Overload 是一个相当简单的装饰器。它所做的只是为每个参数的类型创建一个元组,并将该元组作为第一个参数传递给修饰函数。

圈取 1:

这是最简单的方法。在函数开始时,只需针对所有有效签名测试签名。

圆拍2:

这有点花哨。好处是您可以在一个地方一起定义所有有效签名。 return 语句使用生成器从字典中过滤匹配的有效签名,.next() 只得到第一个。由于整个语句返回一个函数,您可以在之后粘贴() 来调用它。如果没有任何有效签名匹配,.next() 会引发 StopIteration

总而言之,这个函数只是返回带有匹配签名的函数的结果。

最后说明:

您在这段代码中经常看到的一件事是*args 构造。在函数定义中使用时,它只是将所有参数存储在名为“args”的列表中。在其他地方,它扩展了一个名为 args 的列表,以便每个项目都成为函数的参数(例如 a = func(*args))。

我认为在 Python 中做这样奇怪的事情来呈现干净的 API 并不少见。

【讨论】:

  • 谢谢。如果它不是太不符合 Pythonic 的话,那看起来很有希望。抱歉,我还不够聪明,无法理解您写的内容。函数调用/用法和函数文档是什么样的?
  • 有趣的方法。然而,完成的类型匹配是非 Pythonic(非多态),因为它不支持从显式命名的类型派生的类型的参数。您可以通过在签名匹配中使用 isinstance() 而不是 type() 来解决此问题。
  • > 不支持从显式命名的类型派生的类型的参数。那将是一个大问题。我有许多从 Curve 派生的不同曲线类型,我需要能够接收这些作为输入,以便在 TangentThreeCurves 变体中使用。
  • @bubba:我稍微改动了一下。现在它将支持派生类型。
  • 看起来不错。您可能要考虑的一个(其他)增强功能是允许为每个参数指定 [不相关] 类型的子元组。例如,可以用(Point, (float, int)) 代替(Point, float),这样半径参数可以指定为实数或整数值。由于issubclass() 的第二个参数可以是一个元组,也许这已经是允许的......
猜你喜欢
  • 1970-01-01
  • 2013-11-16
  • 2015-11-27
  • 2012-10-13
  • 2023-04-08
  • 2012-09-08
  • 2016-12-15
  • 1970-01-01
  • 2012-07-07
相关资源
最近更新 更多