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. 根據條件選擇合適的包裝函數

以前實現裝飾器時,都是直接函數兩連套或三連套(裝飾器參數、函數、函數參數),再加上一些判斷函數,縮進就太多了,規范代碼會比較難。這時可以把內部函數移到另一個外部函數內,只需要直接調用這個外部函數,就能調用到這個函數內定義的函數,寫起來更簡潔。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM