函數式編程是一種解決問題的思路。我們熟悉的命令式編程把程式看作”一系列改變狀態的指令“;而函數式編程把程式看作”一系列數學函數映射的組合“。 ...
WHAT? 什麼是函數式編程?
函數式編程是一種編程範式。
編程範式又是什麼?
編程範式是一種解決問題的思路。
我們熟悉的命令式編程把程式看作一系列改變狀態的指令
;而函數式編程把程式看作一系列數學函數映射的組合
。
編程範式和編程語言無關,任何編程語言都可以按照函數式的思維來組織代碼。
i++; // 命令式 關心指令步驟
[i].map(x => x + 1); // 函數式 關心映射關係
WHY? 函數式有什麼好處?
易寫易讀
聚焦重要邏輯,擺脫例如迴圈之類的底層工作易復用
面向對象可復用的單位是類,函數式可復用的是函數,更小更靈活易測
純函數【後面會講】不依賴外部環境,測試起來準備工作少看起來很厲害
被人誇獎能增強信心和動力,所以這點也很重要
HOW? 如何做起?
方法不難,回學校念個博士,搞清楚範疇論,么半群之類的就可以了。
人生苦短,還是來點實際的吧。
filter
map
reduce
三板斧用好,從迴圈中解放出來small pure function
多寫小的純函數,小指功能聚焦compose
pipeline
curry
三個工具利用好,把小函數像搭積木一樣拼成大函數
filter map reduce 三板斧
來個例子:找出集合中的素數,算出它們平方的和。
獨孤九劍之命令式
const isPrimeNumber = x => {
if (x <= 1) return false;
let testRangStart = 2,
testRangeEnd = Math.floor(Math.sqrt(x));
let i = testRangStart;
while (i <= testRangeEnd) {
if (x % i == 0) return false;
i++;
}
return true;
};
const arr = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
let sum = 0;
for (let i = 0; i < arr.length; i++) {
if (isPrimeNumber(arr[i])) {
sum += arr[i] * arr[i];
}
}
console.log(sum);
破——劍——嗯....函數式
const arr = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
const sum = arr.filter(isPrimeNumber)
.map(x => x * x)
.reduce((acc, cur) => acc + cur, 0);
console.log(sum);
看吧,for
迴圈沒了,代碼意圖也更明顯了。
filter(isPrimeNumber)
找出素數map(x => x * x)
變成平方reduce((acc, cur) => acc + cur, 0)
求和
是不是比命令式看著更清晰了?
isPrimeNumber
的函數式寫法也放出,去掉了迴圈,看看好懂不。
// 輸入範圍,獲得一個數組,例如 輸入 1和5,返回 [1, 2, 3, 4, 5]
const range = (start, end) => start <= end ? [start].concat(range(start + 1, end)) : [];
const isPrimeNumber = x =>
x >= 2 ? range(2, Math.floor(Math.sqrt(x))).every(cur => x % cur != 0) : false;
有人說函數式的效率不高,因為filter
map
reduce
每次調用,內部都會遍歷一遍集合,而命令式只遍歷了一次。
函數式是更高級的抽象,主要聲明解決問題的步驟,把性能優化交給框架或者runtime來解決。
- 框架
transducer
可以讓集合只遍歷一次【篇幅有限,這裡不展開】
memorize
記錄已經算過的,提高效率【後面講純函數的時候,會給出實現】 - runtime
有的語言map
是多線程運行的,函數式代碼不變,runtime一優化,性能就大幅的提升了,而前面的命令式,就做不到這一點。
small pure function
純函數有兩點要求:
- 相同的傳參,返回值一定相同
- 函數調用不會對外界造成影響,如不會修改外部對象
看個例子
let name = 'apolis';
const greet = () => console.log('Hello ' + name);
greet();
name = 'kzhang';
greet();
greet
函數依賴外部變數name,相同的傳參【都不傳參也算相同的傳參】屏幕輸出的內容卻不一樣,所以它不純,鑒定完畢。
const greet = name => console.log('Hello ' + name);
這樣就好多了,不受外部變數的影響了。
不過更嚴格的認為,調用這個函數造影響了控制台console,所以還不算純。
const greet = name => 'Hello ' + name;
這樣才夠純,同時greet
也擺脫了對控制台的依賴,可以適用的範圍更廣了。
我們要學會把純的留給自己,把不純的甩給別人......咳咳,關在函數外面。
由於它的純,同樣的傳參,返回值一定相同。
我們可以把算過的結果保存下來,下次調用傳的參數發現算過了,直接返回之前計算的結果,提升效率。
const memorize = fn => {
let cache = {};
return x => {
if (cache.hasOwnProperty(x)) return cache[x];
else {
const result = fn(x);
cache[x] = result;
return result;
}
}
};
利用上面的工具函數,我們可以緩存純函數的計算結果,三板斧的例子filter
改一下就可以了。
const sum = arr.filter(memorize(isPrimeNumber))
.map(x => x * x)
.reduce((acc, cur) => acc + cur, 0);
console.log(sum);
如果數組中包含重覆元素,這樣就能減少計算次數了。
命令式寫法要達到這個效果,改動就大的多了。
compose pipeline curry
寫了一堆small pure function
,怎麼把他們組合成更強大的功能呢?
compose
pipeline
curry
這三位該出場了。
compose
舉個例子。
const upperCase = str => str.toUpperCase();
const exclaim = str => str + '!';
const holify = str => 'Holy ' + str;
現在需要一個amaze
方法,字元串前面添加Holy,後面添加嘆號,全部轉為大寫。
const amaze = str => upperCase(exclaim(holify(str)));
很不優雅對不對?
看看compose
怎麼幫我們解決這個問題。
const compose = (...fns) => x => fns.reduceRight((acc, cur) => cur(acc), x);
const amaze = compose(upperCase, exclaim, holify)
console.log(amaze('functional programing'));
這裡用到了reduceRight
,和reduce
的區別就是數組是從後往前遍歷的。
compose
內的函數是從右往左運行的,也就是先holify
再exclaim
再upperCase
。
有人可能看不慣從右往左運行,於是又有了一個pipeline
。
pipeline
和compose
的區別就是換個方向,compose
用的是reduceRight
,pipeline
用的是reduce
。
const pipeline = (...fns) => x => fns.reduce((acc, cur) => cur(acc), x);
const amaze = pipeline(holify, exclaim, upperCase)
console.log(amaze('functional programing'));
curry
上面compose
pipeline
里的函數參數都只是一個,如果函數要傳多個參數怎麼辦?
解決辦法就是用curry
【柯里化】,把函數變成一個參數的。
const add = (x, y) => x + y;
const multiply = (x, y) => x * y;
這兩個函數都是需要傳兩個參數的,現在我需要一個函數,把數字先加5再乘2。
const add5ThenMultiplyBy2 = x => multiply(add(x, 5), 2)
很不好看,我們來curry
一下再compose
看看。
怎麼curry
?
把括弧去掉,逗號變箭頭就可以了。
這樣傳入一個參數x
的時候,返回了一個新函數,等待著接收參數y
。
const add = x => y => x + y;
const multiply = x => y => x * y;
接下來,我們又可以用compose
了
const add5ThenMultiplyBy2 = x => compose(multiply(2), add(5));
不過curry
之後的add
方法要這麼調用了
add(2)(3)
原先的調用方式add(2, 3)
都得改掉了。不喜歡這個副作用?再奉上一個工具函數curry
。
const curry = fn => {
const inner = (...args) => {
if (args.length >= fn.length) return fn(...args);
else return (...newArgs) => inner(...args, ...newArgs);
}
return inner;
};
傳入fn
返回一個新函數,新函數調用時判斷傳入的參數個數有沒有達到fn
的要求,達到了,直接返回fn
調用的結果;沒達到,繼續返回一個新新函數,記錄著之前已傳入的參數。
const add = (x, y) => x + y;
const curriedAdd = curry(add);
這樣兩種調用方式都支持了。
curriedAdd(2)(3);
curriedAdd(2, 3);
總結
函數式是一種編程思維,聲明式、更抽象。
這種思維方式的利弊,大型項目里怎麼用,我還沒深刻的體會,練習還不足。
建議新手和我一樣從下麵三點開始多寫多思考。
filter
map
reduce
三板斧用好,從迴圈中解放出來small pure function
多寫小的純函數,小指功能聚焦compose
pipeline
curry
三個工具利用好,把小函數像搭積木一樣拼成大函數
後面我會繼續學習functor
monad
相關的知識,感興趣可以關註。