`functools.partial` 行为的基本原理

The rationale of `functools.partial` behavior

我想知道这些 functools.partialinspect.signature 事实背后的故事 - 无论是声音设计还是继承的遗产(在这里谈论 python 3.8)。

设置:

from functools import partial
from inspect import signature

def bar(a, b):
    return a / b

一切从以下开始,似乎符合 curry 标准。 我们在位置上将 a 固定为 3a 从签名中消失,它的值确实绑定到 3:

f = partial(bar, 3)
assert str(signature(f)) == '(b)'
assert f(6) == 0.5 == f(b=6)

如果我们尝试为 a 指定替代值,f 不会告诉我们我们得到了一个意外的关键字,而是它得到了参数 [=18= 的多个值]:

f(a=2, b=6)  # TypeError: bar() got multiple values for argument 'a'
f(c=2, b=6)  # TypeError: bar() got an unexpected keyword argument 'c'

但现在如果我们通过关键字修复 b=3b 而不是 从签名中删除,它只是更改为关键字,并且我们仍然可以使用它(覆盖默认值,作为正常的默认值,在之前的情况下我们不能使用 a):

f = partial(bar, b=3)
assert str(signature(f)) == '(a, *, b=3)'
assert f(6) == 2.0 == f(6, b=3)
assert f(6, b=1) == 6.0

为什么会这样不对称?

更奇怪的是,我们可以这样做:

f = partial(bar, a=3)
assert str(signature(f)) == '(*, a=3, b)'  # whaaa?! non-default argument follows default argument?

很好:对于仅关键字参数,不会混淆默认值分配给哪个参数,但我仍然想知道这些选择背后的设计思想或约束是什么。

当您向 partial 提供位置或关键字参数时,将构造新函数

f = partial(bar, 3)
f(a=2, b=6) # TypeError: bar() got multiple values for argument 'a'
f(c=2, b=6) # TypeError: bar() got an unexpected keyword argument 'c'

这其实和partial的思路是一致的,就是参数传递给包装函数加上传递给partial

的位置参数和关键字参数

这些情况符合预期:

bar(3, a=2, b=6)  # TypeError: bar() got multiple values for argument 'a'
bar(3, c=2, b=6)  # TypeError: bar() got an unexpected keyword argument 'c'

But now if we fix b=3 through a keyword, b is not removed from the signature,
f = partial(bar, b=3)
assert str(signature(f)) == '(a, *, b=3)'
assert f(6) == 2.0 == f(6, b=3)
assert f(6, b=1) == 6.0

这种情况与上面的情况不同,因为在前面的情况中,位置参数被提供给partial,而不是关键字参数。当向 partial 提供位置参数时,将它们从签名中删除是有意义的。作为关键字提供的参数不会从签名中删除。

到目前为止,没有不一致或不对称。

f = partial(bar, a=3)
assert str(signature(f)) == '(*, a=3, b)' # whaaa?! non-default argument follows default argument?

这里的签名是有意义的,是对 partial(bar, a=3) 的期望——它与 def f(*, a=3, b): ... 的工作原理相同,在这种情况下是正确的签名。请注意,在这种情况下,当您将 a=3 提供给 partial 时,a 将成为 keyword-only 参数,b.

也是如此

这是因为当位置参数作为关键字提供时,所有后续参数必须指定为关键字参数。

sig = signature(f)
sig.parameters['a'].kind  # <_ParameterKind.KEYWORD_ONLY: 3>
inspect.getfullargspec(f)
# FullArgSpec(args=[], varargs=None, varkw=None, defaults=None, kwonlyargs=['a', 'b'], kwonlydefaults={'a': 3}, annotations={})

partial 与位置参数一起使用

f = partial(bar, 3)

根据设计,在调用函数时,首先分配位置参数。那么按理来说,3应该赋值给apartial。将其从签名中删除是有意义的,因为无法再次为其分配任何内容!

当你有f(a=2, b=6)时,你实际上在做

bar(3, a=2, b=6)

当你有f(2, 2)时,你实际上在做

bar (3, 2, 2)

我们永远摆脱不了3

对于新的偏函数:

  1. 我们不能给 a 一个带有另一个位置参数的不同值
  2. 我们不能使用关键字 a 为其分配不同的值,因为它已经“填充”了

If there is a parameter with the same name as the keyword, then the argument value is assigned to that parameter slot. However, if the parameter slot is already filled, then that is an error.

我建议阅读 function calling behavior section of pep-3102 以更好地掌握这件事。

partial 与关键字参数一起使用

f = partial(bar, b=3)

这是一个不同的用例。我们将关键字参数应用于 bar.

你的功能正在转向

def bar(a, b):
    ...

进入

def f(a, *, b=3):
    ...

其中 b 成为 keyword-only 参数 而不是

def f(a, b=3):
    ...

inspect.signature 正确反映了 partial 的设计决策。传递给 partial 的关键字参数旨在附加额外的位置参数 (source)。

请注意,此行为 不一定 覆盖 f = partial(bar, b=3) 提供的关键字参数,即无论您是否提供是否有第二个位置参数(如果你这样做,将会有一个 TypeError)。这与具有默认值的位置参数不同。

>>> f(1, 2)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: f() takes 1 positional argument but 2 were given

其中 f(1, 2) 等同于 bar(1, 2, b=3)

覆盖它的唯一方法是使用关键字参数

>>> f(2, b=2)

一个只能用关键字赋值但位置赋值的参数?这是一个 keyword-only 论点。因此 (a, *, b=3) 而不是 (a, b=3).

Non-default 参数的基本原理遵循默认参数

f = partial(bar, a=3)
assert str(signature(f)) == '(*, a=3, b)'  # whaaa?! non-default argument follows default argument?
  1. 你做不到def bar(a=3, b)ab就是所谓的positional-or-keyword arguments.
  2. 你可以做到 def bar(*, a=3, b)abkeyword-only arguments

即使在语义上,a 有一个默认值,因此它是 可选的 ,我们不能让它未分配,因为 b,这是如果我们想在位置上使用 b,则需要为 positional-or-keyword argument 赋值。如果我们不为 a 提供值,我们必须使用 b 作为 keyword argument.

将死! b 不可能像我们预期的那样成为 positional-or-keyword argument

positonal-only arguments 的 PEP 也显示了它背后的基本原理。

这也与前面提到的“函数调用行为”有关。

partial != Currying 和实现细节

partial 通过其实现包装原始函数,同时存储您传递给它的固定参数。

它不是通过 CURRYING 实现的。它更像是 部分应用程序 而不是函数式编程意义上的柯里化。 partial 本质上是首先应用固定参数,然后是您使用包装器调用的参数:

def __call__(self, /, *args, **keywords):
    keywords = {**self.keywords, **keywords}
    return self.func(*self.args, *args, **keywords)

这解释了 f(a=2, b=6) # TypeError: bar() got multiple values for argument 'a'

另请参阅:Why is partial called partial instead of curry

inspect

的幕后花絮

inspect 的输出是另一回事。

inspect 本身就是一个产生 user-friendly 输出的工具。特别是对于 partial()(和 partialmethod(),类似地),它遵循包装函数,同时考虑固定参数:

if isinstance(obj, functools.partial):
    wrapped_sig = _get_signature_of(obj.func)
    return _signature_get_partial(wrapped_sig, obj)

请注意,inspect.signature 的目标不是向您展示 AST 中包装函数的实际签名。

def _signature_get_partial(wrapped_sig, partial, extra_args=()):
    """Private helper to calculate how 'wrapped_sig' signature will
    look like after applying a 'functools.partial' object (or alike)
    on it.
    """
    ...

所以我们为 f = partial(bar, 3) 提供了一个漂亮而理想的签名 但在现实中得到f(a=2, b=6) # TypeError: bar() got multiple values for argument 'a'

Follow-up

如果你非常想要柯里化,你如何在 Python 中以给你预期的方式实现它 TypeError?