繁体   English   中英

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

[英]How can a decorator pass variables into a function without changing its signature?

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

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

def more_vars(**extras):
    def wrapper(f):
        @wraps(f)
        def wrapped(*args, **kwargs):
            return f(extras, *args, **kwargs)
        return wrapped
    return wrapper

现在我可以这样做:

@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

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

help(test)
# Output:
# Help on function test in module __main__:
#
# test(deco_vars, x, y)

这使得用户希望用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)

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

你可以用一些技巧来做到这一点,将传递给装饰器的变量插入到函数的局部变量中:

import sys
from functools import wraps
from types import FunctionType


def is_python3():
    return sys.version_info >= (3, 0)


def more_vars(**extras):
    def wrapper(f):
        @wraps(f)
        def wrapped(*args, **kwargs):
            fn_globals = {}
            fn_globals.update(globals())
            fn_globals.update(extras)
            if is_python3():
                func_code = '__code__'
            else:
                func_code = 'func_code'
            call_fn = FunctionType(getattr(f, func_code), fn_globals)
            return call_fn(*args, **kwargs)
        return wrapped
    return wrapper


@more_vars(a="hello", b="world")
def test(x, y):
    print("locals: {}".format(locals()))
    print("x: {}".format(x))
    print("y: {}".format(y))
    print("a: {}".format(a))
    print("b: {}".format(b))


if __name__ == "__main__":
    test(1, 2)

你能做到吗? 当然! 应该这样做吗? 可能不是!

此处提供代码。)

编辑:回答编辑的可读性。 最新答案是最重要的,原始如下。

如果我理解得很好

  • 您希望在@more_vars装饰器中将新参数定义为关键字
  • 你想在装饰功能中使用它们
  • 并且您希望它们对普通用户隐藏(暴露的签名仍应是正常签名)

看看我的库makefun中的@with_partial装饰器。 它提供了开箱即用的功能:

from makefun import with_partial

@with_partial(a='hello', b='world')
def test(a, b, x, y):
    """Here is a doc"""
    print(a, b)
    print(x, y)

它产生预期的输出,并相应地修改docstring:

test(1, 2)
help(test)

产量

hello world
1 2
Help on function test in module <...>:

test(x, y)
    <This function is equivalent to 'test(x, y, a=hello, b=world)', see original 'test' doc below.>
    Here is a doc

要回答评论中的问题, makefun的函数创建策略与着名的decorator库中的函数创建策略完全相同: compile + exec 这里没有魔法,但decorator多年来一直在实际应用中使用这个技巧,所以它非常可靠。 请参阅源代码中的def _make

请注意,如果您想出于某种原因自己创建装饰器, makefun库还会提供partial(f, *args, **kwargs)功能(请参阅下面的灵感)。


如果您希望手动执行此操作,这是一个应该按预期工作的解决方案,它依赖于makefun提供的wraps功能来修改公开的签名。

from makefun import wraps, remove_signature_parameters

def more_vars(**extras):
    def wrapper(f):
        # (1) capture the signature of the function to wrap and remove the invisible
        func_sig = signature(f)
        new_sig = remove_signature_parameters(func_sig, 'invisible_args')

        # (2) create a wrapper with the new signature
        @wraps(f, new_sig=new_sig)
        def wrapped(*args, **kwargs):
            # inject the invisible args again
            kwargs['invisible_args'] = extras
            return f(*args, **kwargs)

        return wrapped
    return wrapper

你可以测试它的工作原理:

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

test(1, 2)
help(test)

如果使用decopatch来移除无用的嵌套级别,甚至可以使装饰器定义更紧凑:

from decopatch import DECORATED
from makefun import wraps, remove_signature_parameters

@function_decorator
def more_vars(f=DECORATED, **extras):
    # (1) capture the signature of the function to wrap and remove the invisible
    func_sig = signature(f)
    new_sig = remove_signature_parameters(func_sig, 'invisible_args')

    # (2) create a wrapper with the new signature
    @wraps(f, new_sig=new_sig)
    def wrapped(*args, **kwargs):
        kwargs['invisible_args'] = extras
        return f(*args, **kwargs)

    return wrapped

最后,如果你不想依赖任何外部库,那么最狡猾的方法就是创建一个函数工厂(但是你不能把它作为装饰器):

def make_test(a, b, name=None):
    def test(x, y):
        print(a, b)
        print(x, y)
    if name is not None:
        test.__name__ = name
    return test

test = make_test(a='hello', b='world')
test2 = make_test(a='hello', b='there', name='test2')

makefun ,我是makefundecopatch的作者;)

这听起来像你唯一的问题是help显示原始test的签名作为包装函数的签名,而你不希望它。

这是发生的唯一原因是, wraps (或者,更确切地说, update_wrapper ,其wraps调用)明确地从wrappee到包装副本这一点。

您可以准确地确定您要执行和不想复制的内容。 如果你想要做的不同的事情很简单,只需WRAPPER_ASSIGNMENTS默认的WRAPPER_ASSIGNMENTSWRAPPER_UPDATES中过滤掉东西。 如果你想改变其他的东西,你可能需要fork update_wrapper并使用你自己的版本 - 但functools是那些在文档顶部有源链接的模块之一,因为它意味着用作可读的示例代码。

在你的情况下,它可能只是一个wraps(f, updated=[]) ,或者你可能想要做一些奇特的事情,比如使用inspect.signature获取f的签名,并修改它以删除第一个参数,并明确地构建一个包装器来欺骗甚至inspect模块。

我已经找到了解决这个问题的方法,虽然大多数标准的解决方案几乎肯定比问题本身更糟糕。 通过巧妙地重写修饰函数的字节码,您可以将对给定名称的变量的所有引用重定向到您可以为该函数动态创建的新闭包。 此解决方案仅适用于标准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]])

这完全符合我的要求:

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)

这几乎肯定不适合生产使用。 如果没有出现意外行为的边缘情况,甚至可能是段错误,我会感到惊讶。 我可能会在“教育好奇心”的标题下提出这个问题。

暂无
暂无

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM