# Python 装饰器本质上是对函数闭包的语法糖

装饰器是 Python 中的一个容易令人困惑的概念,但实际上,只要掌握两句口诀,就可以掌握装饰器,搞定那些令人困惑的面试题.

  • 装饰器本质上是对函数闭包的语法糖.
  • 装饰器在第一次调用被装饰的函数时调用闭包进行函数增强.

# 函数闭包

函数闭包本质上是一个函数,它的接收参数和返回值也都是函数,返回的函数本质上是对传入的参数进行增强之后的结果.

下面,我们从一个例子来引入函数闭包:

假设我们有一个主要需求 (主要功能): 统计 0~100 之间的所有奇数,还有一个额外的需求 (辅助功能): 统计函数运行的时间,我们从各种不同的写法,来引入闭包.

# 把主要逻辑和辅助功能写在同一个函数里:难以修改,容易出 bug

假设我们不使用任何函数增强技术,将所有代码写入一个文件里,代码如下:

import time
def print_odds():
    """
    输出0~100之间所有奇数,并统计函数执行时间
    """
    start_time = time.clock()   # 起始时间
    # 查找并输出所有奇数
    for i in range(100):
        if i % 2 == 1:
            print(i)
    end_time = time.clock()     # 结束时间
    print("it takes {} s to find all the olds".format(end_time - start_time))
if __name__ == '__main__':
    print_odds()

上述代码奇丑无比,它之所以丑,是因为把主要功能逻辑 (输出奇数) 和辅助功能 (记录时间) 耦合在一起了,这样会导致程序的可读性很差,且难以修改,容易出 bug.

# 通过辅助功能函数调用主要功能函数:函数逻辑反了,难以封装成模块

对上面的代码加以改进,既然要解耦合,就将主要功能逻辑 (输出奇数) 和辅助功能 (记录时间) 分到两个函数里就好了,通过函数调用 (将主要功能逻辑 print_odds 传入辅助函数 count_time , 在辅助函数内部调用主要功能逻辑) 来实现功能.

import time
def count_time(func):
    """
    统计某个函数的运行时间
    """
    start_time = time.clock()  	# 起始时间
    func()  					# 执行函数
    end_time = time.clock()  	# 结束时间
    print("it takes {} s to find all the olds".format(end_time - start_time))
def print_odds():
    """
    输出0~100之间所有奇数,并统计函数执行时间
    """
    for i in range(100):
        if i % 2 == 1:
            print(i)
if __name__ == '__main__':
    count_time(print_odds)

这个代码好在解耦合了,方便对主要功能逻辑和辅助功能逻辑分别进行修改.

上述程序可以正常执行,在语法上没有问题,但是在逻辑上是存在问题的,它违反了设计模式中的开闭原则:在 main 函数里调用了 count_time . 模块的主要功能函数 print_odds 应该是对使用者开放的,而辅助功能函数 count_time 应该是对用户封闭的.

也就是说,我们希望的情形是:调用主要功能函数 print_odds 时,就能同时实现主要功能和辅助功能,而不需要显式调用辅助功能函数 count_time .

要实现上述功能,就需要函数闭包了.

# 函数闭包:需要显式调用函数闭包

函数闭包本质上是一个函数,它的接收参数和返回值也都是函数,返回的函数本质上是对传入的参数进行增强之后的结果.

import time
def print_odds():
    """
    输出0~100之间所有奇数,并统计函数执行时间
    """
    for i in range(100):
        if i % 2 == 1:
            print(i)
def count_time_wrapper(func):
    """
    闭包,用于增强函数func: 给函数func增加统计时间的功能
    """
    def improved_func():
        start_time = time.clock()   # 起始时间
        func()                      # 执行函数
        end_time = time.clock()     # 结束时间
        print("it takes {} s to find all the olds".format(end_time - start_time))
    return improved_func
if __name__ == '__main__':
    # 调用 count_time_wrapper 增强函数
    print_odds = count_time_wrapper(print_odds)
    print_odds()

上述代码中, count_time_wrapper 就是一个函数闭包,它的功能是用于增强输入的 func 函数:给函数 func 增加统计时间的功能. improved_func 就是进行增强后的 func .

通过在主函数调用 print_odds = count_time_wrapper(print_odds) , 就可以实现对原 print_odds 函数的增强,返回的是一个增强过后的函数。有一点类似于工厂方法的赶脚.

使用函数闭包要注意在第一次调用被增强函数前,要记得显式调用闭包函数进行增强,但是偷懒的我们自然会想到,有没有一种机制,让 Python 解释器在第一次调用被增强函数时,自动地调用闭包函数对原函数进行增强,而不需要显式调用闭包函数呢?答案是有的,这个机制就是装饰器.

# 装饰器:其实就是函数闭包,只是不用显式调用了而已

装饰器其实就是函数闭包,其语法为: @闭包函数名 ,通过这种形式,通知 Python 解释器自动调用闭包函数对原函数进行增强,

import time
def count_time_wrapper(func):
    """
    闭包,用于增强函数func: 给函数func增加统计时间的功能
    """
    def improved_func():
        start_time = time.clock() 	# 起始时间
        func()  					# 执行函数
        end_time = time.clock()  	# 结束时间
        print("it takes {} s to find all the olds".format(end_time - start_time))
    return improved_func
@count_time_wrapper
def print_odds():
    """
    输出0~100之间所有奇数,并统计函数执行时间
    """
    for i in range(100):
        if i % 2 == 1:
            print(i)
if __name__ == '__main__':
    # 装饰器等价于在第一次调用函数时执行以下语句:
    # print_odds = count_time_wrapper(print_odds)
    print_odds()

使用解释器,我们就不用主动调用 print_odds = count_time_wrapper(print_odds) 函数了,Python 解释器自动帮我们进行了函数增强.

# 装饰器

# 装饰器本质上是对函数闭包的语法糖

什么是语法糖:

语法糖指计算机语言中添加的某种语法,这种语法对语言的功能没有影响,但是更方便程序员使用。

从上面的定义,可以分析出两个关键点:

  • 语法糖没有增加新功能 (解释器本身就是闭包封装而已), 只是一种更方便的写法.
  • 语法糖可以完全等价地转换为原本非语法糖的代码.
    图 1

# 装饰器在第一次调用被装饰的函数时调用闭包进行函数增强

装饰器在第一次调用被装饰的函数时调用闭包进行函数增强.

从这句话,可以分析出两个关键点:

  • 装饰器的增强时机:在第一次调用之前。也就是说,只有在第一次调用被装饰的函数时,闭包函数才会被调用。若整个程序的运行过程中都没有调用被装饰的函数,闭包函数也不会被调用.
  • 装饰器增强的次数:只增强一次。也就是说,闭包函数只被调用一次,当第二次以上调用原函数时,实际上调用的直接就是增强后的函数.

好多关于装饰器的面试题无非就是在这两个问题上做文章,在闭包函数里加一堆 print 语句,然后问你输出啥,本质上就是让你分析闭包函数啥时候被调用了,遵守上面这两条就能分析出来的.

# 一个通用的装饰器:保留原函数的参数列表和返回值

我们之前写的那个闭包函数不能对带有函数参数和返回值的函数进行装饰,否则会出问题,写一个带有函数参数和返回值的函数 count_odds 来验证这一点:

import time
def count_time_wrapper(func):
    """
    闭包,用于增强函数func: 给函数func增加统计时间的功能
    """
    def improved_func():
        start_time = time.clock()   # 起始时间
        func()                      # 执行函数
        end_time = time.clock()     # 结束时间
        print("it takes {} s to find all the olds".format(end_time - start_time))
    return improved_func
def count_odds(lim=100):
    """
    输出0~lim之间所有奇数,并统计函数执行时间
    """
    cnt = 0
    for i in range(lim):
        if i % 2 == 1:
            cnt+=1
    return cnt
  • 验证对于有返回值的函数的情况 在主函数里,我们在函数增强前后分别调用不带参数的 count_odds , 查看输出:
if __name__ == '__main__':
    print('增强前')
    print(count_odds(lim=10000))         # 装饰前函数能正常返回,能接收参数
    print('----------------------')
    print('增强后')
    count_odds = count_time_wrapper(count_odds)
    print(count_odds(lim=10000))         # 装饰后函数不能正常返回,不能接收参数

输出:

增强前
50
----------------------
增强后
it takes 5.3299999999978365e-05 s to find all the olds
None

可以看到,增强后的函数虽然获得了辅助功能,但丢失了返回值.

  • 验证对于有参数的函数的情况 在主函数里,我们在函数增强前后分别调用带参数的 count_odds , 查看输出:
if __name__ == '__main__':
    print('增强前')
    print(count_odds(lim=10000))         # 装饰前函数能正常返回,能接收参数
    print('----------------------')
    print('增强后')
    count_odds = count_time_wrapper(count_odds)
    print(count_odds(lim=10000))         # 装饰后函数不能正常返回,不能接收参数

程序报错:

TypeError: improved_func() got an unexpected keyword argument 'lim'
增强前
5000
----------------------
增强后

看到函数报错:闭包内层的增强函数 improved_func 不能接收参数.

因此我们要对原本的闭包内层的增强函数 improved_func 进行修改,做如下两条修改:

  • 增强函数的返回值应该是被增强函数的返回值.
  • 增强函数接收到的参数应全部传给被增强函数.

因此对闭包 count_time_wrapper 修改如下:

def count_time_wrapper(func):
    """
    闭包,用于增强函数func: 给函数func增加统计时间的功能
    """
    def improved_func(*args, **kwargs): # 增强函数应该把收到的所有参数传给原函数
        start_time = time.clock()   	
        ret = func(*args, **kwargs)     # 执行函数
        end_time = time.clock()     	
        print("it takes {} s to find all the olds".format(end_time - start_time))
        return ret      				# 增强函数的返回值应该是原函数的返回值
    return improved_func

一个通用的装饰器的结构如下:

图 2

# 一道面试题:多个装饰器的执行顺序

对于好友装饰器的面试题,我们的终极解决方式就是把装饰器改写回非语法糖形式的代码.

有一道关于装饰器的题目:有两个装饰器都被装饰到函数 original_func 上,然后加一堆输出语句,问你输出的是啥.

def wrapper1(func1):
    print('set func1')		# 在 wrapper1 装饰函数时输出
    def improved_func1():
        print('call func1')	# 在 wrapper1 装饰过的函数被调用时输出
        func1()
    return improved_func1
def wrapper2(func2):
    print('set func2')  	# 在 wrapper2 装饰函数时被输出
    def improved_func2():
        print('call func2')	# 在 wrapper2 装饰过的函数被调用时输出
        func2()
    return improved_func2
@wrapper1
@wrapper2
def original_func():
    pass
if __name__ == '__main__':
    original_func()
    print('-----')
    original_func()

运行上述程序,输出如下:

set func2
set func1
call func1
call func2
-----
call func1
call func2

对于这道题,我们的分析方式是将装饰器写回成闭包函数 (正如前面所说,装饰器本质上就是闭包函数).

if __name__ == '__main__':
    # original_func = wrapper1(wrapper2(original_func))
    
    original_func = wrapper2(original_func)		
    print(original_func.__name__)		# original_func = improved_func2(original_func)
    original_func = wrapper1(original_func)		
    print(original_func.__name__)		# original_func = improved_func1(improve_func2(original_func))
    
    # original_func 封装了 improved_func1 (improve_func2 (original_func))
    original_func()		# improved_func1(improve_func2(original_func))
    print('-----')
    original_func()		# improved_func1(improve_func2(original_func))

正如前面所说,在第一次调用被装饰函数时,装饰器会被展开成闭包函数的调用,因此上述 main 函数过程可以被分解为两次闭包封装外加两次函数调用.

有些同学可能会问,明明 @wrapper1 装饰器在上面,为什么先进行的是 wrapper2 的闭包封装呢?实际上,这是因为两次封装从上到下进行,可以被看成 original_func = wrapper1(wrapper2(original_func)) , wrapper2 在函数栈的上层,所有先被调用 (或者也可以看成 wrapper2wrapper1 的括号里,说明 wrapper2 的运算优先级较高,因此先被执行).

两次封装的过程中,输出如下:

set func2
set func1

在闭包封装之后,我们可以使用 print(original_func.__name__) 查看当前函数的函数名,也就是查看增强函数到底被封装进哪个函数中了,我们看到,两次封装后,增强过的函数名分别是 improved_func2improved_func1 , 所以 improved_func1 被封装在 improve_func2 的外层,所以每次调用 original_func 时,相当于调用 improved_func1(improve_func2(original_func)) , 因此输出为:

call func1
call func2

为了验证我们之前的说法,可以进行断点调试 (断点调试的过程也可以见文末的视频), 可以看到,函数的调用过程如下:

图 3

# 一道思考题:如何创建带参数的装饰器

我们创建一个 log_wrapper 装饰器如下,用于给函数加日志:

def log_wrapper(func):
    """
    闭包,用于增强函数func: 给func增加日志功能
    """
    def improved_func():
        start_time = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(time.time()))  # 起始时间
        func()  # 执行函数
        end_time = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(time.time()))  # 结束时间
        print("Logging: func:{} runs from {} to {}".format(func.__name__, start_time, end_time))
    return improved_func

我们想要对上述装饰器进行改进,让日志中能输入传给装饰器的信息:

图 4

提示:闭包本身就是一个二层函数,可以通过增加层数来给装饰器赋予更多的功能。这是《Python CookBook》的一个例子,见
Python Cookbook