邱奇數(Church Numerals)是 λ演算中的一個著名例子。我們可以通過實現它來了解和練習使用C++14裡的Generic Lambda。本文裡的例子最新的C++編譯器(VC2015, g++ 6, clang++ 3.8)都可以編譯執行。
什麼是邱奇數?
邱奇數是通過函式來表示自然數的方法。直觀上講,邱奇數可以類比於下圖(圖一)裡表示數字的方法。假設我們有一個球,我們可以把這個球放到一個箱子裡,然後可以把這個裝著球的箱子再放到另一個箱子裡,依次類推。當然你也可以用俄羅斯套娃來想象這個過程。當只有球的時候,我們說這種情況代表0;當有一個箱子的時候代表1。這樣我們就可以用巢狀的箱子的個數來代表自然數了。
邱奇數的構建過程是很類似的。設有一個自變數 x 和一個函式 f,單獨的 x 表示0; 當函式作用一次時,我們得到 f(x),我們以此代表1。類似的,數字2就是這個函式作用兩次,即 f(f(x))。更普遍的,給定自然數 n, 其對應的邱奇數就是把 f 作用 n 次。如果我們要用程式語言來實現邱奇數,那麼這個程式語言必須支援高階函式(Higher Order Functions)。所謂的高階函式就是可以接受別函式的函式,也可以返回一個函式。要實現高階函式,函式必須可以可以被當成一個值來傳遞。為什麼必須要求高階函式呢?這是因為邱奇數本質上就是函式,作用在邱奇數上的代數操作必然是處理函式,產生函式的函式。
C++中的lambda
C++14標準裡的Generic Lambda是一種函式,它可以當作值傳遞,接受別的lambda表示式作為引數,並且可以返回一個lambda表示式。它最基本的形式是:
1 2 |
auto n = 1; auto square = [n] (auto x) {return (x + n) * (n + x);}; |
這裡我們定義了一個變數square
,它的值是一個lambda表示式。一個lambda表示式包含三個部分:
- 捕獲列表:定義一個lambda時,我們有時候希望能用到定義這個lambda時所在的環境裡的其他變數或者值。我們可以把這些放到捕獲列表裡,比如這裡的n。除了通過值捕獲,我們還可以通過引用(reference)來捕獲,其形式是
[&n]
。 - 引數列表:和普通函式一樣,表示函式的變數。
- 函式體:函式的實現程式碼。如果沒有
return
語句,函式返回型別是void
。
更加詳細的描述可以參照這裡。
邱奇數的lambda表示式
用lambda來表述,每一個數都是一個高階函式,比如0對應的邱奇數:
1 2 3 |
auto zero = [](auto f) { return [=](auto x) { return x;};}; |
如前所述,邱奇數是通過一個變數 x 和一個函式 f 來定義的。所以它必然需要兩個引數。在這裡我們用了兩個巢狀的函式來表達。注意在內層的lambda表示式裡我們把=
放到捕獲列表裡了,它的意思是把這個函式所在的環境裡的所有變數的值捕獲到內部的lambda表示式裡。因為這個環境裡只有 f,所以[=]
等同於[f]
。除了捕獲變數的值,我們也可以捕獲變數的引用,即使用[&]
。值得注意的是,呼叫這個函式的時候如果只提供一個引數,例如zero("Hey")
,它會返回一個函式而不是值。我們也可以直接提供兩個引數,例如zero("Hey")("Jude")
,它的返回值是Jude
。
對0來說,我們沒有作用 f 到 x 上。那麼我們怎麼來表示1呢。我們把 f 作用在 x 上一次,像這樣:
1 2 3 |
auto one = [](auto f) { return [=](auto x) { return f(x);};}; |
⚠️這裡 f 必須是一個函式,因為在內部的lambda裡我們把 x 作為變數傳給了 f。
當然我們不可能像這樣把每一個自然數都寫出來,所以我們需要更加普遍的方法來構造邱奇數。最基本的一個操作就是給一個數加一:
1 2 3 4 |
auto add1 = [](auto n) { return [=](auto f) { return [=](auto x) { return f(n(f)(x));};};}; |
add1
是一個典型的高階函式。它接受一個邱奇數,返回一個新的邱奇數。在這裡,我們把 f 作用在 n 上,根據邱奇數的定義,它表示 n + 1。有了這個函式,我們就可以從0開始不斷的加一產生一系列的數。比如:
1 2 |
auto one = add1(zero); auto two = add1(one); |
從add1
我們可以看出,給一個數加一,就是把 f 作用在其上。很自然的,我們可以把兩個數相加:
1 2 3 4 |
auto add = [](auto m, auto n) { return [=](auto f) { return [=] (auto x) { return m(f)(n(f)(x));};};}; |
可以看到,m + n 就是把 n 作為值傳遞給 m。因為 n 已經把 f 應用了 n 次,如果把 n 作為 m 的變數,m 就會把 f 作用到 n 上 m 次,這樣我們總計應用了 m + n 次。例子:
1 |
auto three = add(one, two); |
邱奇數到自然數的轉化
既然邱奇數是自然數的一種表達方式,那麼我們應該可以把邱奇數轉化成自然數。另一個好處,把邱奇數轉化成自然數也可以驗證我們的程式碼是否正確。因為邱奇數對應的自然數就是應用 f 到 x 上的次數,選擇適當的 f 和 x 就可以達到我們的目的。顯然如果我們選擇 x = 0 作為初始值,而 f 每次的呼叫都返回 x + 1,最終的返回值就是 f 被呼叫的次數。所以我們可以定義:
1 |
auto convert = [](auto x) {return x + 1;}; |
轉化的方法就是把convert
傳遞給一個邱奇數:
1 |
auto n = three(convert)(0); |
我們也可以通過邱奇數來多次呼叫一個函式,比如列印一個字串3次:
1 2 |
auto says = [] (auto x) {std::cout << x << std::endl; return x;}; three(says)("Hello from 3"); |
⚠️邱奇數裡的函式 f 必須接受一個值,並且返回一個同型別的值。
結束語
以上的所有程式碼都可以在這裡獲取。我們僅僅實現了最基本的幾個關於邱奇數的函式。開動腦洞,我們也可以做許多很有趣的事情。比如這裡,作者用Ruby裡的lambda函式實現了FizzBuzz。