Python学习笔记——用装饰器decorator和Memoization记忆化提高效率,原理讲清楚了
Python学习笔记——用装饰器decorator和Memoization记忆化提高效率
Python真是一个强大而又简洁的语言,接触python时间越长,越能发现它能提供的某些功能真是让人叹为观止,然而这些强大的语言又能用非常简洁的方式引入到代码中去,真是太厉害了。
今天学到的是memoization,并且兼顾学习了装饰器decorator的知识
装饰器
首先讲一点预备知识,python里常用的装饰器,其实就是一个函数,以另一个函数为参数做一些事情,因此也可以看成是一个包裹函数,把另一个函数包裹起来,提供一些原函数不具备的功能。
比方说,有一个函数计算n的平方。函数的功能只是计算,而不作进一步的操作。我们这时就可以写一个包裹函数把它包裹起来,提供打印的功能。注意,这个包裹函数的返回值是一个新的函数。
看代码,先定义一个包裹函数,添加打印功能。注意这个函数在函数体内定义了一个新的函数,并将新函数作为返回值:
In [1]: def print_func(func):
...: def printing_func(*args):
...: res = func(*args)
...: print('result of', func.__name__, 'is', res)
...: return printing_func
...:
我们要包裹这个简单的平方函数:
In [2]: def pow(n):
...: return n * n
...:
包裹函数写好后,可以手工包裹,可以看到函数的输出已经变成了打印结果。如下:
In [3]: pr_pow = print_func(pow)
In [4]: pr_pow(3)
result of pow is 9
但这体现不出python的简洁性,最能体现的简洁性的是用装饰器:
In [5]: @print_func
...: def pow2(n):
...: return n * n
...:
In [6]: pow2(3)
result of pow2 is 9
直接完成!就这样@装饰器通过一行代码就改变了一个函数的行为,实际上是通过一个“二次函数”返回一个新的函数实现的,但表达方式非常清楚、优美。
上面装饰器的知识是用来帮我我们理解下面的干货的,毕竟用装饰器打印结果并不是一个最佳实践,而下面的例子就充分体现装饰器的作用和python的强大了。
Memoization记忆化
Memoization (注意不是“Memorization”,少一个字母“r”,这个词的词源是memorandum/memo,意思是“备忘录”) 是一种优化程序性能的方法,它用于需要反复执行一个昂贵的函数的场合。所谓昂贵的函数,指的是运行时间很长、或内存占用量很大的函数,这样的函数会导致等待、或占用其他程序的运算时间。
Memoization 的基本原理,是在内存中把这个函数的输出值保存下来供下次使用,这样就不需要重复计算了。
我们可以以一个计算斐波那契数列的递归函数为例看看memoization是如何做的,首先定义memoize()
函数:
In [7]: def memoize(func):
...: cache = {}
...: def memoized_func(*args):
...: if args in cache:
...: return cache[args]
...: res = func(*args)
...: cache[args] = res
...: return res
...: return memoized_func
...:
我们在上例中用了一个字典来存放临时信息缓存,只要字典内有结果,就直接返回结果,只有当字典内找不到结果时才计算,并放入字典供下次使用。使用字典来存放临时结果缓存的原因是字典的读取速度非常快。
接下来定义斐波那契函数。就个函数是一个递归函数,运算效率极低,计算的时间复杂度是 O ( 2 N ) O(2^N) O(2N),因此是个昂贵函数。
In [8]: def fib(n):
...: if n == 0:
...: return 0
...: elif n == 1:
...: return 1
...: return fib(n-1) + fib(n-2)
...:
我们可以看下纯函数的运行耗时:
In [9]: %time fib(35)
CPU times: user 3.87 s, sys: 5.93 ms, total: 3.87 s
Wall time: 3.88 s
Out[9]: 9227465
再用memoize()
函数手工打包,再运行:
In [10]: memoized_fib = memoize(fib)
In [11]: %time memoized_fib(35)
CPU times: user 3.75 s, sys: 11.1 ms, total: 3.76 s
Wall time: 3.77 s
Out[11]: 9227465
时间差不多!这是为什么!因为第一次计算,结果还未保存,现在我们再运行一次:
In [12]: %time memoized_fib(35)
CPU times: user 4 µs, sys: 0 ns, total: 4 µs
Wall time: 6.91 µs
Out[12]: 9227465
看!“耗时”大大降低,很明显,从字典里读取数据当然比递归调用要快得多
我们可以用以下方法方便地检查函数的缓存值
In [13]: memoized_fib.__closure__[0].cell_contents
Out[13]: {(35,): 9227465}
果然!fib(35)
的函数值已经在cache中了,我们可以多试几次,新的计算结果同样会被保存:
In [14]: memoized_fib(36)
Out[14]: 14930352
In [15]: memoized_fib(0)
Out[15]: 0
In [16]: memoized_fib(1)
Out[16]: 1
In [17]: memoized_fib(2)
Out[17]: 1
In [18]: memoized_fib(3)
Out[18]: 2
In [19]: memoized_fib.__closure__[0].cell_contents
Out[19]: {(35,): 9227465, (36,): 14930352, (0,): 0, (1,): 1, (2,): 1, (3,): 2}
有人会想,既然memoize()
函数可以保存最终结果,同时斐波那契函数是递归定义的,那如果把递归调用的中间结果也缓存,是不是可以加速函数运行呢?答案是肯定的,但我们怎么做呢?用包裹好的函数调用自身?那样函数写出来就太复杂了!
所幸,pythom提供了装饰器,通过一个小小的装饰器,让函数改头换面
In [20]: @memoize
...: def fib_memo(n):
...: if n == 0:
...: return 0
...: if n == 1:
...: return 1
...: return fib_memo(n-1) + fib_memo(n-2)
...:
简单吗?
看看运行结果
In [21]: %time fib_memo(35)
CPU times: user 48 µs, sys: 1 µs, total: 49 µs
Wall time: 52 µs
Out[21]: 9227465
In [22]: %time fib_memo(36)
CPU times: user 8 µs, sys: 1 µs, total: 9 µs
Wall time: 12.2 µs
Out[22]: 14930352
是不是很惊喜!由于整个函数被装饰器打包好了,因此它自身递归调用的也是作过缓存改造的函数,因此直接的效果就是极大的效率提升!
当然,惊艳的不仅仅是速度的提升,还有装饰器的极简使用,非常pythonic,非常优美。
运用functools
中的lru_cache()
函数实现记忆化
当然,Python有那么多的扩展包,我们不需要自己来实现memoization,python的functools
包中有lru_cache()
函数可以调用:
In [55]: from functools import lru_cache
In [56]: @lru_cache(maxsize=128)
...: def fib_lru(n):
...: if n == 0:
...: return 0
...: if n == 1:
...: return 1
...: return fib_lru(n-1) + fib_lru(n-2)
...:
看看效果:
In [57]: %time fib_lru(35)
CPU times: user 31 µs, sys: 11 µs, total: 42 µs
Wall time: 63.2 µs
Out[57]: 9227465
而且,lru_cache还自带CacheInfo函数用来查看缓存信息
In [58]: fib_lru.cache_info()
Out[58]: CacheInfo(hits=33, misses=36, maxsize=128, currsize=36)
局限性
一、函数必须严格定义
Memoization最大的局限性是只能应用于严格定义的函数,所谓严格定义的函数的特点是,当给定一个输入时,输出的结果永远是一样的。在计算机领域,我们有大量的函数并不符合这样的额严格定义:比如一个函数返回值跟当天的日期和时间有关联时,在星期五早上调用函数,跟在星期一下午调用同一个函数,尽管输入的参数是一样的,但结果很可能不同。
这种情况记忆化肯定行不通,因为星期五的结果不能用在星期一。
二、函数的参数必须是“不变的” (immutable)
在python中的对象按照可变性分为两类:“可变的 (mutable,例如list
、dict
等) ”和“不可变的 (immutable, 例如str
,int
,float
,tuple
等) ”。只有不可变对象作为参数的函数才能使用Memoization。这是因为将函数的结果存储在字典中时,需要将函数的参数作为字典的key存储,要作为字典的key必须可以计算哈希值,然而可变对象是不可以计算哈希值的,不能作为字典的key存在,因此自然无法进行memoization了。
因此,如果您的函数用到list
或dict
作为参数的话,就无法使用memoizations了,如果一定要用list,建议用tuples
代替。
别的应用场景?
另外,Memoization函数用于函数递归调用提升性能是显而易见的效果,因为递归涉及到大量的同一函数调用,那么,我想跟大家讨论一下,还有什么别的情况是使用memoization的好场景呢??