装饰器如何在不改变签名的情况下将变量传递给函数?

use*_*471 7 python

让我首先承认,我想要做的事情可能被认为是从愚蠢到邪恶,但我想知道我是否可以用Python做到这一点.

假设我有一个函数装饰器,它接受定义变量的关键字参数,我想在包装函数中访问这些变量.我可能会这样做:

def more_vars(**extras):
    def wrapper(f):
        @wraps(f)
        def wrapped(*args, **kwargs):
            return f(extras, *args, **kwargs)
        return wrapped
    return wrapper
Run Code Online (Sandbox Code Playgroud)

现在我可以这样做:

@more_vars(a='hello', b='world')
def test(deco_vars, x, y):
    print(deco_vars['a'], deco_vars['b'])
    print(x, y)

test(1, 2)
# Output:
# hello world
# 1 2
Run Code Online (Sandbox Code Playgroud)

我不喜欢这样的事情是当你使用这个装饰器时,你必须改变函数的调用签名,除了在装饰器上拍打之外还要添加额外的变量.另外,如果你查看函数的帮助,你会看到一个额外的变量,在调用函数时你不应该使用它:

help(test)
# Output:
# Help on function test in module __main__:
#
# test(deco_vars, x, y)
Run Code Online (Sandbox Code Playgroud)

这使得用户希望用3个参数调用该函数,但显然这不起作用.因此,您还必须向docstring添加一条消息,指示第一个参数不是接口的一部分,它只是一个实现细节,应该被忽略.不过,这有点糟糕.有没有办法在不将这些变量挂在全局范围内的情况下做到这一点?理想情况下,我希望它看起来如下:

@more_vars(a='hello', b='world')
def test(x, y):
    print(a, b)
    print(x, y)

test(1, 2)
# Output:
# hello world
# 1 2
help(test)
# Output:
# Help on function test in module __main__:
#
# test(x, y)
Run Code Online (Sandbox Code Playgroud)

如果存在,我满足于仅支持Python 3的解决方案.

use*_*471 0

我已经找到了这个问题的解决方案,尽管按照大多数标准,该解决方案几乎肯定比问题本身更糟糕。通过对修饰函数的字节码进行一些巧妙的重写,您可以将对给定名称的变量的所有引用重定向到可以为该函数动态创建的新闭包。该解决方案仅适用于标准 CPython,并且我仅使用 3.7 对其进行了测试。

import inspect

from dis import opmap, Bytecode
from types import FunctionType, CodeType

def more_vars(**vars):
    '''Decorator to inject more variables into a function.'''

    def wrapper(f):
        code = f.__code__
        new_freevars = code.co_freevars + tuple(vars.keys())
        new_globals = [var for var in code.co_names if var not in vars.keys()]
        new_locals = [var for var in code.co_varnames if var not in vars.keys()]
        payload = b''.join(
            filtered_bytecode(f, new_freevars, new_globals, new_locals))
        new_code = CodeType(code.co_argcount,
                            code.co_kwonlyargcount,
                            len(new_locals),
                            code.co_stacksize,
                            code.co_flags & ~inspect.CO_NOFREE,
                            payload,
                            code.co_consts,
                            tuple(new_globals),
                            tuple(new_locals),
                            code.co_filename,
                            code.co_name,
                            code.co_firstlineno,
                            code.co_lnotab,
                            code.co_freevars + tuple(vars.keys()),
                            code.co_cellvars)
        closure = tuple(get_cell(v) for (k, v) in vars.items())
        return FunctionType(new_code, f.__globals__, f.__name__, f.__defaults__,
                            (f.__closure__ or ()) + closure)
    return wrapper

def get_cell(val=None):
    '''Create a closure cell object with initial value.'''

    # If you know a better way to do this, I'd like to hear it.
    x = val
    def closure():
        return x  # pragma: no cover
    return closure.__closure__[0]

def filtered_bytecode(func, freevars, globals, locals):
    '''Get the bytecode for a function with adjusted closed variables

    Any references to globlas or locals in the bytecode which exist in the
    freevars are modified to reference the freevars instead.

    '''
    opcode_map = {
        opmap['LOAD_FAST']: opmap['LOAD_DEREF'],
        opmap['STORE_FAST']: opmap['STORE_DEREF'],
        opmap['LOAD_GLOBAL']: opmap['LOAD_DEREF'],
        opmap['STORE_GLOBAL']: opmap['STORE_DEREF']
    }
    freevars_map = {var: idx for (idx, var) in enumerate(freevars)}
    globals_map = {var: idx for (idx, var) in enumerate(globals)}
    locals_map = {var: idx for (idx, var) in enumerate(locals)}

    for instruction in Bytecode(func):
        if instruction.opcode not in opcode_map:
            yield bytes([instruction.opcode, instruction.arg or 0])
        elif instruction.argval in freevars_map:
            yield bytes([opcode_map[instruction.opcode],
                         freevars_map[instruction.argval]])
        elif 'GLOBAL' in instruction.opname:
            yield bytes([instruction.opcode,
                         globals_map[instruction.argval]])
        elif 'FAST' in instruction.opname:
            yield bytes([instruction.opcode,
                         locals_map[instruction.argval]])
Run Code Online (Sandbox Code Playgroud)

这的行为完全符合我的要求:

In [1]: @more_vars(a='hello', b='world')
   ...: def test(x, y):
   ...:     print(a, b)
   ...:     print(x, y)
   ...:

In [2]: test(1, 2)
hello world
1 2

In [3]: help(test)
Help on function test in module __main__:

test(x, y)
Run Code Online (Sandbox Code Playgroud)

这几乎肯定还没有准备好用于生产使用。如果没有出现意外行为甚至可能出现段错误的边缘情况,我会感到惊讶。我可能会将其归档在“教育好奇心”标题下。