完美转发 - 在Python中

15

我是一个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中,导致文档有误导性。

是否有一种方式可以将正确的签名,或至少是正确签名的文档,从基类方法转发到派生类方法?

2个回答

7

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

用法示例:

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字典传递给函数。 args参数仅用于将varargs传递给函数。 如果父函数没有varargs,则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)

然后用exec执行它,并返回动态创建的函数。

额外功能:

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

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

这真是太棒了!我已经点赞了,如果你更新一下以传递继承参数,我会标记为正确答案 - 如果你将其作为开源项目发布在某个地方,我会再次标记为正确答案。;-) - Tom Swirly
谢谢,完成了!我本来会标记为正确的,但是想从你这里得到额外的帮助。eval 让我感觉有点不舒服 :-D 但我看不出有什么其他方法可以解决它。 - Tom Swirly
为了我的另一个项目,我将它放入了这个仓库:https://github.com/rec/copysig/tree/master。给我发消息,我会修改提交记录并将项目所有权转让给你! - Tom Swirly
@piccolbo 我快速查看了 boltons.funcutils,但我认为没有任何东西可以帮助我。(至少不是显著的)。实际上并没有任何工具可以帮助从/向签名中删除/添加参数。 - Aran-Fey
无论采用何种方式,它都能完成工作。保留所有属性,但您可以使用少一个参数进行调用。 - piccolbo
显示剩余4条评论

4

考虑使用 attrs 模块

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)

哦,这非常有趣。虽然它并没有完全解决上面提出的问题,但我想知道这是否是我从原始帖子中抽象出来的实际问题的更好解决方案。我为您提出的这个卓越的想法点赞! - Tom Swirly
这是我在autosig包中使用的方法,它可以帮助构建相关的签名(尽管不会帮助转发)。一个示例应用程序是altair_recipes包中的文件signatures.py。 - piccolbo

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