【问题标题】:Perfect forwarding - in Python完美转发 - 在 Python 中
【发布时间】:2017-07-17 04:48:51
【问题描述】:

我是一个大量使用继承的 Python 项目的维护者。有一个反模式给我们带来了一些问题并且使阅读变得困难,我正在寻找一种解决它的好方法。

问题在于将非常长的参数列表从派生类转发到基类 - 主要但不总是在构造函数中。

考虑这个人为的例子:

class Base(object):
    def __init__(self, a=1, b=2, c=3, d=4, e=5, f=6, g=7):
       self.a = a
       # etc

class DerivedA(Base):
    def __init__(self, a=1, b=2, c=300, d=4, e=5, f=6, g=700, z=0):
        super().__init__(a=a, b=b, c=c, d=d, e=e, f=f, g=g)
        self.z = z

class DerivedB(Base):
    def __init__(self, z=0, c=300, g=700, **kwds):
        super().__init__(c=c, g=g, **kwds)
        self.z = z

此时,一切看起来都像DerivedA - 长参数列表,所有这些都显式传递给基类。

不幸的是,在过去的几年中,我们遇到了一些问题,包括忘记传递参数和获取默认值,以及没有注意到派生类中的一个默认参数与默认默认值不同。

它还使代码不必要地庞大,因此难以阅读。

DerivedB 更好并解决了这些问题,但有一个新问题是派生类中方法的 Python 帮助/sphinx HTML 文档具有误导性,因为许多重要参数隐藏在 **kwds 中。

有没有办法将正确的签名——或者至少是正确签名的文档——从基类方法“转发”到派生类方法?

【问题讨论】:

    标签: python python-attrs


    【解决方案1】:

    我还没有找到一种方法来完美地创建具有相同签名的函数,但我认为我的实现的缺点并不太严重。我想出的解决方案是函数装饰器。

    使用示例:

    class Base(object):
        def __init__(self, a=1, b=2, c=3, d=4, e=5, f=6, g=7):
           self.a = a
           # etc
    
    class DerivedA(Base):
        @copysig(Base.__init__)
        def __init__(self, args, kwargs, z=0):
            super().__init__(*args, **kwargs)
            self.z = z
    

    所有命名的继承参数都将通过kwargs dict 传递给函数。 args 参数仅用于将可变参数传递给函数。如果父函数没有可变参数,args 将始终是一个空元组。

    已知问题和限制:

    • 在 python2 中不起作用!(你为什么还在使用 python 2?)
    • 并非装饰函数的所有属性都被完美保留。例如,function.__code__.co_filename 将设置为 "<string>"
    • 如果被修饰的函数抛出异常,异常回溯中会出现额外的函数调用可见,例如:

      >>> f2() Traceback (most recent call last): File "", line 1, in File "", line 3, in f2 File "untitled.py", line 178, in f2 raise ValueError() ValueError

    • 如果一个方法被修饰,第一个参数必须被称为“self”。

    实施

    import inspect
    
    def copysig(from_func, *args_to_remove):
        def wrap(func):
            #add and remove parameters
            oldsig= inspect.signature(from_func)
            oldsig= _remove_args(oldsig, args_to_remove)
            newsig= _add_args(oldsig, func)
    
            #write some code for a function that we can exec
            #the function will have the correct signature and forward its arguments to the real function
            code= '''
    def {name}{signature}:
        {func}({args})
    '''.format(name=func.__name__,
                signature=newsig,
                func='_'+func.__name__,
                args=_forward_args(oldsig, newsig))
            globs= {'_'+func.__name__: func}
            exec(code, globs)
            newfunc= globs[func.__name__]
    
            #copy as many attributes as possible
            newfunc.__doc__= func.__doc__
            newfunc.__module__= func.__module__
            #~ newfunc.__closure__= func.__closure__
            #~ newfunc.__code__.co_filename= func.__code__.co_filename
            #~ newfunc.__code__.co_firstlineno= func.__code__.co_firstlineno
            return newfunc
        return wrap
    
    def _collectargs(sig):
        """
        Writes code that gathers all parameters into "self" (if present), "args" and "kwargs"
        """
        arglist= list(sig.parameters.values())
    
        #check if the first parameter is "self"
        selfarg= ''
        if arglist:
            arg= arglist[0]
            if arg.name=='self':
                selfarg= 'self, '
                del arglist[0]
    
        #all named parameters will be passed as kwargs. args is only used for varargs.
        args= 'tuple(), '
        kwargs= ''
        kwarg= ''
        for arg in arglist:
            if arg.kind in (arg.POSITIONAL_ONLY,arg.POSITIONAL_OR_KEYWORD,arg.KEYWORD_ONLY):
                kwargs+= '("{0}",{0}), '.format(arg.name)
            elif arg.kind==arg.VAR_POSITIONAL:
                #~ assert not args
                args= arg.name+', '
            elif arg.kind==arg.VAR_KEYWORD:
                assert not kwarg
                kwarg= 'list({}.items())+'.format(arg.name)
            else:
                assert False, arg.kind
        kwargs= 'dict({}[{}])'.format(kwarg, kwargs[:-2])
    
        return '{}{}{}'.format(selfarg, args, kwargs)
    
    def _forward_args(args_to_collect, sig):
        collect= _collectargs(args_to_collect)
    
        collected= {arg.name for arg in args_to_collect.parameters.values()}
        args= ''
        for arg in sig.parameters.values():
            if arg.name in collected:
                continue
    
            if arg.kind==arg.VAR_POSITIONAL:
                args+= '*{}, '.format(arg.name)
            elif arg.kind==arg.VAR_KEYWORD:
                args+= '**{}, '.format(arg.name)
            else:
                args+= '{0}={0}, '.format(arg.name)
        args= args[:-2]
    
        code= '{}, {}'.format(collect, args) if args else collect
        return code
    
    def _remove_args(signature, args_to_remove):
        """
        Removes named parameters from a signature.
        """
        args_to_remove= set(args_to_remove)
        varargs_removed= False
        args= []
        for arg in signature.parameters.values():
            if arg.name in args_to_remove:
                if arg.kind==arg.VAR_POSITIONAL:
                    varargs_removed= True
                continue
    
            if varargs_removed and arg.kind==arg.KEYWORD_ONLY:#if varargs have been removed, there are no more keyword-only parameters
                arg= arg.replace(kind=arg.POSITIONAL_OR_KEYWORD)
    
            args.append(arg)
    
        return signature.replace(parameters=args)
    
    def _add_args(sig, func):
        """
        Merges a signature and a function into a signature that accepts ALL the parameters.
        """
        funcsig= inspect.signature(func)
    
        #find out where we want to insert the new parameters
        #parameters with a default value will be inserted before *args (if any)
        #if parameters with a default value exist, parameters with no default value will be inserted as keyword-only AFTER *args
        vararg= None
        kwarg= None
        insert_index_default= None
        insert_index_nodefault= None
        default_found= False
        args= list(sig.parameters.values())
        for index,arg in enumerate(args):
            if arg.kind==arg.VAR_POSITIONAL:
                vararg= arg
                insert_index_default= index
                if default_found:
                    insert_index_nodefault= index+1
                else:
                    insert_index_nodefault= index
            elif arg.kind==arg.VAR_KEYWORD:
                kwarg= arg
                if insert_index_default is None:
                    insert_index_default= insert_index_nodefault= index
            else:
                if arg.default!=arg.empty:
                    default_found= True
    
        if insert_index_default is None:
            insert_index_default= insert_index_nodefault= len(args)
    
        #find the new parameters
        #skip the first two parameters (args and kwargs)
        newargs= list(funcsig.parameters.values())
        if not newargs:
            raise Exception('The decorated function must accept at least 2 parameters')
        #if the first parameter is called "self", ignore the first 3 parameters
        if newargs[0].name=='self':
            del newargs[0]
        if len(newargs)<2:
            raise Exception('The decorated function must accept at least 2 parameters')
        newargs= newargs[2:]
    
        #add the new parameters
        if newargs:
            new_vararg= None
            for arg in newargs:
                if arg.kind==arg.VAR_POSITIONAL:
                    if vararg is None:
                        new_vararg= arg
                    else:
                        raise Exception('Cannot add varargs to a function that already has varargs')
                elif arg.kind==arg.VAR_KEYWORD:
                    if kwarg is None:
                        args.append(arg)
                    else:
                        raise Exception('Cannot add kwargs to a function that already has kwargs')
                else:
                    #we can insert it as a positional parameter if it has a default value OR no other parameter has a default value
                    if arg.default!=arg.empty or not default_found:
                        #do NOT change the parameter kind here. Leave it as it was, so that the order of varargs and keyword-only parameters is preserved.
                        args.insert(insert_index_default, arg)
                        insert_index_nodefault+= 1
                        insert_index_default+= 1
                    else:
                        arg= arg.replace(kind=arg.KEYWORD_ONLY)
                        args.insert(insert_index_nodefault, arg)
                        if insert_index_default==insert_index_nodefault:
                            insert_index_default+= 1
                        insert_index_nodefault+= 1
    
            #if varargs need to be added, insert them before keyword-only arguments
            if new_vararg is not None:
                for i,arg in enumerate(args):
                    if arg.kind not in (arg.POSITIONAL_ONLY,arg.POSITIONAL_OR_KEYWORD):
                        break
                else:
                    i+= 1
                args.insert(i, new_vararg)
    
        return inspect.Signature(args, return_annotation=funcsig.return_annotation)
    

    简短说明:

    装饰器创建表单的字符串

    def functionname(arg1, arg2, ...):
        real_function((arg1, arg2), {'arg3':arg3, 'arg4':arg4}, z=z)
    

    然后execs 并返回动态创建的函数。

    附加功能:

    如果您不想“继承”参数 x 和 y,请使用

    @copysig(parentfunc, 'x', 'y')
    

    【讨论】:

    • 这是很棒的东西!我支持你,如果你更新它以传递继承的参数,我会标记它正确 - 如果你将它作为开源项目发布在某个地方,我会标记它正确再次。 ;-)
    • @TomSwirly 代码更新,修饰函数现在接收 all(命名)参数为kwargsargs 参数仍然是强制性的,但仅用于继承的可变参数。
    • 谢谢,完成!无论如何,我都会将其标记为正确,但想从你那里得到额外的东西。 eval 让我觉得有点脏 :-D 但我不知道如何绕过它。
    • 我将它用于我的另一个项目,我把它放到了这里的仓库中:github.com/rec/copysig/tree/master Ping 我,我会修改那个来自你的承诺,并给你项目的所有权!
    • 对于操作信号,你考虑过boltons.funcutils吗?不要减损这个答案,但这是一个维护、测试、可安装的包。要转发参数,您只需使用 **locals() 提供或获取要添加或删除的参数。杀死样板的战斗仍在继续!
    【解决方案2】:

    考虑使用attrs module

    import attr
    
    @attr.s
    class Base(object):
        a = attr.ib(1)
        b = attr.ib(2)
        c = attr.ib(3)
        d = attr.ib(4)
        e = attr.ib(5)
        f = attr.ib(6)
        g = attr.ib(7)
    
    @attr.s
    class DerivedA(Base):
        z = attr.ib(0)
    
    der_a = DerivedA()
    print(der_a.a, der_a.z)
    

    【讨论】:

    • 哦,这很有趣。虽然这并不能完全解决上面提出的问题,但我想知道这是否是我从原始帖子中抽象出来的实际问题的更好解决方案。我为这个好主意投了赞成票!
    • 这是我在包 autosig 中使用的方法,它有助于构建相关的签名(虽然对转发没有帮助)。一个示例应用程序是包 altair_recipes,尤其是文件 signatures.py
    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 2020-09-12
    • 2017-08-05
    • 1970-01-01
    • 2015-10-03
    • 1970-01-01
    • 1970-01-01
    • 2011-04-05
    相关资源
    最近更新 更多