Python——五分鐘理解函數語言程式設計與閉包

承志發表於2020-04-07

本文始發於個人公眾號:TechFlow,原創不易,求個關注


今天是Python專題的第9篇文章,我們來聊聊Python的函數語言程式設計與閉包。

函數語言程式設計

函數語言程式設計這個概念我們可能或多或少都聽說過,剛聽說的時候不明覺厲,覺得這是一個非常黑科技的概念。但是實際上它的含義很樸實,但是延伸出來許多豐富的用法。

在早期程式語言還不是很多的時候,我們會將語言分成高階語言與低階語言。比如組合語言,就是低階語言,幾乎什麼封裝也沒有,做一個賦值運算還需要我們手動呼叫暫存器。而高階語言則從這些面向機器的指令當中抽身出來,轉而程式導向或者是物件。也就是說我們寫程式碼面向的是一段計算過程或者是一個計算機當中抽象出來的物件。如果你學過物件導向,你會發現和麵向過程相比,物件導向的抽象程度更高了一些,做了更加完善的封裝。

在物件導向之後呢,我們還可以做什麼封裝和抽象呢?這就輪到了函數語言程式設計。

函式我們都瞭解,就是我們定義的一段程式,它的輸入和輸出都是確定的。我們把一段函式寫好,它可以在任何地方進行呼叫。既然函式這麼好用,那麼能不能把函式也看成是一個變數進行返回和傳參呢?

OK,這個就是函數語言程式設計最直觀的特點。也就是說我們寫的一段函式也可以作為變數,既可以用來賦值,還可以用來傳遞,並且還能進行返回。這樣一來,大大方便了我們的編碼,但是這並不是有利無害的,相反它帶來許多問題,最直觀的問題就是由於函式傳入的引數還可以是另一個函式,這會導致函式的計算過程變得不可確定,許多超出我們預期的事情都有可能發生。

所以函數語言程式設計是有利有弊的,它的確簡化了許多問題,但也產生了許多新的問題,我們在使用的過程當中需要謹慎。

傳入、返回函式

在我們之前介紹filter、map、reduce以及自定義排序的時候,其實我們已經用到了函數語言程式設計的概念了。

比如在我們呼叫sorted進行排序的時候,如果我們傳入的是一個物件陣列,我們希望根據我們制定的欄位排序,這個時候我們往往需要傳入一個匿名函式,用來制定排序的欄位。其實傳入的匿名函式,其實就是函數語言程式設計最直觀的體現了:

sorted(kids, key=lambda x: x['score'])
複製程式碼

除此之外,我們還可以返回一個函式,比如我們來看一個例子:

def delay_sum(nums):
    def sum():
        s = 0
        for i in nums:
            s += i
        return s
    return sum
複製程式碼

如果這個時候我們呼叫delay_sum傳入一串數字,我們會得到什麼?

答案是一個函式,我們可以直接輸出,從列印資訊裡看出這一點:

>>> delay_sum([1342])
<function delay_sum.<locals>.sum at 0x1018659e0>
複製程式碼

我們想獲得這個運算結果應該怎麼辦呢?也很簡單,我們用一個變數去接收它,然後執行這個新的變數即可:

>>> f = delay_sum([1342])
>>> f()
10
複製程式碼

這樣做有一個好處是我們可以延遲計算,如果不使用函數語言程式設計,那麼我們需要在呼叫delay_sum這個函式的時候就計算出結果。如果這個運算量很小還好,如果這個運算量很大,就會造成開銷。並且當我們計算出結果來之後,這個結果也許不是立即使用的,可能到很晚才會用到。既然如此,我們返回一個函式代替了運算,當後面真正需要用到的時候再執行結果,從而延遲了運算。這也是很多計算框架的常用思路,比如spark

閉包

我們再來回顧一下我們剛才舉的例子,在剛才的delay_sum函式當中,我們內部實現了一個sum函式,我們在這個函式當中呼叫了delay_sum函式傳入的引數。這種對外部作用域的變數進行引用的內部函式就稱為閉包

其實這個概念很形象,因為這個函式內部呼叫的資料對於呼叫方來說是封閉的,完全是一個黑盒,除非我們檢視原始碼,否則我們是不知道它當中資料的來源的。除了不知道來源之外,更重要的是它引用的是外部函式的變數,既然是變數就說明是動態的。也就是說我們可以通過改變某些外部變數的值來改變閉包的執行效果

這麼說有點拗口,我們來看一個簡單的例子。在Python當中有一個函式叫做math.pow其實就是計算次方的。比如我們要計算x的平方,那麼我們應該這樣寫:

math.pow(x, 2)
複製程式碼

但是如果我們當前場景下只需要計算平方,我們每次都要傳入額外再傳入一個2會顯得非常麻煩,這個時候我們使用閉包,可以簡化操作:

def mypow(num):
    def pw(x):
        return math.pow(x, num)
    return pw
    
pow2 = mypow(2)
print(pow2(10))
複製程式碼

通過閉包,我們把第二個變數給固定了,這樣我們只需要使用pow2就可以實現原來math.pow(x, 2)的功能了。如果我們突然需求變更需要計算3次方或者是4次方,我們只需要修改mypow的傳入引數即可,完全不需要修改程式碼。

實際上這也是閉包最大的使用場景,我們可以通過閉包實現一些非常靈活的功能,以及通過配置修改一些功能等操作,而不再需要通過程式碼寫死。要知道對於工業領域來說,線上的程式碼是不能隨便變更的,尤其是客戶端,比如apple store或者是安卓商店當中的軟體包,只有使用者手動更新才會拉取。如果出現問題了,幾乎沒有辦法修改,只能等使用者手動更新。所以常規操作就是使用一些類似閉包的靈活功能,通過修改配置的方式改變程式碼的邏輯

除此之外閉包還有一個用處是可以暫存變數或者是執行時的環境

舉個例子,我們來看下面這段程式碼:

def step(x=0):
    x += 5
    return x
複製程式碼

這是沒有使用閉包的函式,不管我們呼叫多少次,答案都是5,執行完x+=5之後的結果並不會被儲存起來,當函式返回了,這個暫存的值也就被拋棄了。那如果我希望每次呼叫都是依據上次呼叫的結果,也就是說我們每次修改的操作都能儲存起來,而不是丟棄呢?

這個時候就需要使用閉包了:

def test(x=0):
    def step():
        nonlocal x
        x += 5
        return x
    return step
    
t = test()
t()
>>> 5
t()
>>> 10
複製程式碼

也就是說我們的x的值被儲存起來了,每次修改都會累計,而不是丟棄。這裡需要注意一點,我們用到了一個新的關鍵字叫做nonlocal,這是Python3當中獨有的關鍵字,用來申明當前的變數x不是區域性變數,這樣Python直譯器就會去全域性變數當中去尋找這個x,這樣就能關聯上test方法當中傳入的引數x。Python2官方已經不更新了,不推薦使用。

由於在Python當中也是一切都是物件,如果我們把閉包外層的函式看成是一個類的話,其實閉包和類區別就不大了,我們甚至可以給閉包返回的函式關聯函式,這樣幾乎就是一個物件了。來看一個例子:

def student():
    name = 'xiaoming'
    
    def stu():
        return name
        
    def set_name(value):
        nonlocal name
        name = value
        
    stu.set_name = set_name
    return stu
    
stu = student()
stu.set_name('xiaohong')
print(stu())
複製程式碼

最後運算的結果是xiaohong,因為我們呼叫set_name改變了閉包外部的值。這樣當然是可以的,但是一般情況下我們並不會用到它。和寫一個class相比,通過閉包的方法運算速度會更快。原因比較隱蔽,是因為閉包當中沒有self指標,從而節省了大量的變數的訪問和運算,所以計算的速度要快上一些。但是閉包搞出來的偽物件是不能使用繼承、派生等方法的,而且和正常的用法格格不入,所以我們知道有這樣的方法就可以了,現實中並不會用到。

閉包的坑

閉包雖然好用,但是不小心的話也是很容易踩坑的,下面介紹幾個常見的坑點。

閉包不能直接訪問外部變數

這一點我們剛才已經提到了,在閉包當中我們不能直接訪問外部的變數的,必須要通過nonlocal關鍵字進行標註,否則的話是會報錯的。

def test():
    n = 0
    def t():
        n += 5
        return n
    return t
複製程式碼

比如這樣的話,就會報錯:

閉包當中不能使用迴圈變數

閉包有一個很大的問題就是不能使用迴圈變數,這個坑藏得很深,因為單純從程式碼的邏輯上來看是發現不了的。也就是說邏輯上沒問題的程式碼,執行的時候往往會出乎我們的意料,這需要我們對底層的原理有深刻地瞭解才能發現,比如我們來看一個例子:

def test(x):
    fs = []
    for i in range(3):
        def f():
            return x + i
        fs.append(f)
    return fs


fs = test(3)
for f in fs:
    print(f())
複製程式碼

在上面這個例子當中,我們使用了for迴圈來建立了3個閉包,我們使用fs儲存這三個閉包並進行返回。然後我們通過呼叫test,來獲得了這3個閉包,然後我們進行了呼叫。

這個邏輯看起來應該沒有問題,按照道理,這3個閉包是通過for迴圈建立的,並且在閉包當中我們用到了迴圈變數i。那按照我們的想法,最終輸出的結果應該是[3, 4, 5],但是很遺憾,最後我們得到的結果是[5, 5, 5]

看起來很奇怪吧,其實一點也不奇怪,因為迴圈變數i並不是在建立閉包的時候就set好的。而是當我們執行閉包的時候,我們再去尋找這個i對應的取值,顯然當我們執行閉包的時候,迴圈已經執行完了,此時的i停在了2。所以這3個閉包的執行結果都是2+3也就是5。這個坑是由Python直譯器當中對於閉包執行的邏輯導致的,我們編寫的邏輯是對的,但是它並不按照我們的邏輯來,所以這一點要千萬注意,如果忘記了,想要通過debug查詢出來會很難。

總結

雖然從表面上閉包存在一些問題和坑點,但是它依然是我們經常使用的Python高階特性,並且它也是很多其他高階用法的基礎。所以我們理解和學會閉包是非常有必要的,千萬不能因噎廢食。

其實並不只是閉包,很多高度抽象的特性都或多或少的有這樣的問題。因為當我們進行抽象的時候,我們固然簡化了程式碼,增加了靈活度,但與此同時我們也讓學習曲線變得陡峭,帶來了更多我們需要理解和記住的內容。本質上這也是一個trade-off,好用的特性需要付出程式碼,易學易用的往往意味著比較死板不夠靈活。對於這個問題,我們需要保持心態,不過好在初看時也許有些難以理解,但總體來說閉包還是比較簡單的,我相信對你們來說一定不成問題。

好了,今天的文章就是這些,如果覺得有所收穫,請順手點個關注或者轉發吧,你們的舉手之勞對我來說很重要。

Python——五分鐘理解函數語言程式設計與閉包

相關文章