“functools.partial”行为的基本原理

发布于 2025-01-12 19:09:45 字数 1427 浏览 4 评论 0 原文

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

设置:

from functools import partial
from inspect import signature

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

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

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

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

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=3 通过关键字,b 不会从签名中删除,这是对仅关键字的一种更改,我们仍然可以使用它(覆盖默认值,作为正常的默认值,在前面的情况下我们无法使用 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?

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

I'm wondering what the story -- whether sound design or inherited legacy -- is behind these functools.partial and inspect.signature facts (talking python 3.8 here).

Set up:

from functools import partial
from inspect import signature

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

All starts well with the following, which seems compliant with curry-standards.
We're fixing a to 3 positionally, a disappears from the signature and it's value is indeed bound to 3:

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

If we try to specify an alternate value for a, f won't tell us that we got an unexpected keyword, but rather that it got multiple values for argument a:

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'

But now if we fix b=3 through a keyword, b is not removed from the signature, it's kind changes to keyword-only, and we can still use it (overwrite the default, as a normal default, which we couldn't do with a in the previous case):

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

Why such asymmetry?

It gets even stranger, we can do this:

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

Fine: For keyword-only arguments, there can be no confusing of what parameter a default is assigned to, but I still wonder what design-thinking or constraints are behind these choices.

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。

评论(2

久光 2025-01-19 19:09:46

partial 与位置参数结合使用

f = partial(bar, 3)

根据设计,在调用函数时,首先分配位置参数。那么从逻辑上讲,3应该用partial分配给a。将其从签名中删除是有意义的,因为无法再次为其分配任何内容!

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

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

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

bar (3, 2, 2)

我们永远不会摆脱3

对于新的部分函数:

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

如果存在与关键字同名的参数,则参数值将分配给该参数槽。但是,如果参数槽已满,则会出现错误。

我建议阅读函数调用行为部分pep-3102 来更好地掌握这个问题。

partial 与关键字参数结合使用

f = partial(bar, b=3)

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

您在功能上变成

def bar(a, b):
    ...

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

b 成为仅关键字参数
而不是

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

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

请注意,此行为不一定会覆盖由f =partial(bar, b=3)提供的关键字参数,即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)

一个参数只能被分配一个关键字但位置?这是一个仅限关键字的参数。因此 (a, *, b=3) 而不是 (a, b=3)

非默认参数的基本原理遵循默认参数

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 是所谓的位置或关键字参数
  2. 您可以执行 def bar(*, a=3, b) 。 ab仅关键字参数

尽管从语义上讲,a 有一个默认值,因此它是可选,但我们不能将其保留为未分配状态,因为 b 是 <如果我们想按位置使用 b ,则需要为 code>positional-or-keyword argument 分配一个值。如果我们不为 a 提供值,则必须使用 b 作为关键字参数

将死! b 不可能像我们预期的那样成为位置或关键字参数

仅位置参数的 PEP 也很不错的显示了其背后的基本原理。

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

partial != 柯里化 &实现细节

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

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

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

这解释了 f(a=2, b=6) # TypeError: bar() gets multiple values参数'a'

另请参阅:为什么 partial 被称为 partial 而不是 curry

inspect 的背后

Inspect 的输出是另一个故事。

inspect 本身是一个可以生成用户友好输出的工具。特别是对于 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() gets multiple values for argument 'a'

后续

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

Using partial with a Positional Argument

f = partial(bar, 3)

By design, upon calling a function, positional arguments are assigned first. Then logically, 3 should be assigned to a with partial. It makes sense to remove it from the signature as there is no way to assign anything to it again!

when you have f(a=2, b=6), you are actually doing

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

when you have f(2, 2), you are actually doing

bar (3, 2, 2)

We never get rid of 3

For the new partial function:

  1. We can't give a a different value with another positional argument
  2. We can't use the keyword a to assign a different value to it as it is already "filled"

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.

I recommend reading the function calling behavior section of pep-3102 to get a better grasp of this matter.

Using partial with a Keyword Argument

f = partial(bar, b=3)

This is a different use case. We are applying a keyword argument to bar.

You are functionally turning

def bar(a, b):
    ...

into

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

where b becomes a keyword-only argument
instead of

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

inspect.signature correctly reflects a design decision of partial. The keyword arguments passed to partial are designed to append additional positional arguments (source).

Note that this behavior does not necessarily override the keyword arguments supplied with f = partial(bar, b=3), i.e., b=3 will be applied regardless of whether you supply the second positional argument or not (and there will be a TypeError if you do so). This is different from a positional argument with a default value.

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

where f(1, 2) is equivalent to bar(1, 2, b=3)

The only way to override it is with a keyword argument

>>> f(2, b=2)

An argument that can only be assigned with a keyword but positionally? This is a keyword-only argument. Thus (a, *, b=3) instead of (a, b=3).

The Rationale of Non-default Argument follows Default Argument

f = partial(bar, a=3)
assert str(signature(f)) == '(*, a=3, b)'  # whaaa?! non-default argument follows default argument?
  1. You can't do def bar(a=3, b). a and b are so called positional-or-keyword arguments.
  2. You can do def bar(*, a=3, b). a and b are keyword-only arguments.

Even though semantically, a has a default value and thus it is optional, we can't leave it unassigned because b, which is a positional-or-keyword argument needs to be assigned a value if we want to use b positionally. If we do not supply a value for a, we have to use b as a keyword argument.

Checkmate! There is no way for b to be a positional-or-keyword argument as we intended.

The PEP for positonal-only arguments also kind of shows the rationale behind it.

This also has something to do with the aforementioned "function calling behavior".

partial != Currying & Implementation Details

partial by its implementation wraps the original function while storing the fixed arguments you passed to it.

IT IS NOT IMPLEMENTED WITH CURRYING. It is rather partial application instead of currying in the sense of functional programming. partial is essentially applying the fixed arguments first, then the arguments you called with the wrapper:

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

This explains f(a=2, b=6) # TypeError: bar() got multiple values for argument 'a'.

See also: Why is partial called partial instead of curry

Under the Hood of inspect

The outputs of inspect is another story.

inspect itself is a tool that produces user-friendly outputs. For partial() in particular (and partialmethod(), similarly), it follows the wrapped function while taking the fixed parameters into account:

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

Do note that it is not inspect.signature's goal to show you the actual signature of the wrapped function in the 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.
    """
    ...

So we have a nice and ideal signature for f = partial(bar, 3)
but get f(a=2, b=6) # TypeError: bar() got multiple values for argument 'a' in reality.

Follow-up

If you want currying so badly, how do you implement it in Python, in the way which gives you the expected TypeError?

木有鱼丸 2025-01-19 19:09:46

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

f = 部分(条形图, 3)
f(a=2, b=6) # TypeError: bar() 得到参数 'a' 的多个值
f(c=2, b=6) # TypeError: bar() 有一个意外的关键字参数 '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'

但是现在如果我们通过关键字修复 b=3,b 不会从签名中删除,
f = 部分(条形图,b=3)
断言 str(signature(f)) == '(a, *, b=3)'
断言 f(6) == 2.0 == f(6, b=3)
断言 f(6, b=1) == 6.0

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

到目前为止,不存在不一致或不对称的情况。

f = 部分(bar, a=3)
断言 str(signature(f)) == '(*, a=3, b)' # 哇啊?!非默认参数跟随默认参数?

这里的签名是有意义的,并且是 partial(bar, a=3) 的期望——它的工作方式与 def f(*, a=3, b): ... 是本例中的正确签名。请注意,在这种情况下,当您向 partial 提供 a=3 时,a 将成为仅关键字参数,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={})

When you provide positional or keyword arguments to partial, the new function is constructed

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'

This is actually consistent with the idea of partial, which is that arguments are passed to the wrapped function with the addition of positional and keyword arguments passed to partial

These cases behave as expected:

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

This case is different from the above because in the previous case, a positional argument was provided to partial, not a keyword argument. When positional arguments are provided to partial, then it makes sense to remove them from the signature. Arguments provided as keywords are not removed from the signature.

So far, there is no inconsistency or asymmetry.

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

The signature here makes sense and is the expectation for partial(bar, a=3) -- it works the same as def f(*, a=3, b): ... and is the correct signature in this case. Note that when you provide a=3 to partial in this case, a becomes a keyword-only argument, as does b.

This is because when a positional argument is provided as a keyword, all following arguments must be specified keyword arguments.

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={})
~没有更多了~
我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
原文