無名

按語:我從懸崖上跳了下去,在除了墜落沒別的事可幹的過程中爲「不懂編程的人」寫了這一系列文章的第八篇,整理於此。它的前一篇是《周遊抑或毀滅世界》,講述了遞歸函數的基本用法。

警告,這一篇很長。若不能堅持看到最後,最好還是別看了。更何況,不看它,也沒啥損失。

下面這個函數,可以對自然數求和:

(defun sum (n)
  (if (= n 0)
      0
    (+ n (sum (- n 1)))))

傳說數學界的大宗師高斯同學上小學的時候,曾因飛快地算出從 1 到 100 的自然數之和而震驚四座。

在計算機裏不需要精巧,只需要笨拙,所以上述的 sum 函數就是從大到小,將數字逐一累加。例如:

(sum 100)

sum 這臺發動機周而復始的運轉中,每次都將自己的參數減去 1,然後與自己下一次的運行結果相加,最終發動機的運轉軌跡形成了 100 + 99 + ... + 1 + 0 這樣的表達式,當然在 Emacs Lisp 裏是這樣的:

(+ 100 (+ 99 (+ ... (+ 1 0) ...)))

sum 發現自己的參數爲 0 時,它就停止轉動,此時 Emacs Lisp 就得到了上述的表達式,接下來,Emacs Lisp 解釋器就開始對這個很長的表達式求值,不過就是逐一完成數字累加的這種粗笨的工作,結果爲 5050,跟高斯算出來的一樣,而且可能比高斯算得還要快許多倍。

不過,這篇文章不是再次重複發動機啊發動機啊的,而是開始思考怎麼去定義一個不知道名字的發動機。

再看一遍 sum 函數的定義:

(defun sum (n)
  (if (= n 0)
      0
    (+ n (sum (- n 1)))))

要讓一個發動機周而復始地運轉,必須得知道它的名字。有了名字,sum 函數方能在自己的定義中對自己進行求值。

於是,問題就來了。倘若宇宙的一切運動是由一臺發動機驅動起來的,那麼這臺發動機是如何運轉的呢?這臺發動機沒有名字,因爲它比我們這些命名者先出現。這臺發動機的存在,暗示着,沒有名字也能周而復始。

所謂函數,本質上就是值與值的映射,它有沒有名字,無關緊要,關鍵在於能否在不依賴名字的前提下精確描述映射關係。爲此,Emacs Lisp 語言提供了讓函數匿名的語法——Lambda 表達式。對於函數 f(x, y) = x + y,用 Lambda 表達式可表示爲:

(lambda (x y) (+ x y))

倘若 x = 1, y = 2,那麼如何通過這個 Lambda 表達式對 f(x, y) 進行求值呢?像下面這樣做:

(funcall (lambda (x y) (+ x y)) 1 2)

在 Emacs Lisp 程序中,需要藉助 funcall 方能對匿名函數進行求值。在其他的一些 Lisp 語言裏,可不必如此。沒錯,世界上不止一種 Lisp,Emacs Lisp 不過是 Lisp 語言世界裏的一種方言。在 Scheme 這種 Lisp 方言裏,對上述的匿名函數進行求值,只需:

((lambda (x y) (+ x y)) 1 2)

沒有名字,會讓世界變得更混亂一些,不過這反而是世界原本的樣子。從這個角度去探索世界的本原會更爲客觀,可能也更爲奇妙。

當我們仰望星空,俯瞰大地,感覺冥冥中有一種力量在驅動着或者支配着這個世界的運行,我們沒法給這種力量取名,即使取了名,似乎也沒有用,因爲沒法根據所取的名字去描述它的運作原理。智慧如老子,也只能勉強給這種力量取個名字,叫作「大」。大,就是沒有邊界。沒有邊界,就是非常遙遠。非常遙遠,就是自己的後腦勺——你向任何一個方向看去,距離你最遠的地方是你的後腦勺。

不能給這種力量取名,就沒法描述它的運作原理了嗎?倘若世界受一臺沒有名字的發動機的驅動而運轉,那麼我們隨便從這個世界裏找一臺發動機,然後利用匿名函數消除這臺發動機的名字,結果是不是就能夠切實感受到它的存在呢?

試試看:

(lambda (n)
  (if (= n 0)
      0
    (+ n (sum (- n 1)))))

很好,已經消除掉外層的 sum 了,但是裏面的 sum 怎麼消除?這需要藉助一個小技巧,即它雖然在那裏,但是我們可以裝作看不見它。就像房間裏有一個人,你不喜歡他,但是又沒有能力讓他消失,所以只好裝作沒看見他。每個人應該都具備這種技能。那麼,怎樣對上面這個匿名函數裏面的 sum 視而不見呢?把它當作房間裏一種沒有意義的物件就可以了,像下面這樣:

(lambda (thing)
  (lambda (n)
    (if (= n 0)
        0
      (+ n (thing (- n 1))))))

沒有意義,就是意義不確定。意義不確定的東西,就是變量。函數的參數是變量。所以,只需要將 sum 變成一個函數的參數,就相當於對它視而不見處了。

現在已經成功地將所有的 sum 消除了,我們得到了一個沒有名字的函數。不過,再仔細看一下,真的得到了一個沒有名字的函數嗎?在上述函數的定義中,(thing (- n 1)) 顯然是一個函數求值表達式,因此我們不經意間依然將 thing 當成了一個有名字的函數了。要徹底的消除名字,必須將 thing 當成一個匿名函數,對它進行求值要藉助 funcall,因此上述函數應當改成:

(lambda (thing)
  (lambda (n)
    (if (= n 0)
        0
      (+ n (funcall thing (- n 1))))))

注:在一些其他 Lisp 方言裏,例如 Scheme,不需要這一步。

現在算是徹底消除了名字。現在,我們將這個函數視爲我們所創造的第一個沒有名字的函數,但是爲了便於描述,姑且將其稱爲 X。對 X 進行求值,需要向它傳遞一個匿名函數,求值結果是一個匿名函數,就這麼奇怪。

X 能夠用於自然數序列的求和嗎?試試看:

(funcall (lambda (thing)
           (lambda (n)
             (if (= n 0)
                 0
               (+ n (funcall thing (- n 1)))))) ...)

注:SegmentFault 網站的 Markdown 解析器可能有 Bug。以單個字母 + 1 個空格開頭的文本,會被誤認爲是帶編號的列表,從而變成 1. ... ...

寫着寫着就發現,在省略號的地方寫不下去了,不知道該向這個函數傳遞什麼樣的參數。雖然我們很清楚,thing 應該是一個匿名函數,但是我們現在並沒有這個函數。因此,不妨試着隨便定義一個,將它作爲 thing 傳給 X,看看會發生什麼:

(funcall (lambda (thing)
           (lambda (n)
             (if (= n 0)
                 0
               (+ n (funcall thing (- n 1)))))) (lambda (m) m))

新定義的匿名函數就是 (lambda (m) m),這個函數什麼也沒做,就是把自己接受的參數作爲求值結果。將它作爲參數傳遞給 X 之後,X 的求值結果就是下面這個匿名函數:

(lambda (n)
  (if (= n 0)
      0
    (+ n (funcall (lambda (m) m) (- n 1)))))

倘若將 100 傳遞給這個匿名函數,即:

(funcall (lambda (n)
           (if (= n 0)
               0
             (+ n (funcall (lambda (m) m) (- n 1))))) 100)

結果得到 199,而不是 5050。看來這個匿名函數只能算 100 + 99。不要沮喪,因爲我們現在又得到了一個可以作爲 thing 傳給 X 的匿名函數了。把它傳給 X:

(funcall (lambda (thing)
           (lambda (n)
             (if (= n 0)
                 0
               (+ n (funcall thing (- n 1)))))) (lambda (m) m))

現在,得到的是能夠計算 100 + 99 + 98 的匿名函數。

是不是發現了一點玄機了?我們一開始隨便定義了一個匿名函數,把它傳給 X,結果得到了一個新的匿名函數,然後再將這個新的匿名函數傳遞給 X。

再試試用同樣的手法, 將能夠計算 100 + 99 + 98 的匿名函數傳給 X:

(funcall (lambda (thing)
           (lambda (n)
             (if (= n 0)
                 0
               (+ n (funcall thing (- n 1))))))         
         (funcall (lambda (thing)
           (lambda (n)
             (if (= n 0)
                 0
               (+ n (funcall thing (- n 1)))))) (lambda (m) m)))

結果就得到了一個能計算 100 + 99 + 98 + 97 的匿名函數。

繼續將能計算 100 + 99 + 98 + 97 的匿名函數傳給 X:

(funcall (lambda (thing)
           (lambda (n)
             (if (= n 0)
                 0
               (+ n (funcall thing (- n 1))))))
         (funcall (lambda (thing)
                    (lambda (n)
                      (if (= n 0)
                          0
                        (+ n (funcall thing (- n 1))))))
                  (funcall (lambda (thing)
                             (lambda (n)
                               (if (= n 0)
                                   0
                                 (+ n (funcall thing (- n 1)))))) (lambda (m) m))))

結果就得到了一個能計算 100 + 99 + 98 + 97 + 96 的匿名函數了。

只要你不怕麻煩,可以將上述過程繼續下去,最終 X 就能算出 100 + 98 + ... + 1 + 0。最後一次傳給 X 的匿名函數必定是體積極爲龐大的怪物。

爲了更便於理解,我們用 X 這個名字,將能計算 100 + 99 + 98 + 97 + 96 的匿名函數簡寫成:

(funcall X
         (funcall X
                  (funcall X (lambda (m) m))))

利用 X 生成匿名函數,再將這個匿名函數傳遞給 X,這個想法很好,只是在實現上有些愚蠢。這個過程類似於爲了實現 1 個發動機運轉 100 圈的效果,製造了 100 個發動機,讓它們的每一個只運轉一圈。即使這樣做,也不是太難,但是倘若要實現 1 個發動機轉無數圈的效果呢?好辦,製造無數個發動機,讓每一個只運轉一圈。倘若你真的這樣想,那就對了。製造無數個發動機,每一個只運轉一圈,這不就是相當於將 X 作爲參數傳給自己,然後讓它運轉一圈嗎?這個過程應該像下面這樣描述:

(funcall X X)

當然,在 X 的定義中,需要讓作爲參數的 X 運轉一圈,以便生成像 (lambda (m) m) 這樣的匿名函數。因此,將 X 的定義修改爲:

(lambda (thing)
  (lambda (n)
    (if (= n 0)
        0
      (+ n (funcall (funcall thing thing) (- n 1))))))

然後按照 (funcall X X) 這樣寫:

(funcall (lambda (thing)
           (lambda (n)
             (if (= n 0)
                 0
               (+ n (funcall (funcall thing thing) (- n 1))))))
         (lambda (thing)
           (lambda (n)
             (if (= n 0)
                 0
               (+ n (funcall (funcall thing thing) (- n 1)))))))

上述表達式的求值結果是一個匿名函數。個匿名函數是什麼呢,就是 sum 函數。不信的話,就用它來計算 0 到 100 的和:

(funcall (funcall (lambda (thing)
              (lambda (n)
                (if (= n 0)
                    0
                  (+ n (funcall (funcall thing thing) (- n 1))))))
            (lambda (thing)
              (lambda (n)
                (if (= n 0)
                    0
                  (+ n (funcall (funcall thing thing) (- n 1))))))) 100)

這個表達式看上去很複雜,把它理解爲 (funcall (funcall X X) 100) 會更清楚。這樣,我們就構造了一個比上文用 X 構造的匿名函數再傳給 X 的代碼簡潔了將近 100 倍的可對自然數序列求和的匿名函數。

看,我們沒有使用 defun,單純藉助匿名函數就實現了一個遞歸函數。這說明了什麼?這說明了,即使這個世界沒有任何名字,它依然能夠運轉,亦即世界只能感受或描述,而不能定義。

一個周而復始的發動機,它之所以能夠如此,不是因爲它有了名字,而是因爲它的本質在於將自己作爲參數傳遞給自己。這就是函數遞歸求值的本質。

雖然我說的天花亂墜,但上面那個表達式實際上是無法求值的,Emacs Lisp 解釋器會報錯,說 thing 符號作爲變量是無效的。這是因爲,Emacs Lisp 語言由於歷史太過於悠久,而 Lisp 的世界裏許多好東西出現的比較晚,爲了兼容過去的程序,導致 Emacs Lisp 解釋器不得不墨守成規。要讓上述表達式能夠正確求值,需要在它之前使用下面的語句開啓開啓詞法域模式:

(setq lexical-binding t)

Emacs Lisp 解釋器默認在動態作用域模式下工作,不能正確識別作爲參數傳遞的匿名函數,而詞法域模式卻可以。關於動態作用域與詞法域,以後再作介紹。

現在,我們僅實現了一個特定功能的匿名函數的遞歸求值,還沒有真正達成我們的目標,即尋找一個通用的匿名函數遞歸機制。只有這種發動機能夠驅動整個世界。不過,我們似乎已經有了一個正確的方向,現在要做的就是,繼續前進。

重新觀察 X:

(lambda (thing)
  (lambda (n)
    (if (= n 0)
        0
      (+ n (funcall (funcall thing thing) (- n 1))))))

位於內層的匿名函數是與特定功能相關的部分,我們可以用前文已經使用了多次的手法,將這部分代碼提出來,作爲一個單獨的匿名函數,稱之爲 F,然後想辦法將它作爲參數傳給 X 即可。

F 的定義如下:

(lambda (n)
    (if (= n 0)
        0
      (+ n (funcall (funcall thing thing) (- n 1)))))

這個定義是錯的,因爲 thing 原本是 X 的參數。之前, F 位於 X 內部,它認識 thing,現在它脫離了 X,就不認識它了。該怎麼辦呢?用老辦法,凡是自己不喜歡的或者不認識的,統統提升爲參數,裝作沒看見它們……於是,我們將 F 修改爲:

(lambda (thing)
  (lambda (n)
    (if (= n 0)
        0
      (+ n (funcall (funcall thing thing) (- n 1))))))

等會!這東西似曾相識,它不就是 X 麼?沒錯……X 的引力太大,F 似乎無法掙脫它而孤立地存在。

再仔細研究一下,F 之所以難以擺脫 X,主要是因爲 (funcall (funcall thing thing) (- n 1)) 的制約。這是個函數求值表達式,(funcall thing thing) 是一個函數,它接受一個參數。我們想辦法把它作爲 F 的參數如何?

試試看:

(lambda (thing*)
  (lambda (n)
    (if (= n 0)
        0
      (+ n (funcall thing* (- n 1))))))

爲了與 X 的參數 thing 有所區分,我特意將 F 的參數命名爲 thing*,而實際上,繼續用 thing 也沒事。

倘若我們對 F 像下面這樣求值:

(funcall (lambda (thing*)
           (lambda (n)
             (if (= n 0)
                 0
               (+ n (funcall thing* (- n 1))))))
         (lambda (m) (funcall (funcall thing thing) m)))

看上去有點亂,這樣看就比較清楚了:

(funcall F (lambda (m) (funcall (funcall thing thing) m)))

就是向 F 傳遞了一個具有 1 個參數的匿名函數,函數體中的 thing 是 X 的參數,所以我們可以將上述表達式扔到 X 的定義裏:

(lambda (thing)
  (funcall F
           (lambda (m) (funcall (funcall thing thing) m))))

看,現在 X 裏面不再有任何特定功能的代碼了。我們已經成功地將原先的自然數求和的代碼從 X 中分離了出來,這部分代碼構成了匿名函數 F。

現在,我們再用 (funcall X X) 的辦法對 X 進行求值,即:

(funcall (lambda (thing)
           (funcall F
                    (lambda (m) (funcall (funcall thing thing) m))))
         (lambda (thing)
           (funcall F
                    (lambda (m) (funcall (funcall thing thing) m)))))

這樣,就可以得到一個遞歸的匿名函數,而且這個匿名函數裏面沒有任何特定功能的代碼,它是一個通用的匿名遞歸函數。不過,現在它還不知道 F 是什麼。用老辦法,凡是你不喜歡的或者你不知道的,若你不想因爲它們而壞掉好心情,就將它們統統提升爲函數的參數:

(lambda (F)
  (funcall (lambda (thing)
             (funcall F
                      (lambda (m) (funcall (funcall thing thing) m))))
           (lambda (thing)
             (funcall F
                      (lambda (m) (funcall (funcall thing thing) m))))))

我們將這個函數稱爲 Y。

還記得上面所定義的含有自然數求和代碼的那個 F 嗎?

(lambda (thing*)
  (lambda (n)
    (if (= n 0)
        0
      (+ n (funcall thing* (- n 1))))))

以它爲參數,對 Y 進行求值,即

(funcall (lambda (F)
           (funcall (lambda (thing)
                      (funcall F
                               (lambda (m) (funcall (funcall thing thing) m))))
                    (lambda (thing)
                      (funcall F
                               (lambda (m) (funcall (funcall thing thing) m))))))
         (lambda (thing*)
           (lambda (n)
             (if (= n 0)
                 0
               (+ n (funcall thing* (- n 1)))))))

看着挺複雜,實際上不過是 (funcall Y F),求值結果是什麼呢?一個匿名的自然數序列求值函數。倘若不信,那麼用它算一下 0 到 100 的和:

(funcall (funcall (lambda (F)
                    (funcall (lambda (thing)
                               (funcall F
                                        (lambda (m) (funcall (funcall thing thing) m))))
                             (lambda (thing)
                               (funcall F
                                        (lambda (m) (funcall (funcall thing thing) m))))))
                  (lambda (thing*)
                    (lambda (n)
                      (if (= n 0)
                          0
                        (+ n (funcall thing* (- n 1))))))) 100)

結果爲 5050。

上述的匿名函數 Y,由於它所接受的參數是一個匿名函數。按照數學家們的說法,不管函數是不是匿名的,只要是參數爲函數的函數,就叫算子。所以,函數 Y 應該叫 Y 算子,當然更多的人願意稱它爲 Y 組合子,並將這個算子視爲匿名函數世界中的神蹟。發現這個算子的那個數學家,將這個算子的數學公式 Y = λf. (λx. f(x x)) (λx. f(x x)) 紋在了自己的胳膊上。

這個 Y 組合子,也就是我們一開始想要尋找的那個驅動整個世界的無名發動機。不過,現在它看上去還有點不夠理想。因爲它的參數 F 只是具有 1 個參數的函數,這意味着 Y 組合子構造的匿名遞歸函數只能接受 1 個參數。不過,這不是什麼大問題,多個參數的函數總是能通過單個參數的函數構造出來,不過,這是另外一個話題了。

這篇文章可能你看不懂。沒關係,不懂這個,不影響學會編程。類似於不懂汽車發動機原理,一樣可以駕駛汽車。畢竟我們是生活在一個到處都充滿了名字的世界裏。似乎老子早在兩千多年前就看清了這一切,他低吟着:無名,萬物之始也;有名,萬物之母也。故常無慾,以觀其妙;常有欲,以觀其徼……雖然數學家發現了 Y 組合子,但他們也不會真的在編程中使用這種東西來寫遞歸函數。

倘若你真的很想看懂這篇文章,唯一的辦法是,集中精力,動手動腦,逐步推導。理解 Y 組合子,並非毫無意義,將 Y 倒過來,它是個人。這方面的話題,我不想多說,不然會令人反感。

下一篇長長的望遠鏡


在寫這篇文章時,參考了以下網絡文檔:

[1] http://www.ece.uc.edu/~franco...

[2] http://www.diegoberrocal.com/...

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章