Python lambda 的源代码

发布于 2025-01-02 00:34:20 字数 10047 浏览 15 评论 0

…或者:(几乎是)我做过的最邪恶的 hack

callee我最近发布的 Python 的参数匹配库 中,对于一个看似简单的功能,还有一个可爱的 TODO 注意 。当和一个简单的 lambda 断言一起使用 Matching 构造 时:

mock_foo.assert_called_with(Matching(lambda x: x % 2 == 0))

如果断言失败,那么在错误信息中看到它的 代码 将是棒棒哒。现在,它只是会打印出一些像 <Matching <function <lambda> at 0x7f5d8a06eb18>> 这样的东西。假设你不具备在你的脑袋解引用指针的超自然能力,那么对于什么地方出错了,这将不会给你带来任何直接的提示。如果它会打印出,比方说, <Matching \x: x % 2> ,岂不是棒棒哒? 1

所以我想:为嘛不试着实现这样一个机制?毕竟,这是 Python 呀 — 它是这样一种语言,你可以在运行时生成 全新的类向后 (或者甚至是 向前 ) 遍历堆栈,以及读取局部变量或者改变 import 系统自身 的行为。所以当然咯,获得一个短短的 lambda 函数的源代码是可能的,甚至是简单的,不是吗?

亲,是我 错 了。

虽然,这样没有问题:至少在我想它完成的范围中,这项任务是完全可行的。但是,一个不仅涉及普通的 Python 方法,还涉及 AST 检查,作为文本 和 字节码的源代码转换,你绝咋样?

代码,所有的代码,以及……不仅仅是代码

好吧,让我们从头开始。这里是一个短短的 lambda 函数,也就是我们想要获得源代码的那个:

is_even = lambda x: x % 2 = 0

如果 Python 标准库中的文件是可信的,这应该是很容易的。在 inspect 模块 中,有一个名为与 getsource 没啥不同 的函数。然而,对于我们的目的, getsourcelines 则多了几分便利,因为当 lambda 太长的时候,我们可以很容易的区分:

def get_short_lambda_source(lambda_func):
    try:
        source_lines, _ = inspect.getsourcelines(lambda_func)
    except IOError:
        return None
    if len(source_lines) > 1:
        return None
    return source_lines[0].strip()

当然,如果你已经长时间使用 Python 编程了,那么你就会清楚,标识的文档是 不 能信任的。 except 还应该包含 TypeError ,因为当你试图传递任何 Python 内建给 getsourcelines 时,将会抛出这个错误。

更重要的是,“一个对象的源代码行”实际上意味着什么的模糊性。“源代码 包括 对象定义”会准确得多,而在这里,这个看似微小的区别是相当关键的。传递一个 lambda 函数给 getsourcelines 或者 getsource ,然后我们将获得其源代码 及其它一切东东 ,包括返回行。

好啦。跟完整的 is_even = 赋值和整个 assert_called_with 调用打声招呼吧!而如果你在想:是哒,结果还将包括任何行尾注释。那么不要留下任何标记!

修剪结果

显然,这超乎我们意料。也许有一种方法可以去除不必要的东东。毕竟,Python 确实知道怎样解析自己:标准的 ast 模块 是这方面知识的体现。或许,我们可以使用它来检查 lambda AST 节点,以便于把它 —— 只是它变回到 Python 代码?……

def get_short_lambda_ast_node(lambda_func):
    source_text = get_short_lambda_source(lambda_func)
    if source_text:
        source_ast = ast.parse(source_text)
        return next((node for node in ast.walk(source_ast)
                     if isinstance(node, ast.Lambda)), None)

但事实证明,这种方式得到源文本大多可能仅是可能的。

你看,每一个实质的 AST 节点 — 要么是一个表达式( ast.expr ),要么是一个声明 ( ast.stmt ) — 有两个共同的熟悉: linenocol_offset 。合并后,它们指向原始源代码中的一个地方,这个地方就是解析该节点地方。这就是我们可以怎样找到在哪里查找我们的 lambda 函数定义。

看来有戏,对吧?唯一的问题是,我们不知道何时 停止 查找。这是对的: ast.parse 创建的节点与它们的起始偏移量一起标记,而不是和其长度或结束偏移量。结果是,当涉及到从最开始的例子中取得 lambda 源代码时,我们能做的最好是这样:

lambda x: x % 2 == 0))

很接近了!那些挂在那的括号显然正在嘲笑着我们,但是我们怎样移除它们呢? lambda 基本上只是一个 Python 表达式,因此原则上,它可以跟集合所有东西。这对于 Matching 构造中的 lambda 那可是加倍的对,因为它们也许是一些较大的模拟断言的一部分:

mock_foo.assert_called_with(Matching(lambda x: x % 2 == 0), Integer() & GreaterThan(42))

这里,无关后缀是整个 ), Integer() GreaterThan(42)) ,远不止 )) 。而且,当然还有无限种可能:一个是,那儿可能还会有更多的 lambda !

慢慢滴 ,退回去

不过,看来,那些麻烦的小尾巴有个共同点: 它们不是语法有效的 。

直观地看,嵌套在一些其他语法结构中的 lambda 节点在其尾部的某个地方都有它们自己的关闭段 (如, ) )。如果没有对应的起始段 (如, Matching( ),那么那些段将无法解析。

因此,有一个疯狂的想法。我们所拥有的是无效的 Python,而只是因为一些数目不详的多余字符。要不我们试着一个一个地移除它们,知道获得一些语法正确的东西,你觉得呢?如果我们不犯错,那么最后,这将是我们的 lambda,仅此而已。

财富眷顾勇者,所以让我们继续前进,尝试一下:

# ... continuing get_short_lambda_source() ...

source_text = source_lines[0].strip()
lambda_node = get_short_lambda_ast_node(lambda_func)

lambda_text = source_text[lambda_node.col_offset:]
min_length = len('lambda:_')  # shortest possible lambda expression
while len(lambda_text) > min_length:
    try:
        ast.parse(lambda_text)
        return lambda_text
    except SyntaxError:
        lambda_text = lambda_text[:-1]
return None

考虑到,我们基本是从霍格沃茨图书馆的禁书区中尘封的大部头上学到的,这里的魔法看起来很简单。只有它能通过 lambda 定义,那么我们就试着解析它,并看看是否成功。写着 except SyntaxError: 的那一行显然不是救命药,但是,至少我们指定了预期缓存的 what 异常

结果呢? 有用 。我的意思是,对于几个明显和不那么明显的测试用例,它并没有返回垃圾结果,这已经比你通常从这种规模的 hack 中期望返回的好多了。例如,直到这里,所有定义的 lambda 都能无差错提取它们的源代码。

还有一个东东

所以……搞定啦?不完全呢。聪明的读者可能还记得我对一些字节码奥秘的承诺,现在就轮到它了。

尽管,我们循序渐进,字符丢弃方法取得了初步的成功,但是在有些情况下,并不会产生正确的结果。举个例子,嵌套在一个元祖中的 lambda 定义 2 :

>>> x = lambda _: True, 0
>>> get_short_lambda_source(x[0])
lambda _: True, 0

当然,我们会期待结果是 lambda _: True ,不带逗号或者 0。

不幸的是,这就是我们前面的假设失败的地方。从 AST 抽取的代码行在语法上是有效的,及时 有 多余的字符。结果是, ast.parse 过早的成功并返回一个不正确的定义。这应该是包含在一个元祖中的一个 lambda,但是元祖显然是 lambda 返回的 。

你可能会说,这是一个少见的边缘情况,而那些像这样定义函数的人罪有应得。当然,如果我们只是摆摆手,然后告诉他们我们根本无法在这里检索源代码,那么我是不会介意的。但是我的看法是,给他们提供显然 错误的 结果是不合理的!

一个停止问题

反正,如果我们可以解决这个问题,就要解决。并排地看看预期的源代码和我们已经提取出来的源代码:

lambda _: True
lambda _: True, 0

第二行不只是更长:它还 做得更多 。它不仅是定义一个 lambda;它定义了 lambda,糅合了一个常量 0 ,然后将它们都打包到一个元祖中。相对于初始的,至少有两个额外的步骤。

这些步骤还有更准确的名字:它们是 字节码指令 。在执行之前,每一片 Python 源代码被编译为二进制字节码,因为解释器只能与这种表现形式一起工作。编译通常发生在一个 Python 模块被首次导入,产生对应 .py 文件的 .pyc 文件时,才会发生。随后的导入将简单地重用缓存的字节码。

此外,任何函数或类对象在运行时都访问其字节码(只读)。甚至有一个 专门的数据类型 来保存它 —— 简称 code —— 在其属性之一下带有原始字节的缓冲区。

最后,该字节码编译器自身也作为内置的 compile 函数 提供给 Python 程序。与其同类 evalexec (希望是少见的现象)相比,它并不常见,但是它接近相同的 Python 内部机制。

所以,怎样把它们全都加在一起呢?想法是,基本上,交叉检查 lambda 所谓的源代码及其自身的 字节 码。即使语法有效。仍然需要裁减多余部分。因此,我们可以简单的继续去除字符,知道字节码匹配:

lambda_text = source_text[lambda_node.col_offset:]
lambda_body_text = source_text[lambda_node.body.col_offset:]
min_length = len('lambda:_')  # shortest possible lambda expression
while len(lambda_text) > min_length:
    try:
        code = compile(lambda_body_text, '<unused filename>', 'eval')
        if len(code.co_code) == len(lambda_func.__code__.co_code):
            return lambda_text
    except SyntaxError:
        pass
    lambda_text = lambda_text[:-1]
    lambda_body_text = lambda_body_text[:-1]
return None

好了,也许没有确切的字节 3 ,但停止在相同的字节码长度也是不错的侧脸。作为明显的奖励, compile 也将会注意检测候选源代码中的语法错误,所以我们不再需要 ast 解析了。

快速升级!

信不信由你,但是对于这个解决方案,已经没有更多的异议了,你可以通过查看 这个要点 来看看它完整的部分。

这是否意味着它也能在 callee 库 中客串一下?……

不,恐怕不行。

通常情况下,我并不是那种,嗯,对于难题的 大胆 解决方案退避三舍的人。但在这个例子中,需要的 hack 幅度太大了,结果不够理想,该功能的优先级不是真的那么高,而它所引入的维护负担最有可能过大。

最后,想到这个超棒:关于你能如何使用 Python 来做一些基础工作的另一个例子。尽管如此,我们必须不能陷入我们是否能够做什么,而忘记我们应该做什么。


  1. 反斜杠 ( \ ) 是 lambda 函数在 Haskell 中的表示方式。我们想要简短而亲切,因此它感觉是一个自然的选择。
  2. 这不是来自一个 Python REPL 的一个实际片段,因为 inspect.getsourcelines 要求该对象要在 .py 文件中定义。
  3. 为什么我们不会总是得到相同的字节码?简单回答是,一些指令可能被换成它们的近似等值。

    详细回答是,使用 compile ,我们不能够原始 lambda 的确切封闭的环境。当一个函数指向一个 自由变量 (例如, lambda x: x + foo 中的 foo )时,该变量值来自其闭包。对于临时的 lambda 来说,这通常是它的 外函数 的局部范围。

    然而, compile 生成的代码与这些局部范围没有任何关联。因此,所有的自由名都假设指向 全局 变量。由于 Python 为引用局部名和全局名使用不同的字节码指令 ( LOAD_FAST vs LOAD_GLOBAL ), compile 的结果会区别于常规方法生成的字节码。

原文: Source code of a Python lambda

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

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

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据

关于作者

愛上了

暂无简介

文章
评论
29 人气
更多

推荐作者

笑脸一如从前

文章 0 评论 0

mnbvcxz

文章 0 评论 0

真是无聊啊

文章 0 评论 0

旧城空念

文章 0 评论 0

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