15 - reduce-pratial偏函數-lsu_cache

介紹

        functools模塊存放着不少工具函數,大部分都是高階函數,其做用於或返回其餘函數的函數。通常來講,對於這個模塊,任何可調用的對象均可以被視爲函數。緩存

1 reduce方法

        其含義是減小,它接受一個兩個參數的函數,初始時從可迭代對象中取兩個元素交給函數,下一次會將本次函數返回值和下一個元素傳入函數進行計算,直到將可迭代對象減小爲一個值,而後返回:reduce(lambda x, y: x+y, [1, 2, 3, 4, 5]) calculates ((((1+2)+3)+4)+5),app

reduce(function, sequence[, initial]) -> value
  • function: 兩個參數的函數
  • sequence:可迭代對象(不能爲空)
  • initital:初始值(能夠理解爲給函數的第一個參數指定默認值),不然第一次會在可迭代對象中再取一個元素

下面是一個求1到100累加的栗子分佈式

# 普通版
In [24]: sum = 0
In [25]: for i in range(1,101):
    ...:     sum += i
    ...:
In [26]: print(sum)
5050


# 利用reduce版
In [22]: import functools
In [23]: functools.reduce(lambda x,y:x+y,range(101))
Out[23]: 5050

2 partial方法(偏函數)

        在前面學習函數參數的時候,經過設定參數的默認值,能夠下降函數調用的難度。而偏函數也能夠作到這一點,funtools模塊中的partial方法就是將函數的部分參數固定下來,至關於爲部分的參數添加了一個固定的默認值,造成一個新的函數並返回。從partial方法返回的函數,是對原函數的封裝,是一個全新的函數。函數

注意:這裏的偏函數和數學意義上的偏函數不同。工具

partial(func, *args, **keywords) - 返回一個新的被partial函數包裝過的func,並帶有默認值的新函數

2.1 partial方法基本使用

In [27]: import functools
    ...: import inspect
    ...:
    ...:
    ...: def add(x, y):
    ...:     return x + y
    ...:
    ...:
    ...: new_add = functools.partial(add,1)
    ...: print(new_add)
    ...:
functools.partial(<function add at 0x000002798C757840>, 1)

In [28]:
In [28]: new_add(1,2)
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
<ipython-input-28-2d6520b7602a> in <module>
----> 1 new_add(1,2)

TypeError: add() takes 2 positional arguments but 3 were given

In [29]: new_add(1)
Out[29]: 2
  • 因爲咱們包裝了函數add,並指定了一個默認參數1,這個參數會按照位置參數,看成默認值賦給x了
  • 因此當咱們再次調用new_add,只須要傳入y的值就好了。
  • 若是再傳遞兩個,那麼連同包裝前傳入的1,一塊兒傳給add函數,而add函數只接受兩個參數,因此會報異常。學習

    獲取一個函數的參數列表,可使用前面學習的inspect模塊測試

In [30]: inspect.signature(new_add)
Out[30]: <Signature (y)>
  • 查看new_add的簽名信息,發現,它的確只須要傳入一個y就能夠了。

根據前面咱們所學的函數知識,咱們知道函數傳參的方式有不少種,利用偏函數包裝後產生的新函數的傳參會有所不一樣,下面會列舉不一樣傳參方式被偏函數包裝後的簽名信息。操作系統

# 最複雜的函數的形參定義方式
def add(x, y, *args, m, n, **kwargs):
    return x + y
  • add1 = functools.partial(add,x=1):包裝後的簽名信息(*, x=1, y, m, n, **kwargs),只接受keyword-only的方式賦值了
  • add2 = functools.partial(add,1,y=20):包裝後的簽名信息(*, y=20, m, n, **kwargs),1已經被包裝給x了其餘參數只接受keyword-only的方式賦值了
  • add3 = functools.partial(add,1,2,3,m=10,n=20,a=30,b=40):包裝後的簽名信息(*args, m=10, n=20, **kwargs),1給了x,2給了y, 3給了args,能夠直接調用add3,而不用傳遞任何參數
  • add4 = functools.partial(add,m=10,n=20,a='10'):包裝後的簽名信息(x, y, *args, m=10, n=20, **kwargs),a='10'已被kwargs收集,依舊可使用位置加關鍵字傳遞實參。

2.2 partial原碼分析

上面咱們已經瞭解了partial的基本使用,下面咱們來學習一下partial的原碼,看它究竟是怎麼實現的,partial的原碼存在於documentation中,下面是原碼:code

def partial(func, *args, **keywords):
    def newfunc(*fargs, **fkeywords):
        newkeywords = keywords.copy()    # 偏函數包裝時指定的位置位置參數進行拷貝
        newkeywords.update(fkeywords)    # 將包裝完後,傳遞給偏函數的關鍵字參數更新到keyword字典中去(key相同的被替換)
        return func(*args, *fargs, **newkeywords)  # 把偏函數包裝的位置參數優先傳遞給被包裝函數,而後是偏函數的位置參數,而後是關鍵字參數
    newfunc.func = func    # 新增函數屬性,將被包裝的函數綁定在了偏函數上,能夠直接經過偏函數的func屬性來調用原函數
    newfunc.args = args    # 記錄包裝指定的位置參數
    newfunc.keywords = keywords # 記錄包裝指定的關鍵字參數
    return newfunc

上面是偏函數的原碼註釋,若是不是很理解,請看下圖
partial

2.3 functools.warps實現分析

如今咱們在來看一下functools.warps函數的原碼實現,前面咱們已經說明了,它是用來拷貝函數簽名信息的裝飾器,它在內部是使用了偏函數實現的。

def wraps(wrapped,
          assigned = WRAPPER_ASSIGNMENTS,
          updated = WRAPPER_UPDATES):

    return partial(update_wrapper, wrapped=wrapped,
                   assigned=assigned, updated=updated)

使用偏函數包裝了update_wrapper函數,並設置了下面參數的默認值:

  • wrapped=wrapped:將傳入給wraps的函數,使用偏函數,看成update_wrapper的默認值。
  • assigned=assigned:要拷貝的信息'__module__', '__name__', '__qualname__', '__doc__','__annotations__'
  • updated=updated: 這裏使用的是'__dict__',用來拷貝函數的屬性信息

    __dict__是用來存儲對象屬性的一個字典,其鍵爲屬性名,值爲屬性的值

下面來看一下update_wrapper函數,由於真正執行的就是它:

def update_wrapper(wrapper,
                   wrapped,
                   assigned = WRAPPER_ASSIGNMENTS,
                   updated = WRAPPER_UPDATES):
    for attr in assigned:
        try:
            value = getattr(wrapped, attr)
        except AttributeError:
            pass
        else:
            setattr(wrapper, attr, value)
    for attr in updated:
        getattr(wrapper, attr).update(getattr(wrapped, attr, {}))
    wrapper.__wrapped__ = wrapped   # 將被包裝的函數,綁定在__wrapped__屬性上。
    return wrapper
  • update_wrapper在外層被wraps包裝,實際上只須要傳入wrapper便可
  • 後面的代碼能夠理解爲是經過反射獲取wrapped的屬性值,而後update到wrapper中(拷貝屬性的過程)
  • 最後返回包裝好的函數wrapper

update_wrapper返回的就是咱們的wrapper對象,因此若是不想用wraps,咱們能夠直接使用update_wrapper

import time
import datetime
import functools

def logger(fn):
    # @functools.wraps(fn)  # wrapper = functools.wraps(fn)(wrapper)
    def wrapper(*args, **kwargs):
        start = datetime.datetime.now()
        res = fn(*args, **kwargs)
        total_seconds = (datetime.datetime.now() - start).total_seconds()
        print('函數:{} 執行用時:{}'.format(wrapper.__name__,total_seconds))
        return res

    wrapper = functools.update_wrapper(wrapper, fn)  # 這裏進行調用,可是很難看有木有?
    return wrapper

@logger
def add(x, y):
    time.sleep(2)
    return x + y

add(4,5)

這裏之因此使用偏函數實現,是由於對於拷貝這個過程來講,要拷貝的屬性通常是不會改變的,那麼針對這些不長改變的東西進行偏函數包裝,那麼在使用起來會很是方便,我以爲這就是偏函數的精髓吧。

結合前面參數檢查的例子,來加深functools.wraps的實現過程理解。

def check(fn):
    @functools.wraps(fn) 
    def wrapper(*args, **kwargs):
        sig = inspect.signature(fn) 
        params = sig.parameters  
        values = list(params.values()) 
        for i, k in enumerate(args): 
            if values[i].annotation != inspect._empty: 
                if not isinstance(k, values[i].annotation): 
                    raise ('Key Error')
        for k, v in kwargs.items():
            if params[k].annotation != inspect._empty:
                if not isinstance(v, params[k].annotation):
                    raise ('Key Error')
        return fn(*args, **kwargs)

    return wrapper
  • @functools.wraps(fn) 表示一個有參裝飾器,在這裏實際上等於:wrapper = functools.wraps(fn)(wrapper)
  • functools.wraps(fn) 的返回值就是偏函數update_wrapper , 因此也能夠理解爲這裏實際上:update_wrapper(wrapper)
  • update_wrapper 在這裏將wrapped的屬性(也就是fn),拷貝到了wrapper上,並返回了wrapper。

通過上述數說明 @functools.wraps(fn) 就等價於 wrapper = update_wrapper(wrapper),那麼再來看拷貝的過程,就很好理解了。

3 lsu_cache方法

        學習lsu_cache方法,那麼不得不提cache,那什麼是cache呢?咱們說數據是存放在磁盤上的,CPU若是須要提取數據那麼須要從磁盤上拿,磁盤速度很慢,直接拿的話,就很耗時間,因此操做系統會把一些數據提早存儲到內存中,當CPU須要時,直接從內存中讀取便可,可是內存畢竟是有限的,不是全部空間都用來存這些數據,因此內存中的一小部分用來存儲磁盤上讀寫頻繁的數據的空間,就能夠簡單的理解爲cache(這裏就不提CPU的L1,L2,L3 cache了).
        lsu_cache方法簡單來講,就是當執行某一個函數時,把它的計算結果緩存到cache中,當下次調用時,就直接從緩存中拿就能夠了,不用再次進行計算。這種特性對於那種計算很是耗時的場景時很是友好的。

把函數的計算結果緩存,須要的時候直接調用,這種模式該如何實現呢?簡單來說就是經過一個東西來獲取它對應的值,是否是和字典的元素很像?經過一個key獲取它對應的value!實際上大多數緩存軟件都是這種key-value結構!!!

3.1 基本使用

它做爲裝飾器做用於須要緩存的函數,用法格式以下:

functools.lru_cache(maxsize=128, typed=False)
  • maxsize:限制不一樣參數和結果緩存的總量,若是設置爲None,則禁用LRU功能,而且緩存能夠無限制增加,當maxsize是二的冪時,LRU功能執行的最好,當超過maxsize設置的總數量時,LRU會把最近最少用的緩存彈出的。
  • typed:若是設置爲True,則不一樣類型的函數參數將單獨緩存,例如f(3)和f(3.0)將被視爲具備不一樣結果的不一樣調用

    使用被裝飾的函數.cache_info()來查看緩存命中的次數,以及結果緩存的數量。

In [33]: import functools

In [34]: @functools.lru_cache()
    ...: def add(x: int, y: int) -> int:
    ...:     time.sleep(2)
    ...:     return x + y
    ...:

In [35]: import time

In [36]: add.cache_info()   # 沒有執行,沒有緩存,也就沒有命中了
Out[36]: CacheInfo(hits=0, misses=0, maxsize=128, currsize=0)

In [37]: add(4,5)    # 執行一次,緩存中不存在,因此miss1次,本次結果將會被緩存
Out[37]: 9

In [38]: add.cache_info()   # 驗證緩存信息,currsize表示當前緩存1個,misses表示錯過1次
Out[38]: CacheInfo(hits=0, misses=1, maxsize=128, currsize=1)

In [39]: add(4,5)  # 本次執行速度很快,由於讀取的是緩存,被命中一次,因此瞬間返回
Out[39]: 9

In [40]: add.cache_info()  # 命中加1次
Out[40]: CacheInfo(hits=1, misses=1, maxsize=128, currsize=1)

cache_info各參數含義:

  • hits: 緩存命中次數。當次傳入計算的參數,若是在緩存中存在,則表示命中
  • misses: 未命中次數。當次傳入計算的參數,若是在緩存中存在,則表示未命中
  • maxsize:表示緩存的key最大數量
  • currsize:已經緩存的key的數量

3.2 lru_cache原碼分析

def lru_cache(maxsize=128, typed=False):
    if maxsize is not None and not isinstance(maxsize, int):
        raise TypeError('Expected maxsize to be an integer or None')

    def decorating_function(user_function):
        wrapper = _lru_cache_wrapper(user_function, maxsize, typed, _CacheInfo)
        return update_wrapper(wrapper, user_function)

    return decorating_function

        這裏的返回的 decorating_function 函數中返回的 update_wrapper 是否是看起來很熟悉,沒錯,這裏一樣利用了偏函數對被包裝函數的屬性簽名信息進行了拷貝,而傳入的wrapper是纔是緩存的結果,因此咱們進一步查看_lru_cache_wrapper究竟是怎麼完成緩存的。

def _lru_cache_wrapper(user_function, maxsize, typed, _CacheInfo):
    ... ...

    cache = {}
    hits = misses = 0
    full = False

    ... ...

    def wrapper(*args, **kwds):
        # Size limited caching that tracks accesses by recency
        nonlocal root, hits, misses, full
        key = make_key(args, kwds, typed)
        with lock:
       
    ... ...

        這裏截取部分代碼進行簡要說明:cache是個字典,那麼就印證了以前咱們的設想,的確是使用字典key-value的形式進行緩存的。字典的key是來自於make_key函數的,那麼咱們接下來看一看這個函數都作了哪些事

def _make_key(args, kwds, typed,
             kwd_mark = (object(),),
             fasttypes = {int, str, frozenset, type(None)},
             tuple=tuple, type=type, len=len):
    key = args
    if kwds:     # 在使用關鍵字傳參時,遍歷kwds
        key += kwd_mark  # 使用一個特殊的對象obkect() 來 做爲位置傳參和關鍵字傳參的'分隔符'
        for item in kwds.items():
            key += item
    if typed:
        key += tuple(type(v) for v in args)
        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)
  • args: 是咱們給函數進行的位置傳參,這裏是元組類型(由於不但願被修改)。
  • kwargs: 關鍵字傳參的字典。
  • _HashedSeq: 能夠理解爲對hash()函數的封裝,僅僅是計算構建好的key的hash值,並將這個值做爲key進行存儲的。

    注意,這裏的函數_make_key是以_開頭的函數,目的僅僅是告訴你,不要擅自使用,可是爲了學習cache的key是怎麼生成的,咱們能夠直接調用它,來查看生成key的樣子(這裏只模擬參數的傳遞,理解過程便可)

In [41]: functools._make_key((1,2,3),{'a':1,'b':2},typed=False)  # 不限制類型
Out[41]: [1, 2, 3, <object at 0x2798734b0b0>, 'a', 1, 'b', 2]    # 緩存的key不帶類型

In [49]: functools._HashedSeq(functools._make_key((1,2,3),{'a':1,'b':2},typed=True))     # 限制類型
Out[49]: [1, 2, 3, <object at 0x2798734b0b0>, 'a', 1, 'b', 2, int, int, int, int, int]  # 緩存的key帶類型

key構建完畢了,_HashedSeq是如何對一個列表進行hash的呢?下面來閱讀如下_HashedSeq原碼

class _HashedSeq(list):
    __slots__ = 'hashvalue'

    def __init__(self, tup, hash=hash):
        self[:] = tup
        self.hashvalue = hash(tup)

    def __hash__(self):
        return self.hashvalue

這裏發現_HashedSeq,是一個類,當對其進行hash時,實際上調用的就是它的__hash__方法,返回的是hashvalue這個值,而這個值在__init__函數中賦值時,又來自於hash函數(這不是畫蛇添足嗎,哈哈),tup是元組類型,這裏仍是對元組進行了hash,只是返回了一個list類型而已。這裏爲了測試,咱們使用_HashedSeq對象的hashvalue屬性和hash函數來對比生成的hash值

In [54]: value = functools._HashedSeq(functools._make_key((1,2,3),{'a':1,'b':2},typed=True))
In [55]: value
Out[55]: [1, 2, 3, <object at 0x2798734b0b0>, 'a', 1, 'b', 2, int, int, int, int, int]

In [56]: value.hashvalue
Out[56]: 3337684084446775700
In [57]: hash(value)
Out[57]: 3337684084446775700    # 這裏兩次執行的結果是相同的!

小結:

  1. 經過對原碼分析咱們知道,lru_cache是經過構建字典來完成key到value的映射的
  2. 構建字典的key來源於在_make_key函數中處理過得args,kwargs參數列表
  3. 最後對列表進行hash,獲得key,而後在字典中做爲key對應函數的計算機結果

    因爲_make_key在內部是經過args和kwargs拼接來完成key的構建的,也就是說args參數位置不一樣或者kwargs位置不一樣,構建出來的key都不相同,那麼對應的hash值也就不一樣了!!!,這一點要特別注意

In [60]: add.cache_info()
Out[60]: CacheInfo(hits=1, misses=1, maxsize=128, currsize=1)

In [61]: add(4,5)
Out[61]: 9

In [62]: add.cache_info()
Out[62]: CacheInfo(hits=2, misses=1, maxsize=128, currsize=1)

In [63]: add(4.0,5.0)
Out[63]: 9

In [64]: add.cache_info()   # 因爲咱們沒有對類型的限制,因此int和float構建的key是相同的,這裏就命中了!
Out[64]: CacheInfo(hits=3, misses=1, maxsize=128, currsize=1)

In [65]: add(5,4) 
Out[65]: 9

In [66]: add.cache_info()    # 當5,4調換時,key不一樣,那麼就要從新緩存了!
Out[66]: CacheInfo(hits=3, misses=2, maxsize=128, currsize=2)

3.3 斐波那契序列的lru改造

前面咱們講遞歸的時候,使用遞歸的方法編寫fib序列,是很是優美的可是因爲每次要從新計不少值,效率很是低,若是把計算事後的值進行緩存,那麼會有什麼不一樣的呢?

普通版:
import datetime


def fib(n):
    return 1 if n < 3 else fib(n - 1) + fib(n - 2)


start = datetime.datetime.now()
print(fib(40))
times = (datetime.datetime.now() - start).total_seconds()
print(times)  # 31.652353


lru_cache加成版本:
import datetime
import functools


@functools.lru_cache()
def fib(n):
    return 1 if n < 3 else fib(n - 1) + fib(n - 2)


start = datetime.datetime.now()
print(fib(40))
times = (datetime.datetime.now() - start).total_seconds()
print(times)  # 0.0

速度簡直要起飛了!

3.4 lsu_cache的總結

lru_cache使用的前提是:

  • 一樣函數參數必定獲得一樣的結果
  • 函數執行時間很長,且要屢次執行
  • 其本質就是函數調用的參數到函數返回值的映射

缺點:

  • 不支持緩存過時,key沒法過時、失效。
  • 不支持清除操做
  • 不支持分佈式,是一個單機緩存

適用場景:單機上須要空間換時間的地方,能夠用緩存來將計算變成快速查詢。

相關文章
相關標籤/搜索