问题:制作一个装饰器,在不改变代码文件,只引入这个装饰器的情况下实现如下效果

@profiler
def akkerman(m, n):
    '''
    a function neen some decoration to count the calling times
    '''
    while(m!=0):
        if(n==0):
            n=1
        else:
            n=ack(m, n-1)
        m -= 1
    return n+1

akkerman(3, 2)

print(akkerman.__doc__)    # OUT: a function neen some decoration to count the calling times
print(akkerman.calls)    # OUT: 258

第一次解题

根据题目要求,需要保证装饰器不影响原函数的 __doc__ 描述部分,意味着需要在装饰器内使用 functools.wraps() 来包装,保证不会导致装饰器函数顶替掉原函数(因为装饰器的原理)。

def profiler(func):
    func.calls = 0
    @wraps(func)
    def f(*args):
        f.calls += 1    # 此处使用 func 会因为装饰器的原理而无法使 akkerman 函数改变 calls 属性
        return func(*args)
    return f

如上代码,运行结果如下

print(akkerman.__doc__)    # OUT: a function neen some decoration to count the calling times
print(akkerman.calls)    # OUT: 258

但是由于只是为了满足题目要求,并且不清楚 wraps 的具体运作原理,所以决定不使用 wraps。故代码修改如下(二次编辑提示:最后决定使用 wraps 来继承如 __doc__,__name__ 等属性,不然题目提交不过,当然使用 wraps 也是正常的装饰器的标准方式,看来是想偷懒没偷成功)

def profiler(func):
    def f(*args):
        f.calls += 1
        value = func(*args)
        return value
    f.calls = 0
    f.__doc__ = func.__doc__
    return f

同时测试用例运行结果也没有什么问题。看似这个问题已经解决了,用装饰器实现了函数计数,但是伴随着新的问题,并且困扰了很久的问题出现了。

第二次解题:新的问题

新的问题是,如果 akkerman 函数多次调用,它会将每一次调用的时候的递归次数进行加和,因为装饰器内没有实现初始化,或者说只在定义装饰器的时候初始化了。

关于如何当装饰器在反复运行的时候能理解当前递归函数是否结束了递归过程,并在结束之后如何进行初始化这个问题上,思考了很久没有得出答案。

如下代码为第一次尝试:

def profiler(func):
    def f(*args):
        try:
            calls += 1
        except UnboundLocalError:
            calls = 1
        
        value = func(*args)
        f.calls = calls if calls else f.calls
        calls = 0
        return value
    return f

由于源代码已经删掉了,在改进的过程中,所以直接现场写一份,为了简便就暂时不管 __doc__ 的继承问题了,在之后改进后的成功实现的代码部分会重新提到 __doc__。

当时写这个方法的时候的想法现在也忘差不多了,很明显代码是有问题的。

第三次解题:成功实现

首先今早开始的想法是,f 会被 profiler 返回,并且会代替原函数,那么这个 f 会随着递归被调用无数次,但是如果将 f 放在一个新的函数内,这样这个新的函数就会只执行一次,而 f 会递归地在新函数内执行,从而简单的实现初始化、计数、输出、初始化操作

def new_function():
    counter = 0
    def profiler(func):
        def f(*args):
            counter += 1
            value = func()
            f.calls = counter
            return value
        return f

但是这个方法在动手实现的时候被自己否决掉了,因为这样的话需要运行函数 new_function,就算将其作为装饰器再给 akkerman 依旧会面临跟 f 一样的待遇,也就是反复递归运行。所以这个方法在这个题中是不可能用的

同时还有一个问题在于,counter在 profiler 外部定义,无法直接在 profiler 和 f 内使用,但是如果 counter 是一个类,则可以(原理还不清楚),所以在之后将 counter 修改为类

既然这个方向走不通,那么问题就是如何让装饰器知道现在是递归的过程还是一个新的调用。于是开始试图着手去找可以表示递归函数递归结束的标志,从而用这个标志进行初始化。

首先想到的是地址,但是很快就被自己否决了,因为地址只是函数的存放位置,和实际执行过程中没有关系。其次想到的是函数的各种内置属性,但是通过调试看了半天也没找到在递归过程中有规律的、跟随递归过程改变的内容。

最后忽然想到,递归函数的运行机制,是通过递归调用的时候运用函数栈的方式实现。递的过程中是入栈,将当前函数的状态入栈;归的过程是出栈,将函数的返回值进行返回,并且使函数出栈,进入上一次入栈的函数内。通过在递归函数中添加输出也证实了这个事实。

于是,通过设置一个参数 stuck_deep 来记录函数栈的深度,然后当深度为 0 的时候则可以认为这次递归函数的整个递归过程结束。可以通过判断 stuck_deep 是否为 0 来进行计数器初始化。

最后成功实现的代码如下

from functools import wraps

def profiler(func):
    class Counter:
        '''
        计数器类
        '''
        def __init__(self):
            self.count = 0    # 计数器
            self.stuck_deep = 0    # 当前计数器位于递归函数的递归深度,或者说是栈的深度

        def increase(self):
            '''
            自增
            '''
            self.count += 1

        def init(self):
            '''
            初始化
            '''
            self.__init__()

    counter = Counter()
    @wraps(func)    # 返回的装饰器函数 f 继承被装饰函数 func 的属性,包括 __doc__,__name__等
    def f(*args):
        counter.stuck_deep += 1    # 入栈
        counter.increase()    # 计数器自增
        value = func(*args)    # 调用函数并存储返回值
        counter.stuck_deep -= 1    # 出栈
        if counter.stuck_deep==0:    # 如果栈为空,则证明递归过程结束,进行初始化
            f.calls = counter.count    # 为了满足题目要求,将计数器内容给函数的属性 calls
            counter.init()    # 初始化计数器
        return value
    return f

@profiler
def ack(m, n):
    '''
    this is the function's __doc__ part
    '''
    while(m!=0):
        if(n==0):
            n=1
        else:
            n=ack(m, n-1)
        m -= 1
    return n+1

ack(3, 4)

print(ack.calls)    # 5094

ack(3, 2)

print(ack.__doc__)    # this is the function's __doc__ part
print(ack.calls)        # 258

总结

巧妙的使用了递归函数的本质,通过设置一个变量来记录递归函数的函数栈的大小,从而可以使装饰器识别出当前递归过程是否结束,从而判断是否需要初始化计数器。

 第二次编辑内容:将原本的 `f.__doc__ = func.__doc__` 取消,使用 @wraps 来代替