functools.lru_cache的实现

Python3有个内置的缓存装饰器 - lru_cache,写程序的时候省了我好多时间(不用自己写数据结构管理查询的结果了,直接使用函数管理)。最近研究了一下它的实现方法,学到了很多编程的技巧,先记录下来。

LRU,即Least_Recently_Used。lru_cache的使用方法非常简单,在需要缓存结果的函数或方法上加上 @lru_cache(maxsize=128, typed=False) 即可,maxsize是缓存的最大结果数目,当maxsize为None时会变成简单的cache,就不具备LRU特性了;typed表示是否根据传入参数类型的不同缓存不同的结果。

一、lru_cache的设计

我从以下几个方面对此函数的实现进行分析:

1. 缓存的结构

lru_cache的真正实现是在_lru_cache_wrapper(user_function, maxsize, typed, _CacheInfo)方法内,其中有以下几个变量:

# Constants shared by all lru cache instances:
sentinel = object()          # unique object used to signal cache misses
make_key = _make_key         # build a key from the function arguments
PREV, NEXT, KEY, RESULT = 0, 1, 2, 3   # names for the link fields

cache = {}
hits = misses = 0
full = False
cache_get = cache.get    # bound method to lookup a key or return None
cache_len = cache.__len__  # get cache size without calling len()
lock = RLock()           # because linkedlist updates aren't threadsafe
root = []                # root of the circular doubly linked list
root[:] = [root, root, None, None]     # initialize by pointing to self

 我们先看缓存存储的结构。cache是一个字典,显然这是存储结果的变量,字典可以根据key快速返回result;hits和misses对缓存的命中和未命中进行统计;root按照注释来说是一个双向链表的根结点,很明显LRU特性的实现用到了双向链表,而链表的初始化很有趣,根结点以自身初始化其前、后结点,以None初始化其key和result。

2. key的生成

从最简单的功能看起,cache的key是如何生成的?如何区别不同类型的参数?首先我把生成key的源码贴上来:

def _make_key(args, kwds, typed,
             kwd_mark = (object(),),
             fasttypes = {int, str},
             tuple=tuple, type=type, len=len):

    key = args  # 必选参数以tuple形式传入,做为key的初始值
    if kwds:  # 若存在可选参数
        key += kwd_mark  # 首先添加mark
        for item in kwds.items():   # 然后将所有可选参数以tuple形式拼接到key上
            key += item
    if typed:
        key += tuple(type(v) for v in args)  # 参数类型的识别就是把参数的类型字符串添加到key中
        if kwds:
            key += tuple(type(v) for v in kwds.values())
    elif len(key) == 1 and type(key[0]) in fasttypes:
        return key[0]
    return _HashedSeq(key)

中间的函数注释我删掉了,大意是生成的key是扁平的(flat)而非嵌套类型的,因为嵌套会占用更多的内存;若原函数传入的参数只有一个且可存入cache的key,则直接返回参数值(fasttypes内的类型)。最后的返回值可以看作直接调用了hash函数。

若函数未传入任何参数,则一个空的tuple也是可哈希的。

3. 命中率数值的返回

_CacheInfo = namedtuple("CacheInfo", ["hits", "misses", "maxsize", "currsize"])

 命中率使用命名元组返回,字段分别为缓存命中次数、未命中次数、缓存容量、当前缓存使用量。之前觉得命名元组和原始的元组用法差不多,也没提高数据的存取效率,就没注意过这个类,现在看用来显示缓存的返回值是非常合适的结构,也让我对这一结构有了新的认识。

4. maxsize == 0

def wrapper(*args, **kwds):
    # No caching -- just a statistics update
    nonlocal misses
    misses += 1
    result = user_function(*args, **kwds)
    return result

 不使用缓存,只能统计缓存未命中次数(即函数调用次数)。

5. maxsize == None

def wrapper(*args, **kwds):
    # Simple caching without ordering or size limit
    nonlocal hits, misses
    key = make_key(args, kwds, typed)
    result = cache_get(key, sentinel)
    if result is not sentinel:
        hits += 1
        return result
    misses += 1
    result = user_function(*args, **kwds)
    cache[key] = result
    return result

 无LRU特性的缓存,只是简单地用字典缓存

6. maxsize 有值

当限制了缓存的个数时,LRU特性就会生效。

def wrapper(*args, **kwds):
	nonlocal root, hits, misses, full
	key = make_key(args, kwds, typed)
	with lock:
		link = cache_get(key)
		if link is not None:
			link_prev, link_next, _key, result = link
			link_prev[NEXT] = link_next
			link_next[PREV] = link_prev
			last = root[PREV]
			last[NEXT] = root[PREV] = link
			link[PREV] = last
			link[NEXT] = root
			hits += 1
			return result
		misses += 1
	result = user_function(*args, **kwds)
	with lock:
		if key in cache:
			pass
		elif full:
			oldroot = root
			oldroot[KEY] = key
			oldroot[RESULT] = result
			root = oldroot[NEXT]
			oldkey = root[KEY]
			oldresult = root[RESULT]
			root[KEY] = root[RESULT] = None
			del cache[oldkey]
			cache[key] = oldroot
		else:
			last = root[PREV]
			link = [last, root, key, result]
			last[NEXT] = root[PREV] = cache[key] = link
			full = (cache_len() >= maxsize)
	return result

 这段代码的核心是要理解双向链表是如何操作的,以及如何用链表实现LRU特性。从这段代码中,我们可以学到如何用列表模拟双向链表这种数据结构(这段实现非常巧妙,我很喜欢)

 二、双向链表的操作

列表实现的双向链表是lru实现的重点,下面详细解析双链表的构成及操作。

首先设计链表的结点。双向链表的结点包含指向上个结点的指针、指向下个结点的指针、数据域,而指针可以看作指针域,这样就需要考虑:结点的指针域和数据域如何设计?

我们可以用Python的列表作为双向链表的结点,结点内的域可以用列表内的元素表示,即结点结构应该是:[上个结点, 下个结点, 数据域]。先直接套用代码里的初始化操作:

PREV, NEXT, DATA = 0, 1, 2
root = [] # 声明一个列表作为root结点
root[:] = [root, root, None]  # 为root结点的指针域和数据域赋初始值

 初始时根结点的两个指针都指向自身,数据域则一直为空。

插入结点

last = root[PREV]
link = [last, root, data]
root[PREV] = last[NEXT] = link

 新结点在插入前将结点的前后指针指向root的前一个结点和root,然后root的前个结点的下个结点指针和root指向前个结点的指针再指向新结点,完成插入操作。

删除结点

oldroot = root[NEXT]
root[NEXT] = oldroot[NEXT]
oldroot[NEXT][PREV] = root

 删除结点的代码很好理解,虽然源码里并未用到删除结点,但理解它有助于我们理解双链表这一结构的操作方式。

满插入结点

当链表达到限制长度不能再插入结点时,需要将旧结点删掉,才能插入新结点。但直接删除旧结点会造成内存的浪费,我们可以利用root结点的数据域为空的特性,将新结点更新到root上,并将旧结点赋值为root。

oldroot = root
root[DATA] = new_data  # 将新结点更新到root上
root = oldroot[NEXT]  # 旧结点成为root结点
root[DATA] = None

最近使用结点插入队尾

LRU的特性,最近使用过的元素要在队列的尾部。理解此步需要熟练掌握双链表的插入和删除操作。

# 先提前引入cache,便于说明问题
used_link = cache[key]  # 从cache中取出key对应的结点

# 结点的前后结点互相引用,将used_link排除在链表外
link_prev, link_next, *_ = used_link
link_prev[NEXT] = link_next
link_next[PREV] = link_prev

# 再将结点插入到队尾
last = root[PREV]
used_link[PREV] = last
used_link[NEXT] = root
root[PREV] = last[NEXT] = used_link

掌握了以上的操作后,再配合源码的cache,理解这一结构并不困难。

三、lru_cache的亮点

 1. 使用双链表实现LRU特性

双链表相比单链表来说,在频繁地插入和删除结点方面更具优势。单链表求前一结点的操作避免不了要遍历一遍表,时间复杂度为O(n),而双链表能直接通过当前结点求得前后结点,时间复杂度为O(1),双链表会多耗些内存,是一种以空间换时间的策略。

2. 使用namedtuple做为返回值

看过python文档的代码,这一结构除了能让元组更方便地以属性方式取值外,还对__repr__()重写,使其能格式化为name=value的形式,在显示方式这一结构比元组更有优势。

3. 根据条件选择合适的包装函数

以前实现装饰器时,都是直接函数两连套或三连套(装饰器参数、函数、函数参数),再加上一些判断函数,缩进就太多了,规范代码会比较难。这时可以把内部函数移到另一个外部函数内,只需要直接调用这个外部函数,就能调用到这个函数内定义的函数,写起来更简洁。

猜你喜欢

转载自www.cnblogs.com/fengg123/p/11032615.html