如何通过纯粹使用Python的标准库来组合函数?

How to compose functions through purely using Python's standard library?

提问人:ruohola 提问时间:11/5/2023 最后编辑:ruohola 更新时间:11/22/2023 访问量:419

问:

Python 的标准库非常庞大,我的直觉告诉我,它一定有一种方法可以做到这一点,但我就是想不通。这纯粹是出于好奇心和学习目的:

我有两个简单的函数:

def increment(x):
    return x + 1

def double(x):
    return x * 2

我想将它们组合成一个新功能.我当然可以简单地这样做:double_and_increment

double_and_increment = lambda x: increment(double(x))

但我也可以用一种更复杂但可能更“符合人体工程学的可扩展性”的方式来做到这一点:

import functools

double_and_increment = functools.partial(functools.reduce, lambda acc, f: f(acc), [double, increment])

以上两种方法都可以正常工作:

>>> double_and_increment(1)
3

现在的问题是,标准库中是否有工具可以在没有任何用户定义的 lambda、常规函数或类的情况下实现组合。

第一种直觉是将 functools.reduce 调用中的定义替换为 operator.call,但不幸的是,这会以相反的顺序获取参数:lambda acc, f: f(acc)

>>> (lambda acc, f: f(acc))(1, str)  # What we want to replace.
>>> '1'
>>> import operator
>>> operator.call(str, 1)  # Incorrect argument order.
>>> '1'

我有一种预感,使用仍然是完成组合的方法,但就我而言,我一辈子都想不出摆脱用户定义的 lambda 的方法。functools.reduce

一些开箱即用的方法让我接近:

import functools, operator

# Curried form, can't figure out how to uncurry.
functools.partial(operator.methodcaller, '__call__')(1)(str)

# The arguments needs to be in the middle of the expression, which does not work.
operator.call(*reversed(operator.attrgetter('args')(functools.partial(functools.partial, operator.call)(1, str))))

已经浏览了所有现有问题,但它们完全不同,并且依赖于使用用户定义的函数和/或 lambda。

Python 函数式编程 标准库 语言特性 组合

评论

1赞 njzk2 11/5/2023
标记为重复的问题中得票最高的答案有什么问题?(stackoverflow.com/a/24047214/671543)
0赞 ruohola 11/5/2023
@njzk2 它定义了 ,而 又使用 .我想“滥用”该语言,只使用标准库中的现有定义。compose2lambda
2赞 Barmar 11/5/2023
我认为它是重复的,因为它列出了十几种实现您想要的方法。选择最接近您需求的一种。如果有一种内置的方法可以完全按照您的意愿行事,那么它就在那里。
2赞 Barmar 11/5/2023
你的限制似乎是武断的,基本上我对这种关闭说的是“对不起,这是不可能的。
1赞 njzk2 11/5/2023
如果你不想要一个lambda,没有什么能阻止你定义一个函数,我不确定我是否明白。您是否在问是否有包含已定义函数的函数?

答:

6赞 AKX 11/5/2023 #1

好吧,既然你说

我想“滥用”该语言,只使用标准库中的现有定义

从 Python 3.12 开始,测试套件恰好包含您想要的小工具

import functools
import operator
from test.test_zipfile._path._functools import compose

increment = functools.partial(operator.add, 1)
double = functools.partial(operator.mul, 2)
increment_and_double = compose(increment, double)
print(increment_and_double(10))

(我通过本地 CPython 结账时的战略发现了这一点。ag compose

评论

0赞 ruohola 11/6/2023
没想到会这么简单,谢谢!
0赞 Abhijit Sarkar 11/8/2023
那么,一个看似有用的函数只能通过一长串用于测试的下划线导入才能使用?看来这将是对现有 .functools
2赞 AKX 11/8/2023
@AbhijitSarkar我不同意。没有OP对自己不定义任何东西的人为限制,这只是......def compose(fa, fb): return lambda x: fa(fb(x))
0赞 ruohola 11/8/2023
完全同意标准库不需要这个。
1赞 ruohola 11/16/2023
@SuramuthuR不会。这个问题纯粹是出于好奇。在任何实际任务中,绝对不应该将自己局限于标准库。
4赞 blhsing 11/10/2023 #2

虽然@AKX CPython 代码树中找到完美实现 OP 所需的函数组合功能的函数很酷,但它实际上并不属于问题规则所要求的标准库,原因如下:test.test_zipfile._path._functools.compose

  • 它属于 Python 语言的 CPython 实现的测试套件中的帮助程序模块。
  • 测试套件不是该语言标准库的一部分;它只是验证语言及其标准库的特定实现的代码。
  • 测试套件(更不用说测试套件中的任何帮助程序功能)可以随时删除,而无需任何正常的高级弃用警告正当程序。
  • Python 的其他实现不需要包含任何 CPython 的测试套件以符合 Python 的规范。

因此,如果没有 CPython 3.12 测试套件中不属于标准库的辅助函数,我相信 OP 在评估中确实是正确的,即 Python 的标准库中没有开箱即用的工具可以实现函数组合。

但是,这并不意味着我们不能通过修改现有工具来实现它,因为 OP 的规则只是使用“标准库中的工具,这些工具允许在没有任何用户定义的 lambda、常规函数或类的情况下实现组合”。

由于 OP 几乎已经得到了它:

double_and_increment = partial(reduce, lambda acc, f: f(acc), [double, increment])

和:

>>> (lambda acc, f: f(acc))(1, str)  # What we want to replace.
>>> '1'
>>> import operator
>>> operator.call(str, 1)  # Incorrect argument order.
>>> '1'

这里真正的问题是,我们如何修改标准库中的现有函数,使其变为:

def rcall(value, obj):
    return obj(value)

为此,让我们看一下上述函数的字节码,以及定义参数的代码对象的相关属性:

>>> import dis
>>> def call(value, obj):
...     return obj(value)
...
>>> dis.dis(call)
  1           0 RESUME                   0

  2           2 PUSH_NULL
              4 LOAD_FAST                1 (obj)
              6 LOAD_FAST                0 (value)
              8 PRECALL                  1
             12 CALL                     1
             22 RETURN_VALUE
>>> c = call.__code__
>>> c.co_varnames
('value', 'obj')
>>> c.co_argcount
2
>>> c.co_nlocals
2
>>>

这并不奇怪。一个简单的函数体,将第二个参数 () 和第一个参数 () 加载到堆栈上,然后使用堆栈中的可调用对象和参数进行调用,最后将堆栈顶部的值返回给调用方。objvalue

现在,让我们在标准库中找到一个类似的简单函数,该函数接受一两个参数并用它/它们进行调用,因此可以更轻松地将其修改为我们想要的函数。事实证明,operator.abs 就是这样一个函数,它接受一个参数并对内置函数进行包装调用:_abs

def abs(a):
    "Same as abs(a)."
    return _abs(a)

我们想拆解它进行比较,但不幸的是,如果我们尝试访问 ,你会得到一个错误:operator.abs.__code__

>>> import operator
>>> operator.abs.__code__
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: 'builtin_function_or_method' object has no attribute '__code__'. Did you mean: '__call__'?
>>>

这是因为 CPython 的模块实现包括一个模块,该模块用 C 语言实现的函数覆盖了所有纯 Python 函数,并在 operator.py 中有一个块:operator_operatoroperator.pytry

try:
    from _operator import *
except ImportError:
    pass

用 C 语言实现的函数没有对象,因此无法修改。我们需要的是纯 Python 版本的 ,然后再被 覆盖。但是我们如何避免覆盖呢?好吧,我们可以先自己导入模块,然后从中删除属性,以便将修改后的模块缓存在其中,以便在导入时,它得到的是我们的修改版本,而不是其中:__code__operator.call_operator.call_operatorcallsys.modulesoperator.py_operatorcall

>>> try: # other Python implementations may not have _operator.py
...     import _operator
...     del _operator.call
... except ImportError:
...     pass
...
>>> import operator
>>> operator.call.__code__
<code object call at 0x000001F68F4FADB0, file "C:\python311\Lib\operator.py", line 226>

伟大!现在我们终于可以看一下 代码对象的字节码和相关属性了:operator.abs

>>> dis.dis(operator.abs)
 71           0 RESUME                   0

 73           2 LOAD_GLOBAL              1 (NULL + _abs)
             14 LOAD_FAST                0 (a)
             16 PRECALL                  1
             20 CALL                     1
             30 RETURN_VALUE
 71           0 RESUME                   0
>>> c = operator.abs.__code__
>>> c.co_varnames
('a',)
>>> c.co_argcount
1
>>> c.co_nlocals
1
>>>

可以看出,我们需要修改才能变成我们想要的函数对象,只需将指令替换为(以指示 CALL 的常规函数调用)和(加载第二个参数,可调用对象)以及 ,并添加第二个参数。operator.absLOAD_GLOBALPUSH_NULLLOAD_FAST 1co_varnamesco_argcountco_nlocalsobj

要从现有的代码对象中获取修改后的代码对象,我们可以调用它的 replace 方法:operator.abs

try:
    import _operator
    del _operator.abs
except ImportError:
    pass
from operator import abs as rcall
from opcode import opmap
from functools import partial, reduce

code = bytearray(rcall.__code__.co_code)
code[code.find(opmap['LOAD_GLOBAL']):code.find(opmap['LOAD_FAST'])] = \
    opmap['PUSH_NULL'], 0, opmap['LOAD_FAST'], 1
rcall.__code__ = rcall.__code__.replace(
    co_code=bytes(code),
    co_varnames=('value', 'obj'),
    co_argcount=2,
    co_nlocals=2
)
print(rcall(1, str))

这将正确输出:

1

因此,通过将修改后的 in 插入到 OP 的关闭尝试中,实现 OP 想要的复合函数就变得微不足道了:operator.call

def increment(x):
    return x + 1

def double(x):
    return x * 2

double_and_increment = partial(reduce, rcall, [double, increment])
print(double_and_increment(1))

这将输出:

3

演示: 这里

评论

2赞 AKX 11/10/2023
出色的黑客,干得好!我正在考虑使用字节码,但觉得这不一定值得。关于版本之间可移植性的担忧(小工具可能存在也可能不存在)仍然存在——我认为字节码没有任何兼容性保证。test
1赞 blhsing 11/10/2023
同意。我想我的主要观点很简单,我的方法只使用我认为是标准库的工具。
0赞 ruohola 11/13/2023
这是针对 Python 3.11 的吗?我认为在 3.12: github.com/python/cpython/pull/103910/files 中删除了,并且在它上运行它会导致.PRECALLKeyError: 'PRECALL'
1赞 blhsing 11/13/2023
@ruohola Indeed 在 3.12 中被移除。我已经更新了我的答案,以便它使用与我们想要的函数更相似的函数作为修改的起点,以便代码可以与 3.12 兼容。还为我的新发现添加了一个单独的答案。PRECALL
3赞 blhsing 11/13/2023 #3

正如我的另一个答案中提到的,我不同意 @AKX 发现的测试套件应被视为 OP 规则的标准库的一部分。

事实证明,在研究要修改我的另一个答案的现有函数时,我发现模块中有这个辅助函数_int_to_enum,它完美地实现了具有单个参数的可调用对象,但参数颠倒了,正是 OP 想要的,并且从 Python 3.5 开始可用:signaloperator.call

def _int_to_enum(value, enum_klass):
    """Convert a numeric value to an IntEnum member.
    If it's not a known member, return the numeric value itself.
    """
    try:
        return enum_klass(value)
    except ValueError:
        return value

因此,我们可以简单地重新利用/滥用它:

from signal import _int_to_enum as rcall
from functools import reduce, partial

def increment(x):
    return x + 1

def double(x):
    return x * 2

double_and_increment = partial(reduce, rcall, [double, increment])
print(double_and_increment(1))

这将输出:

3

演示: 这里

评论

2赞 AKX 11/13/2023
哈哈,很好。现在我想知道是否有一个很好的 ast-grep 模式可以找到所有这样的小工具......
0赞 blhsing 11/13/2023
哈哈,确实我们可以这样做,以更有效地找到我们想要的特定代码模式。
1赞 ruohola 11/13/2023
好吧,这很好。尽管它是未导出的,但与其他两个(但仍然很棒)答案相比,它仍然更像是标准库的一部分。_int_to_enum
0赞 Gwang-Jin Kim 11/22/2023
但是使用onliner更具可读性: 并使用:compose = lambda *fs: reduce(lambda f, g: lambda x: f(g(x)), fs)double_and_incrmeent = compose(increment, double)
1赞 blhsing 11/22/2023
@Gwang-JinKim:是的,用户定义的函数是最明显的方法,但 OP 人为地添加了禁止用户定义的函数和类的规则,显然是为了好玩和出于好奇。