标签:wrapper arguments 数值 指针 直接 自己 field sed max
Python3有个内置的缓存装饰器 - lru_cache,写程序的时候省了我好多时间(不用自己写数据结构管理查询的结果了,直接使用函数管理)。最近研究了一下它的实现方法,学到了很多编程的技巧,先记录下来。
LRU,即Least_Recently_Used。lru_cache的使用方法非常简单,在需要缓存结果的函数或方法上加上 @lru_cache(maxsize=128, typed=False) 即可,maxsize是缓存的最大结果数目,当maxsize为None时会变成简单的cache,就不具备LRU特性了;typed表示是否根据传入参数类型的不同缓存不同的结果。
我从以下几个方面对此函数的实现进行分析:
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。
从最简单的功能看起,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也是可哈希的。
_CacheInfo = namedtuple("CacheInfo", ["hits", "misses", "maxsize", "currsize"])
命中率使用命名元组返回,字段分别为缓存命中次数、未命中次数、缓存容量、当前缓存使用量。之前觉得命名元组和原始的元组用法差不多,也没提高数据的存取效率,就没注意过这个类,现在看用来显示缓存的返回值是非常合适的结构,也让我对这一结构有了新的认识。
def wrapper(*args, **kwds): # No caching -- just a statistics update nonlocal misses misses += 1 result = user_function(*args, **kwds) return result
不使用缓存,只能统计缓存未命中次数(即函数调用次数)。
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特性的缓存,只是简单地用字典缓存
当限制了缓存的个数时,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,理解这一结构并不困难。
1. 使用双链表实现LRU特性
双链表相比单链表来说,在频繁地插入和删除结点方面更具优势。单链表求前一结点的操作避免不了要遍历一遍表,时间复杂度为O(n),而双链表能直接通过当前结点求得前后结点,时间复杂度为O(1),双链表会多耗些内存,是一种以空间换时间的策略。
2. 使用namedtuple做为返回值
看过python文档的代码,这一结构除了能让元组更方便地以属性方式取值外,还对__repr__()重写,使其能格式化为name=value的形式,在显示方式这一结构比元组更有优势。
3. 根据条件选择合适的包装函数
以前实现装饰器时,都是直接函数两连套或三连套(装饰器参数、函数、函数参数),再加上一些判断函数,缩进就太多了,规范代码会比较难。这时可以把内部函数移到另一个外部函数内,只需要直接调用这个外部函数,就能调用到这个函数内定义的函数,写起来更简洁。
标签:wrapper arguments 数值 指针 直接 自己 field sed max
原文地址:https://www.cnblogs.com/fengg123/p/11032615.html