緣起
造了一個輪子,根據GitHub專案地址,生成專案目錄樹,直觀的展現專案結構,以便於介紹專案。歡迎Star。
技術棧:
- ES6
- Vue.js
- Webpack
- Vuex
- lodash
- GitHub API
應用涉及到了展現目錄樹,實現方法不可或缺的一定是遞迴遍歷。進而開啟了我對lambda演算
的探索發現之旅。
探索發現之旅
本次乘坐的是 斐波那契 號郵輪,下面會涉及到一些 JavaScript
函數語言程式設計中的一些基本概念。如果出現眩暈、噁心(kan bu dong)等不良反應,想下船的旅客純屬正常。常旅客請安心乘坐。
高階函式
函數語言程式設計中,接受函式作為引數,或者返回一個函式作為結果的函式通常就被稱為高階函式。
map、filter、reduce 均屬於高階函式,高階函式並不神祕,我們日常程式設計也會用到。
ES6 中的 map
例子
const arr = [1, 2, 3, 4, 5, 6]
const powArr = arr.map(v => v * v)
console.log(powArr) // [ 1, 4, 9, 16, 25, 36 ]
複製程式碼
尾呼叫
尾呼叫(Tail Call)是函數語言程式設計的一個重要概念,本身非常簡單,是指某個函式的最後一步是呼叫另一個函式。尾呼叫即是一個作為返回值輸出的高階函式。
例如:
function f(x) {
return g(x);
}
複製程式碼
函式f()
在尾部呼叫了函式g()
尾呼叫的重要性在於它可以不在呼叫棧上面新增一個新的堆疊幀,而是更新它,如同迭代一般。
尾遞迴
遞迴我們都不陌生,函式呼叫自身,稱為遞迴。如果尾呼叫自身,就稱為尾遞迴。通常被用於解釋遞迴的程式是計算階乘:
// ES5
function factorial(n) {
return n === 1 ? 1 : n * factorial(n - 1);
}
factorial(6) // => 720
// ES6
const factorial = n => n === 1 ? 1 : n * factorial(n - 1)
factorial(6) // => 720
複製程式碼
遞迴非常耗費記憶體,因為需要同時儲存成千上百個呼叫記錄,很容易發生“棧溢位”錯誤(stack overflow)。但對於尾遞迴來說,由於只存在一個呼叫記錄,所以永遠不會發生“棧溢位”錯誤。對函式呼叫在尾位置的遞迴或互相遞迴的函式,由於函式自身呼叫次數很多,遞迴層級很深,尾遞迴優化則使原本 O(n) 的呼叫棧空間只需要 O(1)
尾遞迴因而具有兩個特徵:
- 呼叫自身函式(Self-called);
- 計算僅佔用常量棧空間(Stack Space)。
再看看尾遞迴優化過的階乘函式:
// ES5
function factorial(n, total) {
return n === 1 ? total : factorial(n - 1, n * total);
}
factorial(6, 1) // => 720
// ES6
const factorial = (n, total) => n === 1 ? total : factorial(n - 1, n * total)
factorial(6, 1) // => 720
複製程式碼
在ES6中,只要使用尾遞迴,就不會發生棧溢位,相對節省記憶體。
上面的階乘函式factorial
,尾遞迴優化後的階乘函式使用到了total
這個中間變數,為了做到遞迴實現,確保最後一步只呼叫自身,把這個中間變數改寫成函式的引數,這樣做是有缺點的,為什麼計算6的階乘,還要傳入兩個變數6和1呢?解決方案就是柯里化
。
柯里化
柯里化(Currying),是把接受多個引數的函式變換成接受一個單一引數的函式,並且返回接受餘下的引數而且返回結果的新函式的技術。
維基百科上的解釋稍微有點繞了,簡單來說,一個 currying
的函式只傳遞給函式一部分引數來呼叫它,讓它返回一個閉包函式去處理剩下的引數。
// 階乘尾遞迴優化寫法
function currying(fn, n) {
return function (m) {
return fn.call(this, m, n);
};
}
function tailFactorial(n, total) {
if (n === 1) return total;
return tailFactorial(n - 1, n * total);
}
const factorial = currying(tailFactorial, 1);
factorial(6) // => 720
複製程式碼
下面看下 ES6 中的 柯里化:
const fact = (n, total) => n === 1 ? total : fact(n - 1, n * total)
const currying = f => n => m => f(m, n)
const factorial = currying(fact)(1)
factorial(6) // => 720
複製程式碼
上面程式碼通過柯里化,將尾遞迴變為只接受單個引數的 factorial,得到了想要的factorial(6)
獨參函式。
思考?,有木有更簡單的方法實現上面獨參尾遞迴栗子。當然有,利用ES6的函式新特性,函式預設值。
簡單化問題:
const fact = (n, total = 1) => n === 1 ? total : fact(n - 1, n * total)
factorial(6) // => 720
複製程式碼
Lambda表示式
在 JavaScript
中,Lambda表示式可以表示匿名函式。
恆等函式在 JavaScript 中的栗子:
// ES5
var f = function (x) {
return x;
};
// ES6
const f = x => x
複製程式碼
用 lambda表示式
來寫是這樣子的:λx.x
現在試著用lambda表示式寫出遞迴
(匿名函式遞迴),使用具有遞迴效果的lambda
表示式,將lambda
表示式作為引數之一傳入其自身。
// ES5
function factorial(f, n) {
return n === 1 ? 1 : n * f(f, n - 1)
}
factorial(factorial, 6) // => 720
// ES6
const factorial = (f, n) => n === 1 ? 1 : n * f(f, n - 1)
factorial(factorial, 6) // => 720
複製程式碼
是的,這麼做還是太難看了,沒人希望寫一個階乘函式還要傳入其他引數。解決方案仍然是柯里化
。尾呼叫優化後的Lambda表示式遞迴:
const fact = (f, n ,total = 1) => n === 1 ? total : f(f, n - 1, n * total)
const currying = f => n => m => f(f, m ,n)
const factorial = currying(fact)()
factorial(6) // => 720
複製程式碼
最終達到了目的,得到了獨參函式factorial。
Lambda演算
在Lambda演算中的所有函式都是匿名的,它們沒有名稱,它們只接受一個輸入變數,即獨參函式。
構建一個高階函式,它接受一個函式作為引數,並讓這個函式將自身作為引數呼叫其自身:
const invokeWithSelf = f => f(f)
用Lambda演算寫出遞迴栗子:
const fact = f => (total = 1) => n => n === 1 ? total : f(f)(n * total)(n - 1)
const factorial = fact(fact)()
factorial(6) // => 720
複製程式碼
黑魔法Y組合子
什麼是Y組合子?
Y = λf.(λx.f(xx))(λx.f(xx))
η-變換後的寫法:
Y = λf.(λx.f(λv.x(x)(v)))(λx.f(λv.x(x)(v)))
用ES6箭頭函式寫出lambda演算Y組合子
const Y = f =>
(x => f(v => x(x)(v)))
(x => f(v => x(x)(v)))
複製程式碼
Y組合子推導
以匿名函式遞迴開始
const fact = f => (total = 1) => n => n === 1 ? total : f(f)(n * total)(n - 1)
const factorial = fact(fact)()
factorial(6) // => 720
複製程式碼
上面程式碼有一種模式被重複了三次, f(f) 兩次, fact(fact) 一次。為了讓程式碼更加 DRY ,嘗試把 f(f)
解耦,當作引數傳遞。
const fact = f =>
(g => (total = 1) => n => n === 1 ? total : g(n * total)(n - 1))(f(f))
const factorial = fact(fact)()
factorial(6) // => Maximum call stack size exceeded
複製程式碼
當然上面程式碼執行結果會棧溢位,因為 JavaScript 中引數是 按值傳遞 的,形參必須先求值再作為實參傳入函式,f(f)
作為引數傳遞時,會無限遞迴呼叫自身,導致棧溢位。這時候就需要用到 lambda 演算中的 η-變換
。其原理是用到了惰性求值。
η-變換
什麼是η-變換?如果兩個函式對於任意的輸入都能產生相同的行為(即返回相同的結果),那麼可以認為這兩個函式是相等的。
lambda演算中有效的η-變換:f = λx.(fx)
JavaScript中的η-變換:f = x => f(x)
根據η-變換,f(f)
作為函式代入,等價於 x => f(f)(x)
const fact = x => (f => (total = 1) => n => n === 1 ? total : f(n * total)(n - 1))(v => x(x)(v))
const factorial = fact(fact)()
factorial(6) // => 720
複製程式碼
抽離共性
也許你也已經發現f => (total = 1) => n => n === 1 ? total : f(n * total)(n - 1)
這就是柯里化後的遞迴方法。抽離出 fact
方法。
const fact = f => (total = 1) => n => n === 1 ? total : f(n * total)(n - 1)
const factorial = (x => fact((v => x(x)(v))))(x => fact((v => x(x)(v))))()
factorial(6) // => 720
複製程式碼
構建Y
將具名 fact
函式變為匿名函式,構建一個工廠函式 Y,將 fact
函式作為引數傳入。
const fact = f => (total = 1) => n => n === 1 ? total : f(n * total)(n - 1)
const Y = f => (x => f(v => x(x)(v)))
(x => f(v => x(x)(v))) // 瞧,這不就是黑魔法Y組合子嘛
const factorial = Y(fact)()
factorial(6) // => 720
複製程式碼
用Y組合子實現的匿名遞迴函式,它不僅適用於階乘函式的遞迴處理,任意遞迴工廠函式經過Y函式後,都能得到真正的遞迴函式。
沿途風景
斐波那契數列
在數學上,斐波那契數列是以遞迴的方法定義的:
用文字來說:就是斐波那契數列由0和1開始,之後的斐波那契係數就由之前的兩數加和。
0,1,1,2,3,5,8,13,21,34,55,89,144,233......
用JavaScript遞迴實現:
// 非尾遞迴
function fibonacci (n) {
if ( n <= 1 ) return 1;
return fibonacci(n - 1) + fibonacci(n - 2);
}
fibonacci(6) // 13
複製程式碼
使用尾呼叫優化的斐波那契數列
// 尾遞迴寫法
function fibonacci (n , before , after) {
if( n <= 1 ) return before;
return fibonacci (n - 1, after, before + after);
}
fibonacci(6, 1, 2) // 13
複製程式碼
使用lambda表示式的斐波那契數列
// ES6 lambda calculus
const Y = f => (x => f(v => x(x)(v)))(x => f(v => x(x)(v)))
const fibonacci = Y(
f => (n) => n <= 1 ? 1 : f(n - 1) + f(n - 2)
)
fibonacci(6) // 13
複製程式碼
德羅斯特效應
在生活中,德羅斯特效應(Droste effect)是遞迴的一種視覺形式,指一張圖片部分與整張圖片相同,一張有德羅斯特效應的圖片,在其中會有一小部分是和整張圖片類似。 而這小部分的圖片中,又會有一小部分是和整張圖片類似,以此類推,……。德羅斯特效應的名稱是由於荷蘭著名廠牌德羅斯特(Droste) 可可粉的包裝盒,包裝盒上的圖案是一位護士拿著一個有杯子及紙盒的托盤,而杯子及紙盒上的圖案和整張圖片相同
總結
我在做repository-tree專案的過程中學習到了很多之前沒有接觸過的東西,這也是我的初衷,想到各種各樣的idea,去想辦法實現它,過程中自然會提升自己的見識。以此篇博文激勵自己繼續學習下去。如果上述定義有不對之處,懇請拍磚。