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. 根據條件選擇合適的包裝函數
以前實現裝飾器時,都是直接函數兩連套或三連套(裝飾器參數、函數、函數參數),再加上一些判斷函數,縮進就太多了,規范代碼會比較難。這時可以把內部函數移到另一個外部函數內,只需要直接調用這個外部函數,就能調用到這個函數內定義的函數,寫起來更簡潔。