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,例如listdict等) ”和“不可变的 (immutable, 例如strintfloattuple等) ”。只有不可变对象作为参数的函数才能使用Memoization。这是因为将函数的结果存储在字典中时,需要将函数的参数作为字典的key存储,要作为字典的key必须可以计算哈希值,然而可变对象是不可以计算哈希值的,不能作为字典的key存在,因此自然无法进行memoization了。
因此,如果您的函数用到listdict作为参数的话,就无法使用memoizations了,如果一定要用list,建议用tuples代替。

别的应用场景?

另外,Memoization函数用于函数递归调用提升性能是显而易见的效果,因为递归涉及到大量的同一函数调用,那么,我想跟大家讨论一下,还有什么别的情况是使用memoization的好场景呢??

  • 3
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值