如何实现动态作用域,即使调用者不是封闭作用域,也可以访问变量?

25
Consider this example:
def outer():
    s_outer = "outer\n"

    def inner():
        s_inner = "inner\n"
        do_something()

    inner()

我希望do_something中的代码能够访问调用函数调用栈上方的变量,例如s_outers_inner。更一般地,我想从各种其他函数调用它,但始终在它们各自的上下文中执行,并访问它们各自的作用域(实现动态作用域)。

我知道在Python 3.x中, nonlocal关键字允许从inner中访问s_outer。不幸的是,如果do_something不是在inner中定义,则不能帮助它。否则,inner不是一个词法封闭范围(同样,除非do_somethingouter中定义,否则outer也不是)。

我发现可以使用标准库inspect来检查堆栈帧,并创建了一个小访问器,可以从do_something()内部调用它,如下所示:

def reach(name):
    for f in inspect.stack():
        if name in f[0].f_locals:
            return f[0].f_locals[name]
    return None 

然后

def do_something():
    print( reach("s_outer"), reach("s_inner") )

工作得非常好。

“reach”能否更简单地实现?还有哪些方法可以解决这个问题?


走遍堆栈真的正确吗?你想要遍历Python作用域(从嵌套函数到模块),而不是调用堆栈帧。在上面的例子中,它们是相同的,因为你在outer内部调用了inner,但是想象一下,如果你返回inner,然后做类似于outer()()这样的事情,你的方法仍然有效吗? - Mitar
@Mitar 不是的;nonlocal 已经可以遍历封闭作用域了。目标是将调用者视为封闭作用域(动态作用域),即使在 Python 中它实际上并不是(Python 实现了词法作用域,就像几乎所有现代语言一样)。OP 遍历堆栈是因为堆栈可以告诉你谁是调用者。 - Karl Knechtel
4个回答

8

在我看来,实现reach没有捷径,也不应该有优雅的方式,因为这会引入一个新的非标准间接性,这真的很难理解、调试、测试和维护。正如Python格言(尝试import this)所说:

明确胜于含蓄。

所以,只需传递参数。未来的你将为现在的你感到非常感激。


我担心那就是答案。嗯,不管reach()函数做什么,有没有更紧凑的实现嵌套的for/if的方法呢? - Jens
@Jens 我想不出任何更易读的方法了。你可以检查一下从 inspect.currentframe() 遍历 f_back 是否更好看。 - bereal

7
我最终做的是:
scope = locals()

scope可以从do_something中访问。这样我就不必去寻找,但仍然可以访问调用者的本地变量字典。这与自己构建一个字典并将其传递非常相似。


如何使其可访问?难道不是通过传递它吗? - Karl Knechtel

6

我们可以更为灵活些。

这是对于“有没有更优雅/缩短的方法实现reach()函数?”问题的回答。

  1. 我们可以给用户提供更好的语法:使用outer.foo替代reach("foo")

    这样更易于输入,而且语言本身会立即告诉您是否使用了一个无效的变量名(属性名和变量名具有相同的限制)。

  2. 我们可以引发错误,以明确区分“该对象不存在”的情况和“该对象被设置为None”的情况。

    如果我们真的想把这些情况混在一起,我们可以使用带默认参数的getattrtry-except AttributeError

  3. 我们可以进行优化:不需要悲观地一次性构建足够所有帧的列表。

    在大多数情况下,我们可能不需要一直遍历调用栈到达根节点。

  4. 仅仅因为我们通过不当地向上访问堆栈帧来违反编程中最重要的规则之一,即不要让远离代码造成不可见的影响,就不能文明一些。

    如果有人试图在不支持堆栈帧检查的Python上使用此严肃API进行实际工作,我们应该友善地告知他们。

import inspect


class OuterScopeGetter(object):
    def __getattribute__(self, name):
        frame = inspect.currentframe()
        if frame is None:
            raise RuntimeError('cannot inspect stack frames')
        sentinel = object()
        frame = frame.f_back
        while frame is not None:
            value = frame.f_locals.get(name, sentinel)
            if value is not sentinel:
                return value
            frame = frame.f_back
        raise AttributeError(repr(name) + ' not found in any outer scope')


outer = OuterScopeGetter()

非常好。现在我们只需要执行:

>>> def f():
...    return outer.x
... 
>>> f()
Traceback (most recent call last):
    ...
AttributeError: 'x' not found in any outer scope
>>> 
>>> x = 1
>>> f()
1
>>> x = 2
>>> f()
2
>>> 
>>> def do_something():
...     print(outer.y)
...     print(outer.z)
... 
>>> def g():
...     y = 3
...     def h():
...         z = 4
...         do_something()
...     h()
... 
>>> g()
3
4

优雅地实现了变态。
(附注:这是我在我的 dynamicscope 库中更完整实现的简化只读版本。)

@Jens 嗯,我一直在考虑学习新的打包方式,但是似乎如果我只是使用基本的、直接的 setuptools-based pyproject.toml ,这将会导致我的许多向后兼容性问题出现回归...对于这个小玩具包 dynamicscope,好处很小(只支持 distutils),所以它是一个很好的转换候选。但是例如看看我的 compose 模块的 setup.py,针对不同的版本选择了不同的源代码。 - mtraceur
如果您想支持Python 2.7和旧版本的Python 3.x,则会变得棘手。这是向后兼容性的祸根... - Jens
当然,对我来说解决所有向后兼容性问题的理想方式是找到或编写一个“反编译器”,它可以将最新的Python源代码转换为旧版向后兼容的Python源代码,然后找到或编写一种方法将其钩入通过 pyproject.toml 配置的构建系统... 并且使该构建系统生成具有任何所需可移植性特征的sdists... 现代打包方向的优点在于我们应该能够将其钩入 pyproject.toml - 我只是还没有时间弄清楚如何 - mtraceur
1
@Jens,顺便说一下,那是一个很棒的模板仓库 - 我认为它会帮助我熟悉我想学习的一些东西以及我可能最终想在我的项目中使用的一些东西。谢谢! - mtraceur

3
有没有更好的解决这个问题的方法?(除了将相应的数据包装成字典并明确传递给 do_something() 之外)
明确传递字典是一种更好的方式。
您提出的方案听起来非常不寻常。当代码增长时,您必须将代码分解为模块化架构,并在模块之间拥有清晰的 API。它还必须是易于理解、易于解释和易于交给另一个程序员修改/改进/调试的东西。您所提出的方案似乎不是一个干净的 API,不寻常,并且具有非显然的数据流。我怀疑当其他程序员看到它时会感到不满。:)
另一个选择是将函数作为类的成员,并使数据位于类实例中。如果您的问题可以被建模为在数据对象上运行多个函数,则这可能效果很好。

你说的每句话我都能理解,并且通常我会同意。然而,最近我正在学习Python,这意味着我想尽可能地深入了解它。虽然这不是用于发布代码的私人项目,但我将是这里唯一沮丧的程序员;-) - Jens

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接