1. 程式人生 > >一文搞懂:詞法作用域、動態作用域、回撥函式、閉包

一文搞懂:詞法作用域、動態作用域、回撥函式、閉包

不管什麼語言,我們總要學習作用域(或生命週期)的概念,比如常見的稱呼:全域性變數、包變數、模組變數、本地變數、區域性變數等等。不管如何稱呼這些作用域的範圍,實現它們的目的都一樣:

  • (1)為了避免名稱衝突;
  • (2)為了限定變數的生命週期(本文以變數名說事,其它的名稱在規則上是一樣的)。

但是不同語言的作用域規則不一樣,雖然學個簡單的基礎就足夠應用,因為我們有程式設計規範:(1)儘量避免名稱衝突;(2)加上類似於local的修飾符儘量縮小生效範圍;(3)放進程式碼塊,等等。但是真正去細心驗證作用域的生效機制卻並非易事(我學Python的時候,花了很長時間細細驗證,學perl的時候又花了很長時間細細驗證),但可以肯定的是,理解本文的詞法作用域規則(Lexical scoping)和動態作用域規則(dynamic scoping),對學習任何語言的作用域規則都有很大幫助,這兩個規則是各種語言都巨集觀通用的。

很簡單的一段bash下的程式碼:

x=1
function g(){ echo "g: $x" ; x=2; }
function f(){ local x=3 ; g; echo "f: $x"; } # 輸出2還是3
f           # 輸出1還是3?
echo $x     # 輸出1還是2?

對於bash來說,上面輸出的分別是3(g函式中echo)、2(f函式中的echo)和1(最後一行echo)。但是同樣語義的程式碼在其它語言下得到的結果

可能就不一樣(分別輸出1、3和2,例如perl中將local替換為my)。

這牽扯到兩種貫穿所有程式語言的作用域概念:詞法作用域(類似於C語言中static)和動態作用域

。詞法作用域和"詞法"這個詞真的沒什麼關係,反而更應該稱之為"文字段作用域"。要區別它們,只需要回答"函式out_func中巢狀的內層函式in_func能否看見out_func中的環境"。

對於上面的bash程式碼來說,假如這段程式碼是適用於所有語言的虛擬碼:

  • 對於詞法作用域的語言,執行f時會呼叫g,g將無法訪問f文字段的變數,詞法作用域認為g並不是f的一部分,而是跳出f的,因為g的定義文字段是在全域性範圍內的,所以它是全域性文字段的一部分。如果函式g的定義文字段是在f內部,則g屬於f文字段的一部分
    • 所以g不知道f文字段中local x=3的設定,於是g的echo會輸出全域性變數x=1
      ,然後設定x=2,因為它沒有加上作用域修飾符,而g又是全域性內的函式,所以x將修改全域性作用域的x值,使得最後的echo輸出2,而f中的echo則輸出它自己文字段中的local x=3。所以整個流程輸出1 3 2
  • 對於動態作用域的語言,執行f時會呼叫g,g將可以訪問f文字中的變數,動態作用域認為g是f文字段的一部分,是f中的巢狀函式
    • 所以g能看到local x=3的設定,所以g的echo會輸出3。g中設定x=2後,僅僅只是在f的內層巢狀函式中設定,所以x=2對g文字段和f文字段(因為g是f的一部分)都可見,但對f文字段外部不可見,所以f中的echo輸出2,最後一行的echo輸出1。所以整個流程輸出3 2 1
  • 總結來說:
    • 詞法作用域是關聯在編譯期間的,對於函式來說就是函式的定義文字段的位置決定這個函式所屬的範圍
    • 動態作用域是關聯在程式執行期間的,對於函式來說就是函式執行的位置決定這個函式所屬的範圍

由於bash實現的是動態作用域規則。所以,輸出的是3 2 1。對於perl來說,my修飾符實現詞法作用域規則,local修飾符實現動態作用域規則。

例如,使用my修飾符的perl程式:

#!/usr/bin/perl

$x=1;
sub g { print "g: $x\n"; $x=2; }
sub f { my $x=3; g(); print "f: $x\n"; }  # 詞法作用域
f(); 
print "$x\n"; 

執行結果:

[[email protected]:/perlapp]$ perl scope2.pl 
g: 1
f: 3
2

使用local修飾符的perl程式:

#!/usr/bin/perl

$x=1;
sub g { print "g: $x\n"; $x=2; }
sub f { local $x=3; g(); print "f: $x\n"; }  # 動態作用域
f(); 
print "$x\n"; 

執行結果:

[[email protected]:/perlapp]$ perl scope2.pl 
g: 3
f: 2
1

有些語言只支援一種作用域規則,特別是那些比較現代化的語言,而有些語言支援兩種作用域規則(正如perl語言,my實現詞法變數作用域規則,local實現動態作用域規則)。相對來說,詞法作用域規則比較好控制整個流程,還能借此實現更豐富的功能(如最典型的"閉包"以及高階函式),而動態作用域由於讓變數生命週期"沒有任何深度"(回想一下shell指令碼對函式和作用域的控制,非常傻瓜化),比較少應用上,甚至有些語言根本不支援動態作用域。

閉包和回撥函式

理解閉包、回撥函式不可不知的術語

1.引用(reference):資料物件和它們的名稱

前文所說的可見、不可見、變數是否存在等概念,都是針對變數名(或其它名稱,如函式名、列表名、hash名)而言的,和變數的值無關。名稱和值的關係是引用(或指向)關係,賦值的行為就是將值所在的資料物件的引用(指標)交給名稱,讓名稱指向這個記憶體中的這個資料值物件。如下圖:

2.一級函式(first-class functions)和高階函式(high-order functions)

有些語言認為函式就是一種型別,稱之為函式型別,就像變數一樣。這種型別的語言可以:

  1. 將函式賦值給某個變數,那麼這個變數就是這個函式體的另一個引用,就像是第二個函式名稱一樣。通過這個函式引用變數,可以找到函式體,然後呼叫執行。
    • 例如perl中$ref_func=\&myfunc表示將函式myfunc的引用賦值給$ref_func,那麼$ref_func也指向這個函式。
  2. 將函式作為另一個函式的引數。例如兩個函式名為myfunc和func1,那麼myfunc(func1)就將func1作為myfunc的引數。
    • 這種行為一般用於myfunc函式中對滿足某些邏輯的東西執行func1函式。
    • 舉個簡單的例子,unix下的find命令,將find看作是一個函式,它用於查詢指定路徑下符合條件的檔名,將-print-exec {}\;選項實現的功能看作是其它的函式(請無視它是否真的是函式),這些選項對應的函式是find函式的引數,每當find函式找到符合條件的檔名時,就執行-print函式輸出這個檔名
  3. 函式的返回值也可以是另一個函式。例如myfunc函式的定義語句為function myfunc(){ ...return func1 }

其實,實現上面三種功能的函式稱之為一級函式或高階函式,其中高階函式至少要實現上面的2和3。一級函式和高階函式並沒有區分的必要,但如果一定要區分,那麼:

  • 一級函式更像是一種術語概念,它將函式當作一種值看待,可以將其賦值出去、作為引數傳遞出去以及作為返回值,對於計算機程式語言而言,它更多的是用來描述某種語言是否支援一級函式;
  • 高階函式是一種函式型別,就像回撥函式一樣,當某個函式符合高階函式的特性,就可以將其稱之為這是一個高階函式。

3.自由變數(free variable)和約束變數(bound variable)

這是一組數學界的術語。

在計算機程式語言中,自由變數是指函式中的一種特殊變數,這種變數既不在本函式中定義,也不是本函式的引數。換句話說,可能是外層函式中定義的但卻在內層函式中使用的,所以自由變數常常和"非本地變數"(non-local variable,熟悉Python的人肯定知道)互用。例如:

function func1(x){
    var z;
    function func2(y){
        return x+y+z     # x和z既不是func2內部定義的,也不是func2的引數,所以x和z都是自由變數
    }
    return func1
}

自由變數和約束變數對應。所謂約束變數,是指這個變數之前是自由變數,但之後會對它進行賦值,將自由變數繫結到一個值上之後,這個變數就成為約束變數或者稱為繫結變數。

例如:

function func1(x){
    var m=20     # 對func2來說,這是自由變數,對其賦值,所以m變成了bound variable
    var z
    function func2(y){
        z=10       # 對自由變數z賦值,z變成bound variable
        return m+x+y+z     # m、x和z都是自由變數
    }
    return func1
}

ref_func=func1(3)       # 對x賦值,x變成bound variable

回撥函式

回撥函式一開始是C裡面的概念,它表示的是一個函式:

  • 可以訪問另一個函式
  • 當這個函式執行完了,會執行另一個函式

也就是說,將一個函式(B)作為引數傳遞給另一個函式(A),但A執行完後,再自動呼叫B。所以這種回撥函式的概念也稱為"call after"。

但是現在回撥函式已經足夠通用化了。通用化的回撥函式定義為:將函式B作為另一個函式A的引數,執行到函式A中某個地方的時候去呼叫B。和原來的概念相比,不再是函式A結束後再呼叫,而是我們自己定義在哪個地方呼叫。

例如,Perl中的File::Find模組中的find函式,通過這個函式加上回調函式,可以實現和unix find命令相同的功能。例如,搜尋某個目錄下的檔案,然後print輸出這個檔名,即find /path xxx -print

#!/usr/bin/perl
use File::Find;

sub print_path {         # 定義一個函式,用於輸出路徑名稱
    print "$File::Find::name\n";
}

$callback = \&print_path;  # 建立一個函式引用,名為$callback,所以perl是一種支援一級函式的語言

find( $callback,"/tmp" );  # 查詢/tmp下的檔案,每查詢到一個檔案,就執行一次$callback函式

這裡傳遞給find函式的$callback就是一個回撥函式。幾個關鍵點:

  • $callback作為引數傳遞給另一個find()函式(所以find()函式是一個高階函式)
  • 在find()函式中,每查詢到一個檔案,就呼叫一次這個$callback函式。當然,如果find是我們自己寫的程式,就可以由我們自己定義在什麼地方去呼叫$callback
  • $callback不是我們主動呼叫的,而是由find()函式在某些情況下(每查詢到一個檔案)去呼叫的

回撥就像對函式進行填空答題一樣,根據我們填入的內容去複用填入的函式從而實現某一方面的細節,而普通函式則是定義了就只能機械式地複用函式本身。

之所以稱為回撥函式,是因為這個函式並非由我們主觀地、直接地去呼叫,而是將函式作為一個引數,通過被呼叫者間接去呼叫這個函式引數。本質上,回撥函式和一般的函式沒有什麼區別,可能只是因為我們定義一個函式,卻從來沒有直接呼叫它,這一點感覺上有點奇怪,所以有人稱之為"回撥函式",用來統稱這種間接的呼叫關係。

回撥函式可以被多執行緒非同步執行。

徹底搞懂閉包

計算機中的閉包概念是從數學世界引入的,在計算機程式語言中,它也稱為詞法閉包、函式閉包。

閉包簡單的、通用的定義是指:函式引用一個詞法變數,在函式或語句塊結束後(變數的名稱消失),詞法變數仍然對引用它的函式有效。在下一節還有關於閉包更嚴格的定義(來自wiki)。

看一個python示例:函式f中嵌套了函式g,並返回函式g

def f(x):
    def g(y):
        return x + y
    return g  # 返回一個閉包:有名稱的函式(高階函式的特性)

# 將執行函式時返回的閉包函式賦值給變數(高階函式的特性)
a = f(1)

# 呼叫儲存在變數中閉包函式
print (a(5))

# 無需將閉包儲存進臨時變數,直接一次性呼叫閉包函式
print( f(1)(5) )   # f(1)是閉包函式,因為沒有將其賦值給變數,所以f(1)稱為"匿名閉包"

上面的a是一個閉包,它是函式g()的一個例項。f()的引數x可以被g訪問,在f()返回g函式後,f()就退出了,隨之消失的是變數名x(注意是變數名稱x,變數的值在這裡還不一定會消失)。當將閉包f(1)賦值給a後,原來x指向的資料物件(即數值1)仍被a指向的閉包函式引用著,所以x對應的值1在x消失後仍儲存在記憶體中,只有當名為a的閉包被消除後,原來x指向的數值1才會消失。

閉包特性1:對於返回的每個閉包g()來說,不同的g()引用不同的x對應的資料物件。換句話說,變數x對應的資料物件對每個閉包來說都是相互獨立的

例如下面得到兩個閉包,這兩個閉包中持有的自由變數雖然都引用相等的數值1,但兩個數值是不同資料物件,這兩個閉包也是相互獨立的:

a=f(1)
b=f(1)

閉包特性2:對於某個閉包函式來說,只要這不是一個匿名閉包,那麼閉包函式可以一直訪問x對應的資料物件,即使名稱x已經消失

但是

a=f(1)      # 有名稱的閉包a,將一直引用數值物件1
a(3)        # 呼叫閉包函式a,將返回1+3=4,其中1是被a引用著的物件,即使a(3)執行完了也不放開
a(3)        # 再次呼叫函式a,將返回4,其中1和上面一條語句的1是同一個資料物件
f(1)(3)     # 呼叫匿名的閉包函式,資料物件1在f(1)(3)執行完就消失
f(1)(3)     # 呼叫匿名的閉包函式,和上面的匿名閉包是相互獨立的

最重要的特性就在於上面執行的兩次a(3):將詞法變數的生命週期延長,但卻足夠安全

看下面perl程式中的閉包函式,可以更直觀地看到結果。

sub how_many {       # 定義函式
    my $count=2;     # 詞法變數$count
    return sub {print ++$count,"\n"};  # 返回一個匿名函式,這是一個匿名閉包
}

$ref=how_many();    # 將閉包賦值給變數$ref

how_many()->();     # (1)呼叫匿名閉包:輸出3
how_many()->();     # (2)呼叫匿名閉包:輸出3
$ref->();           # (3)呼叫命名閉包:輸出3
$ref->();           # (4)再次呼叫命名閉包:輸出4

上面將閉包賦值給$ref,通過$ref去呼叫這個閉包,則即使how_many中的$count在how_many()執行完就消失了,但$ref指向的閉包函式仍然在引用這個變數,所以多次呼叫$ref會不斷修改$count的值,所以上面(3)和(4)先輸出3,然後輸出改變後的4。而上面(1)和(2)的輸出都是3,因為兩個how_many()函式返回的是獨立的匿名閉包,在語句執行完後資料物件3就消失了。

閉包更嚴格的定義

注意,嚴格定義的閉包和前面通俗定義的閉包結果上是不一樣的,通俗意義上的閉包並不一定符合嚴格意義上的閉包。

關於閉包更嚴格的定義,是一段誰都看不懂的說明(來自wiki)。如下,幾個關鍵詞我加粗顯示了,因為重要。

閉包是一種在支援一級函的程式語言中能夠將詞法作用域中的變數名稱進行繫結的技術。在操作上,閉包是一種用於儲存函式和環境的記錄。這個環境記錄了一些關聯性的對映,將函式的每個自由變數與建立閉包時所繫結名稱的值或引用相關聯通過閉包,就算是在作用域外部呼叫函式,也允許函式通過閉包拷貝他們的值或通過引用的方式去訪問那些已經被捕獲的變數

我知道這段話誰都看不懂,所以簡而言之一下:一個函式例項和一個環境結合起來就是閉包。這個所謂的環境,決定了這個函式的特殊性,決定了閉包的特性。

還是上面的python示例:函式f中嵌套了函式g,並返回函式g

def f(x):
    def g(y):
        return x + y
    return g  # 返回一個閉包:有名稱的函式

# 將執行函式時返回的閉包函式賦值給變數
a = f(1)

上面的a是一個閉包,它是函式g()的一個例項。f()的引數x可以被g訪問,對於g()來說,這個x不是g()內部定義的,也不是g()的引數,所以這個x對於g來說是一個自由變數(free variable)。雖然g()中持有了自由變數,但是g()函式自身不是閉包函式,只有在g持有的自由變數x和傳遞給f()函式的x的值(即f(1)中的1)進行繫結的時候,才會從g()建立一個閉包函式,這表示閉包函式開始引用這個自由變數,並且這個閉包一直持有這個變數的引用,即使f()已經執行完畢了。然後在f()中return這個閉包函式,因為這個閉包函式綁定了(引用)自由變數x,這就是閉包函式所在的環境。

環境對閉包來說非常重要,是區別普通函式和閉包的關鍵。如果返回的每個閉包不是獨立持有屬於自己的自由變數,而是所有閉包都持有完全相同的自由變數,那麼閉包雖然仍可稱為閉包,但和普通函式卻沒有區別了。例如:

def f(x):
    x=3
    def g(y):
        return x + y
    return g

a = f(1)
b = f(3)

在上面的示例中,x雖然是自由變數,但卻在g()的定義之前就綁定了值(前文介紹過,它稱為bound variable),使得閉包a和閉包b持有的不再是自由變數,而是值物件完全相同的繫結變數,其值物件為3,a和b這個時候其實沒有任何區別(雖然它們是不同物件)。換句話說,有了閉包a就完全沒有必要再定義另一個功能上完全相同的閉包b。

在函式複用性的角度上來說,這裡的a和普通函式沒有任何區別,都只是簡單地複用了函式體。而真正嚴格意義上的閉包,除了複用函式體,還複用它所在的環境。

但是這樣一種情況,對於通俗定義的閉包來說,返回的g()也是一個閉包,但在嚴格定義的閉包中,這已經不算是閉包。

再看一個示例:將自由變數x放在g()函式定義文字段的後面。

def f(y):
    return x+y

x=1

def g(z):
    x=3
    return f(z)

print(g(1))   # 輸出2,而不是4

首先要說明的是,python在沒有給任何作用域修飾符的時候實現的詞法作用域規則,所以上面return f(z)中的f()看見的是全域性變數x(因為f()定義在全域性文字段中),而不是g()中的x=3。

回到閉包問題上。上面f()持有一個自由變數x,這個f(z)的文字定義段是在全域性文字段中,它繫結的自由變數x是全域性變數(宣告並初始化為空或0),但是這個變數之後賦值為1了。對於g()中返回的每個f()所在的環境來說,它持有的自由變數x一開始都是不確定的,但是後來都確定為1了。這種情況也不能稱之為閉包,因為閉包是在f()對自由變數進行繫結時建立的,而這個時候x已經是固定的值物件了。

回撥函式、閉包和匿名函式

回撥函式、閉包和匿名函式其實沒有必然的關係,但因為很多書上都將匿名函式和回撥函式、閉包放在一起解釋,讓人誤以為回撥函式、閉包需要通過匿名函式實現。實際上,匿名函式只是一個有函式定義文字段,卻沒有名稱的函式,而閉包則是一個函式的例項加上一個環境(嚴格意義上的定義)。

對於閉包和匿名函式來說,仍然以python為例:

def f(x):
    def g(y):
        return x + y
    return g    # 返回一個閉包:有名稱的函式

def h(x):
    return lambda y: x + y  # 返回一個閉包:匿名函式

# 將執行函式時返回的閉包函式賦值給變數
a = f(1)
b = h(1)

# 呼叫儲存在變數中閉包函式
print (a(5))
print (b(5))

對於回撥函式和匿名函式來說,仍然以perl的find函式為例:

#!/usr/bin/perl
use File::Find;

$callback = sub {
    print "$File::Find::name\n";
};  # 建立一個匿名函式以及它的引用

find( $callback,"/tmp" );  # 查詢/tmp下的檔案,每查詢到一個檔案,就執行一次$callback函式

匿名函式讓閉包的實現更簡潔,所以很多時候返回的閉包函式就是一個匿名函式例項。