神坑·Python 装饰类无限递归

dxbjfu0 2019-06-20

本文首发于我的博客转载请注明出处

《神坑》系列将会不定期更新一些可遇而不可求的坑
防止他人入坑,也防止自己再次入坑

简化版问题

现有两个 View 类:

class View(object):

    def method(self):
        # Do something...
        pass

class ChildView(View):

    def method(self):
        # Do something else ...
        super(ChildView, self).method()

以及一个用于修饰该类的装饰器函数 register——用于装饰类的装饰器很常见(如 django.contrib.adminregister),通常可极大地减少定义相似类时的工作量:

class Mixin(object):
    pass

def register(cls):

    return type(
        'DecoratedView',
        (Mixin, cls),
        {}
    )

这个装饰器为被装饰类附加上一个额外的父类 Mixin,以增添自定义的功能。

完整的代码如下:

class Mixin(object):
    pass

def register(cls):

    return type(
        'DecoratedView',
        (Mixin, cls),
        {}
    )

class View(object):

    def method(self):
        # Do something...
        pass

@register
class ChildView(View):

    def method(self):
        # Do something else ...
        super(ChildView, self).method()

看上去似乎没什么问题。然而一旦调用 ChildView().method(),却会报出诡异的 无限递归 错误:

# ...
File "test.py", line 23, in method
  super(ChildView, self).method()
File "test.py", line 23, in method
  super(ChildView, self).method()
File "test.py", line 23, in method
  super(ChildView, self).method()
RuntimeError: maximum recursion depth exceeded while calling a Python object

【一脸懵逼】

猜想 & 验证

从 Traceback 中可以发现:是 super(ChildView, self).method() 在不停地调用自己——这着实让我吃了一惊,因为 按理说 super 应该沿着继承链查找父类,可为什么在这里 super 神秘地失效了呢?

为了验证 super(...).method 的指向,可以尝试将该语句改为 print(super(ChildView, self).method),并观察结果:

<bound method ChildView.method of <__main__.ChildView object at 0xb70fec6c>>

输出表明: method 的指向确实有误,此处本应为 View.method

super 是 python 内置方法,肯定不会出错。那,会不会是 super 的参数有误呢?

super 的签名为 super(cls, instance),宏观效果为 遍历 cls 的继承链查找父类方法,并以 instance 作为 self 进行调用。如今查找结果有误,说明 继承链是错误的,因而极有可能是 cls 出错。

因此,有必要探测一下 ChildView 的指向。在 method 中加上一句: print(ChildView)

<class '__main__.DecoratedView'>

原来,作用域中的 ChildView 已经被改变了。

真相

一切都源于装饰器语法糖。我们回忆一下装饰器的等价语法:

@decorator
class Class:
    pass

等价于

class Class:
    pass

Class = decorator(Class)

这说明:装饰器会更改该作用域内被装饰名称的指向

这本来没什么,但和 super 一起使用时却会出问题。通常情况下我们会将本类的名称传给 super(在这里为 ChildView),而本类名称和装饰器语法存在于同一作用域中,从而在装饰时被一同修改了(在本例中指向了子类 DecoratedView),进而使 super(...).method 指向了 DecoratedView 的最近祖先也就是 ChildView 自身的 method 方法,导致递归调用。

解决方案

找到了病因,就不难想到解决方法了。核心思路就是:不要更改被装饰名称的引用

如果你只是想在内部使用装饰后的新类,可以在装饰器方法中使用 DecoratedView,而在装饰器返回时 return cls,以保持引用不变:

def register(cls):

    decorated = type(
        'DecoratedView',
        (Mixin, cls),
        {}
    )

    # Do something with decorated

    return cls

这种方法的缺点是:从外部无法使用 ChildView.another_method 调用 Mixin 上的方法。可如果真的有这样的需求,可以采用另一个解决方案:

def register(cls):

    cls.another_method = Mixin.another_method
    return cls

即通过赋值的方式为 cls 添加 Mixin 上的新方法,缺点是较为繁琐。

两种方法各有利弊,要根据实际场景权衡使用。

相关推荐