搞懂 Lexical scoping 與 Dynamic scoping 的差異與用途

「阿母啊!我終於搞懂 Lexical Scoping 是啥啦~!」

Lexical scoping 與 Dynamic scoping 我以前一直嘗試想要弄懂但卻一直搞不懂,就算一時看懂了也不知這到底在幹麻或能幹麻所以每次看完就忘記(用不到的東西都記不住的啦),但最近因為 Lisp 寫得比較熟悉了,開始狂用 functional programming 的一些技巧後才終於了解其中奧妙,尤其是 lexical scoping 的好用之處,以及為什麼王垠會幹譙 dynamic scoping。希望這篇筆記可以幫助到一些跟我一樣搞不清楚狀況的人搞懂並記住兩者差異,尤其是如何善用 lexical scoping 的特性做一些神奇的功能。

  • 採用 lexical scoping 的語言:JavaScript、Scheme、Common Lisp
  • 採用 dynamic scoping 的語言:Emacs Lisp(預設行為)、Lisp Machine Lisp。

關於 Emacs Lisp 的 Lexical Scoping

Emacs Lisp 在 Emacs 24.3 開始也支援 lexical scoping 了,然而它預設仍然是 dynamic scoping。如果需要使用 lexical scoping。必須在你寫的 el 檔案的第一行內加入這句咒語「-*- lexical-binding: t; -*-」,例如:

1
;;; hexo.el --- Major mode & tools for Hexo      -*- lexical-binding: t; -*-

這句咒語的作用技術上來說,Emacs 的設計是:當 lexical-binding 這個 buffer local 變數的值是 non-nil 時,該 el 檔案/buffer 就會以 lexical scoping 去 interpret Emacs Lisp。而-*- lexical-binding: t; -*-的作用其實就是自動在你打開這個檔案、或者 interpreter 在 eval 這個檔案時自動幫你(setq-local lexical-binding t)而已。

要記住用身體記住最快,所以如果可以建議以下整個自己開個 Emacs 跑一遍。以下會拿 Emacs Lisp 當例子,而不是大家常用的 Scheme 或 JS。為什麼呢~~~因為 Emacs Lisp 是目前我所知道最詭異的語言,竟然同時存在 dynamic scoping 跟 lexical scoping 的執行方式……總而言之,寫 Emacs Lisp 你可以自己決定要用哪一種 scoping,這種情況拿來實驗 scoping 是非常容易理解的,因為你可以拿一樣的 code 放在不同的 scoping 下實際執行比較看看。

下面會大量用到 lambda。如果你不熟悉 Lisp 裡的 lambda ,請先讀過這篇Lisp 裡面的 Lambda 到底是什麼?

先從例子來談談 Dynamic Scoping

我們先用 Emacs 建立一個新檔案 test.el,注意行首不要加上 -*- lexical-binding: t; -*- ,這樣該檔案寫的 Emacs Lisp 都會是預設的 dynamic scoping。

首先,我們先寫出兩個非常簡單、功能完全相同的函數(只是一個有名字、一個是 lambda 匿名函數),這兩個函數都一樣接受 1 個參數 x,並回傳 (* x 3) 的值:

1
2
3
4
5
6
;; 匿名函數
(lambda (x) (* x 3))

;; 有名字的函數
(defun triple (x)
(* x 3))

這兩個函數都不會有任何問題,不管餵它幾次、不管在哪裡餵,只要餵的數字 x 一樣,他永遠會給你正確答案,也就是 x * 3:

1
2
3
4
5
6
7
;; 匿名函數
((lambda (x) (* x 3)) 7)
;; => 得到 21

;; 有名字的函數
(triple 7)
;; => 一樣也是 21

到這裡為止都很單純。

加上自由變量…Dynamic Scoping 開始麻煩了

讓我們讓函數定義變得詭異起來:

1
2
3
4
5
6
7
;; 匿名函數
(let ((a 3))
((lambda (x) (* a x)) 7))
;; => 成功輸出 21

((lambda (x) (* a x)) 7)
;; => 炸掉啦!Debugger entered--Lisp error: (void-variable a)

注意第一個 lambda 內的那個 a,它的值 3 是從外部的 let 得來的,而不是一開始就把 3 這個值寫死在 lambda 中。

所以第二個 lambda 在被呼叫時立刻炸掉,因為它只知道 x7 ,卻不知道 a 是多少。

這段 code 沒有任何副作用與 assignment,所以應該很好懂吧?那現在我們來看有名子的函數是不是也是這樣。我們再用類似的環境條件重新 defun 一次 triple 這個函數:

1
2
3
4
5
6
7
;; 有名字的函數
(let ((a 3))
(defun triple (x)
(* a x))
(triple 7)) ;; 這個 (triple 7) 是在 let 中呼叫,可以正常運作,得到 21

(triple 7) ;; 炸掉啦!Debugger entered--Lisp error: (void-variable a)

可以理解嗎?Emacs Lisp interpreter 在第二次呼叫 triple 時告訴你「變數 a 尚未定義」,這就是你的 function 內有自由變數時,dynamic scoping 會遇到的麻煩。

在這個例子中,a 就是一個自由變數。

let 裡面 defun 時,function 的定義中包含了 a 這個自由變數。在 dynamical scoping 下,定義該 function 時只會保留一個 reference a,但並「不會」捕捉這個 a 實際的值 3,。

所以在 let 裡呼叫 (triple 7) 不會有問題(因為呼叫 (triple 7) 時,triple 知道他裡面的 a 其實是 3),然而一旦跑到 let 外面,呼叫 (triple 7) 時,triple 不知道他裡面的 a 到底是什麼,於是就炸掉了:「變數 a 尚未定義」。

這就是 dynamical scoping 的問題所在。你定義的 function 很有可能放在不同的地方執行起來就得到不同的結果。

Lexical Scoping 與 Closure

我們現在來看看 lexical scoping。在我們剛才的 test.el 的第一行(一定要在第一行!)尾端加上 -*- lexical-binding: t; -*- 後,存檔,重開 test.el。現在,這個檔案中寫的 Emacs Lisp 會變成以 lexical scoping 的方式來執行。

Lambda 變成怎樣?

再嘗試一次 defun 之前,來看看熟悉的 lambda 現在變成什麼樣子。輸入下面這行,按 C-x C-e eval 它:

1
2
(lambda (x) (* 3 x))
;; => (closure (t) (x) (* 3 x))

minibuffer 竟然變成奇怪的 (closure (t) (x) (* 3 x)) 而不是原本的 (lambda (x) (* 3 x)),這代表有成功啟動 lexical scoping。

在 Lexical Scoping 下再實驗一次之前會炸掉的東西

1
2
3
4
5
6
7
;; 匿名函數
(let ((a 3))
((lambda (x) (* a x)) 7))
;; => 一樣成功輸出 21

((lambda (x) (* a x)) 7)
;; => 當然一樣炸掉啦!因為周圍根本沒有 a 的值可以參考嘛。

到這裡為止都是一樣的,但接下來用 defun ,細微的差異就出現了:

1
2
3
4
5
6
7
;; 有名字的函數
(let ((a 3))
(defun triple (x)
(* a x))
(triple 7)) ;; 這個 (triple 7) 是在 let 中呼叫,跟前面的例子一樣,可以正常運作,得到 21

(triple 7) ;; 注意!這次竟然沒炸掉!還正常運作,得到 21

這裡就是 lexical scoping 與 closure 的威力展現之時了。

Lexical Scoping 與 Closure

Closure 中文翻譯成「閉包」,可以想像一下,他會把自由變數給捕捉並「包」起來

這裡要先離題一下…之前 這篇 裡面提過,Emacs Lisp 是屬於 LISP-2,他的 function 跟 variable 是放在不同的 namespace 下,所以要取得一個已經定義的函數的定義不像 Scheme 或 JS 那樣簡單,必須要用 symbol-function 來強制取得。總之…還是順便提一下免得看不懂。

到底閉包長什麼樣呢?使用 symbol-function 來看看我們剛才新定義的 triple 實際長什麼樣子:

1
2
3
(symbol-function 'triple)
;; 得到 =>
(closure ((a . 3) t) (x) (* a x))

這個 closure 就是關鍵之處了。注意那個 (a . 3) ,這就是在 let 中被 closure 所捕捉到的自由變數 a,所以就算定義該 function 時,裡面包含了自由變數,也會被 closure 給完美地捕捉並「包」起來。就算往後呼叫 triple 時,全域或區域變數中都沒有 a 也沒關係,因為 closure 裡面捕捉到一個 a 了。 lambda 在呼叫時,如果自己裡面找不到 a ,會先往上一層找,就會找到被 closure 所捕捉到的 a。再也不用擔心定義 function 會炸掉,真是太完美了!

所以,為什麼會叫做「lexical scoping(詞法作用域)」,因為有了 closure,在程式設計師的眼裡,在寫一段 code 時,該段 code 的 scope 只要管「該段 code 寫在 code 裡的上下文」,而不用費神去管「該段 code 在執行時的上下文(例如 function 定義時其中包含了執行時如果沒寫好就可能會變來變去的全域變數)」,所以 lexical scoping 也被稱為「static scoping(靜態作用域)」,而相對的,沒有 closure 的 scoping 就叫做「動態作用域(dynamic scoping)」。

Lexical Scoping 用途範例

一開始我也想說「我又不會無聊到故意寫個 let 還在裡面定義 function,lexical scoping 到底有什麼強的?」,後來才發現它真的很好用。這裡就舉個簡單的例子來談 lexical scoping 的實際用途。

案例:「產生匿名函數」的函數

這是我自己在寫 code 時遇到的情況,我覺得拿來解釋 scoping 很容易理解。

有寫 Lisp 的應該都很熟悉 (member ELT LIST) 這個函數。他可以檢查 ELT 是否在 LIST 之中 ,如果是,回傳該 ELTLIST 上的位置數起的 cdr,否則回傳 nil

1
2
3
4
(member ELT LIST)

Return non-nil if ELT is an element of LIST. Comparison done with `equal'.
The value is actually the tail of LIST whose car is ELT.

簡單的說,我們現在要做的就是「動態產生 member 函數」:

  1. 定義一個 function 叫做 generate-member-decider
  2. generate-member-decider 他的功能是接受 1 個型態為 list 的 arg,並回傳一個匿名 function lambda
  3. 這個 lambda 功能則是接受 1 個 arg,並判斷這個 arg 是否屬於剛才 list 內的一員。
1
2
3
4
5
6
7
8
9
(defun generate-member-decider (list)
(lambda (x) (member x list)))

;; 產生 fruit? 這個會判斷是否為水果的 function
(setq fruit? (generate-member-decider '("apple" "banana" "citrus" "durian")))
;; 執行看看
(funcall fruit? "cucumber") ;; => nil
(funcall fruit? "cat") ;; => nil
(funcall fruit? "citrus") ;; => ("citrus" "durian")

看懂了吧?這種寫法在 dynamic scoping 裡面一定炸掉的。

在 functional programming 的世界裡,這種產生 function 的 function 只要會用的話真的很好用啊!

最後我一定要特別提一下,Emacs Lisp 裡面有提供一個東西叫做lexical-let,這玩意…其實是以前 Emacs Lisp 不支援 dynamic scoping 時,用一堆奇技淫巧來「模擬」出來的 lexical scoping 版的 let。不要用它,你會很混亂。

更深入的 Closure 用途探討

Scheme 是第一個實做 closure 的語言,一開始是為了解答「要如何給 Lisp 加上物件導向的功能」。如果你有興趣,可以讀讀 Closures And Objects Are Equivalent ,只靠著 closure 的威力,要怎麼用 Scheme 的簡潔語法搞出物件導向來。