做爲一名有追求的程序員,對於計算機基礎的理論必定要有所瞭解。最近幾年,隨着分佈式、雲計算等技術的發展,函數式編程語言也趨於流行。若是要學習函數式編程,必定要深刻理解它背後的理論知識。從收益的角度上講,這些基礎理論知識幾十年不變,是十分值得花時間進行學習的。lambda演算(Lambda Calculus)就屬於這樣一套理論,能夠說它在函數式編程領域就如牛頓萬有引力定律同樣基礎。接下來這篇文章我將主要介紹lambda演算的基本知識,最後我還會嘗試用es6的箭頭函數來演示如何利用lambda演算來實現編程語言中的基本組成要素。javascript
要了解一個事物,先了解它的歷史必定是重中之重。lamda表達式最初是由一個美國普林斯頓大學的數學家Alonzo Church在1932年所發明的。他也是"計算機科學之父"——圖靈的博士生導師。html
咱們都知道現代的計算機基本上都是基於圖靈機的。在圖靈機中,全部的計算過程其實都是基於狀態的,這也是爲何咱們日常寫代碼要聲明並使用變量的緣由:變量主要做用就是用來存儲狀態。而Alonzo Church所提出的lamda演算(lamda calcus)模型其實是基於函數的。圖靈機模型和Lambda演算模型雖然是兩種不一樣的理論模型,但它們其實是等價的,這也意味着,任何基於圖靈機的計算機程序都能等價地翻譯成基於lambda演算模型的程序。java
lambda演算是一套研究函數定義、函數應用和遞歸的形式系統。它基本的組成部分就是三種表達式: 1. 函數定義 2. 標識符引用 3.函數應用。git
那麼到底什麼是lamda表達式呢,它又是由哪些基本組成要素構成的呢?咱們都知道在函數式編程語言裏面,最基本的組成單位就是函數。lambda表達式從定義上來說能夠看作是一個匿名的純函數。ES6中引入了箭頭函數,它的本質實際上就是咱們這裏所說的lambda表達式:程序員
const lambda = x => x + 1;
複製代碼
實際上如今大部分的編程語言都引入了lambda表達式這一特性,如Java, c#和es6等。咱們一般將lambda表達式,看做是一個黑盒,只關心它的輸入和輸出。因爲沒有內部狀態,用函數式編程的思想寫代碼就與用命令式語言寫代碼大相徑庭。做爲一個純函數,每一次運行定義好的lambda表達式的時候,結果都應該是一致的。es6
在純粹的lambda演算中其實是沒有任何內置的數據結構和邏輯控制語句的,可是咱們可使用函數來建構整個編程語言的全部要素。github
lambda演算中的一些基本規則,能夠類比到咱們比較熟悉的ES6語法:編程
lambda表達式 | ES6 箭頭函數 | |
---|---|---|
定義函數 | λx.x | x => x |
柯里化 curry | λx. λy.x+y | x => y => x + y |
應用 application | (λx. λy.x+y) 5 1 | (x => y => x + y) (5) (1) |
在實現具體的邏輯以前,咱們須要明確的一點是:在lambda演算中,lambda表達式自己既能夠是操做數也能夠是函數,就好像一隻雞(lambda表達式),既能夠吃蟲子(另外一個lambda表達式),也能夠被狐狸(還有一個lambda表達式) 吃(請原諒我這糟糕的類比),它們統稱爲動物(lambda表達式)。歸根結底就是,在這個封閉的概念世界裏,只有一類事物,那就是lambda表達式(函數),咱們能夠利用這個最基礎的概念生成其它的概念和運算邏輯。c#
在純粹的函數式編程世界裏,沒有1,2,3這樣的數字也沒有+-*/這樣的基本運算符,因此這些咱們都須要本身手動去實現。微信
首先做爲一門計算機設計語言,數字是關鍵,所謂」數是萬物本源「在計算機世界裏簡直就是真理。那麼咱們如何利用lamda表達式來表示數呢?在這裏,咱們採用函數調用次數來表示天然數,用這樣的編碼方式表示的天然數也叫邱奇數。
有了理論的指導,咱們很容易就能寫出下面的代碼:
const ZERO = f => x => x;
const ONE = f => x => f(x);
const TWO = f => x => f(f(x));
...
複製代碼
有了數字後,而後還須要再定義一個轉換函數,它能夠將ZERO
和ONE
這種函數式定義轉成咱們所熟悉的數字,方便調試。
const toNumber = n => n(i => i+1)(0);
console.log(toNumber(ONE));
// 1
複製代碼
通過上面的步驟,咱們就定義了最基本的數字。有了這些數字,咱們應該如何去作一些簡單的加減乘除運算呢?既然數字表示的是調用函數次數的多少,那麼在這裏對於加法運算,咱們也能夠將它定義成調用函數的次數的相加。例如,要表示1+2等於3這一過程,輸入的函數就應該調用三次。
const add = n => m => fn => x => m(fn)(n(fn)(x));
const TWO = add(ONE)(ONE);
const THREE = add(ONE)(TWO);
toNumber(THREE);
// 3
複製代碼
其中n和m表示add操做的兩個操做數。
接着咱們再來實現乘法,回顧一下你們小時候學習乘法的過程。n * m
的一個樸素定義能夠表示成: n個m相加。那麼在這裏,咱們須要實現的就是先調用n次函數,將它的結果再調用m次。表示到代碼中就像這樣:
const multiply = n => m => fn => x => m(n(fn))(x);
const result = toNumber(multiply(TWO)(THREE));
console.log(result);
// 6
複製代碼
至於減法和除法,實現起來相對來講比較複雜,你們若是感興趣的話,能夠參考其餘的資料進行學習。
接下來咱們再進一步考慮一下如何實現條件分支語句。條件分支語句中一個很重要的元素就是布爾值,先來定義TRUE和FALSE這兩個基本的布爾值類型:
const TRUE = first => second => first;
const FALSE = first => second => second;
複製代碼
它表示的是從兩個事物中選擇其中一個事物,TRUE表示選擇的第一個,而FALSE與之相反,選擇的是第二個。
定義好基本的布爾值類型,再實現條件分支語句就很簡單了:
const ifElse = boolFn => first => second => boolFn(first)(second);
// ifElse(TRUE)(x)(y) ===> x
// ifElse(FALSE)(x)(y) ===> y
複製代碼
再增長一個轉換函數:
const toBoolean = boolFn => boolFn(true)(false);
console.log(toBoolean(TRUE));
// true
複製代碼
大功告成,Bingo!
利用上述定義的布爾值,推導出三大邏輯運算:與(and)、或(or)、非(not)就瓜熟蒂落了。反轉邏輯最簡單,只要將上面定義條件分支語句的邏輯反過來就能夠了。這與布爾值的定義也是強聯繫,若是說TRUE表示的是選擇第一個分支條件,那麼not就要反轉這種邏輯:
const not = boolFn => first => second => boolFn(second)(first);
toBoolean(not(TRUE));
// false
複製代碼
至於或運算符,實質上應該是個帶有兩個操做數的運算,因此咱們須要定義個高階函數,須要調用兩次,每次接收一個操做數。根據以前的定義,咱們知道TRUE會返回第一個變量,FALSE會返回第二個變量。而或運算(or)的意思是隻要兩個操做數中有一個TRUE,就返回TRUE。那麼咱們只要使變量應用的順序和調用順序一致,就能保證當TRUE做爲第一個參數時,正好應用到TRUE函數上, 當FALSE做爲第一個參數時,函數返回第二個參數的值。
const or = first => second => first(first)(second);
toBoolean(or(TRUE)(FALSE)); // true
toBoolean(or(FALSE)(FALSE)); // false
toBoolean(or(TRUE)(FALSE)) // true
複製代碼
與操做與或操做類似,咱們要保證當兩個操做數都是TRUE的時候纔會返回TRUE。將上面的實現邏輯反轉一下,就能獲得下面的代碼:
const and = first => second => first(second)(first);
toBoolean(and(FALSE)(FALSE)); // false
toBoolean(and(FALSE)(TRUE)); // false
toBoolean(and(TRUE)(TRUE)); // true
複製代碼
這塊邏輯可能比較繞,你們能夠用心體會一下。
咱們先從一個最簡單的遞歸定義提及,下面這個故事想必你們都有據說過:
從前有座山,山裏有座廟,廟裏有個老和尚和小和尚,有一天老和尚對小和尚講故事,講的什麼故事呢?從前有座山,山裏有座廟,廟裏有個老和尚和小和尚…
這樣一個不停的引用自身的概念,其實就是遞歸的簡單定義。
有了遞歸的定義後,咱們再來深刻思考一下,如何用lambda表達式來實現遞歸的基本邏輯。
這裏我舉一個簡單的斐波那契數列的例子(1, 2, 6, 24…),若是語言中已經支持遞歸,很容易能夠寫出這樣的代碼:
const factrial => n => n == 0 ? 1 : n * factrial(n - 1);
複製代碼
若是語言中不支持遞歸,那麼在lambda表達式中,咱們並不能直接利用factorial這個名字來引用其自身。
不過能夠換個思路,既然不能在函數聲明裏面使用未定義的函數名,那麼咱們能夠將這個函數定義以參數的形式傳進去:
const makeFactorial = factroial => n => n == 0 ? 1 : n * factroial(n - 1);
複製代碼
有了一個makeFactorial後,怎麼使用呢? 假設,如今已經有了一個seed函數:
const seed = n => n;
複製代碼
它其實啥都沒幹,不過咱們能夠利用seed函數生成factorial0:
const factorial0 = makeFactorial(seed)
複製代碼
很容易知道,factorial0函數展開後是這樣的:
const factorial0 = n => n == 0 ? 1 : n * (n => n)(n - 1);
複製代碼
咱們知道這個函數在參數n等於0的時候結果是對的,而n等於其它數值的時候結果是0,顯然不正確。不過,彆着急,咱們能夠利用factorial0進一步構造factorial1:
const factorial1 = makeFactorial(factorial0)
複製代碼
它展開後:
const factorial1 = n => n == 0 ? 1 : n * factorial0(n - 1);
複製代碼
這個函數在n等於1的時候結果等於 1 * factorial0(0)
, 已知n 等於 0 時候,factorial0的結果是正確的,因此factorial1在n 等於0 和 n等於1的時候也都能正常工做。
一樣的原理,咱們能夠繼續構造出factorail2, factorial3, factorial4….
因此能夠概括出通用的factorial函數定義應該是長這個樣子的:
const factorialn = makeFactorial(makeFactorial(...)); // 無窮個makeFactorial
複製代碼
到這裏,咱們進一步思考🤔,有沒有什麼辦法能讓這個makeFactorial函數不停遞歸下去呢?
咱們先定義一個基本循環函數:
const loop = x => x(x);
複製代碼
這樣當我用這個函數調用makeFactorial的時候,就會調用makeFactorial(makeFactorial),不過這樣的定義好像缺乏動力。要讓它不停循環下去,咱們可讓它再自身調用一次:
const loop = (x => x(x))(x => x(x))
複製代碼
能夠看到當你將x = x => x(x)
代入到loop
函數後,展開結果以下:
const loop = (x => x(x))(x => x(x))
複製代碼
是否是又回到以前的定義了?
不過這樣一個函數在javascript中使用是不行的,由於在javascript中參數是計算完後再傳入到函數中去的,因此咱們要延遲參數的計算, 將代碼改爲以下:
const loop = (x => x(x))(y => x(x)(y))
複製代碼
引入參數y後,只有最終展開到最後一層的時候,纔會開始計算值,這也是延遲計算的思想。
有了這個loop函數後,咱們最終的factorial函數就很容易構造了:
const factorial = loop(makeFactorial);
複製代碼
咱們在控制檯試一下效果:
factorial(4) = 24
複製代碼
實際上上面定義的那個loop
函數,它就叫作Y組合子(Y combinator)。這也是在lambda演算中很是著名的一個概念,它是在不支持遞歸的編程語言中實現遞歸的關鍵,也是學習lambda演算理論的一個難點。要完全理解它可能還須要多花點時間思考。
至此,咱們已經實現了數據,計算,邏輯和控制語句還有最重要的遞歸。這幾個部分實際上是編程語言中最核心的組成部分。從一個基本的組成元素——函數,再經過幾個法則,就能夠構建成整個計算機編程語言的核心要素,這就是lambda演算的神奇之處。
固然,整個lambda演算的理論要完全理解確定是不那麼容易的,這篇文章也只是我的學習後的一些思路梳理,不免有所錯漏。你們若是在閱讀過程當中,發現有不對的地方,歡迎交流指正。
——--轉載請註明出處--———
最後,歡迎你們關注個人公衆號,一塊兒學習交流。