什麼是函數語言程式設計
在文章之前,先和大家講一下對於函數語言程式設計(Functional Programming, aka. FP)的理解(下文我會用FP指代函數語言程式設計):
- FP需要保證函式都是純淨的,既不依賴外部的狀態變數,也不產生副作用。基於此前提下,那麼純函式的組合與呼叫,在時間順序上就不會產生依賴,改變多個函式的呼叫順序也不必擔心產生問題,因此也會消滅許多潛在的bug。
- 函式必須有輸入輸出。如果一個函式缺乏輸入或輸出,那麼它其實是一段處理程式procedure而已。
- 函式儘可能的保持功能的單一,如果一個函式做了多件事情,那麼它理論上應當被拆分為多個函式。
- FP的意義之一就是,在適當的時機使用宣告式程式設計,抽象了程式流的控制與表現,從理解和維護的角度上會勝於指令式程式設計。
- FP是一種正規化,但並不意味這和OOP(物件導向程式設計)衝突,兩者當然是可以和諧共存的。個人認為 React 其實就是一個很好的栗子~
- Javascript的函式一等公民以及閉包的特性,決定了Javascript的確是適合施展FP的舞臺
理解閉包
閉包對於 Javascript 來說,當然十分重要。然而對於函數語言程式設計來說,這更加是必不可少的,必須掌握的概念,閉包的定義如下:
Closure is when a function remembers and accesses variables from outside of its own scope, even when that function is executed in a different scope.
相信大部分同學都對閉包有不錯的理解,但是由於對FP的學習十分重要。接下來我還是會囉嗦的帶大家過一遍。閉包就是能夠讀取其他函式內部變數的函式
簡單示例如下
// Closure demo
function cube(x) {
let z = 1;
return function larger(y) {
return x * y * z++;
};
}
const makeCube = cube(10);
console.log(makeCube(5)); // 50
console.log(makeCube(5)); // 100
複製程式碼
那麼有沒有想過在函式makeCube,或者也可以說是函式larger是怎麼記住原本不屬於自己作用域的變數x和z的呢?在控制檯檢視makeCube.prototype
,點開會發現原來是有個[[Scopes]]
這個內建屬性裡的Closure(cube)
記住了函式larger返回時記住的變數x和z。如果多巢狀幾層函式,也會發現多幾個Closure(name)在[[Scopes]]
的Scopes[]
陣列裡,按序查詢變數。
再看下圖測試程式碼:
function cube(x) {
return function wrapper(y) {
let z = 1;
return function larger() {
return x * y * z++;
};
}
}
const makeCubeY = cube(10);
const makeCube = makeCubeY(5);
const $__VAR1__ = '1. This var is just for test.';
let $__VAR2__ = '2. This var is just for test.';
var $__VAR3__ = '3. This var is just for test.';
console.log(makeCubeY.prototype, makeCube.prototype);
console.log(makeCube()); // 50
console.log(makeCube()); // 100
複製程式碼
列印makeCubeY.prototype
:
列印makeCube.prototype
:
通過這幾個實驗可以從另一個角度去理解Javascript中閉包,一個閉包是怎麼去查詢不是自己作用域的變數呢?makeCube函式分別從[[Scopes]]
中的Closure(wrapper)裡找到變數y、z,Closure(cube)裡找到變數x。至於全域性let、const宣告的變數放在了Script
裡,全域性var宣告的變數放在了Global
裡。
在學習FP前,理解閉包是尤為重要的~ 因為事實上大量的FP工具函式都使用了閉包這個特性。
工具函式
unary
const unary = fn => arg => fn(arg);
複製程式碼
一元函式,應用於當只想在某個函式上傳遞一個引數情況下使用。嘗試考慮以下場景:
console.log(['1', '2', '3'].map(parseInt)); // [1, NaN, NaN]
console.log(['1', '2', '3'].map(unary(parseInt))); // [1, 2, 3]
複製程式碼
parseInt(string, radix)
接收兩個引數,而map函式中接收的回撥函式callback(currentValue[, index[, array]])
,第二個引數是index,此時如果parseInt的使用就是錯誤的。當然除了Array.prototype.map
,大量內建的陣列方法中的回撥函式中都不止傳遞一個引數,如果存在適用的只需要第一個引數的場景,unary函式就發揮了它的價值,無需修改函式,優雅簡潔地就接入了。(對於unary函式,fn就是閉包記憶的變數資料)
identity
const identity = v => v;
複製程式碼
有同學會看到identity函式會覺得莫名其妙?是幹嘛的?我第一眼看到也很迷惑?但是考慮以下場景:
console.log([false, 1, 2, 0, '5', true].filter( identity )); // [1, 2, "5", true]
console.log([false, 0].some( identity )); // false
console.log([-2, 1, '3'].every( identity )); // true
複製程式碼
怎麼樣?眼前一亮吧,沒想到identity
函式原來深藏不露,事實上雖然identity返回了原值,但是在這些函式中Javascript會對返回的值進行型別裝換,變成了布林值。比如filter函式。我們可以看MDN定義filter描述如下(看標粗的那一句)。
filter() calls a provided callback function once for each element in an array, and constructs a new array of all the values for which callback returns a value that coerces to true.
constant
const constant = v => () => v;
複製程式碼
同樣,這個函式...乍一看,也不知道具體有什麼用。但是考慮場景如下:
const p1 = new Promise((resolve, reject) => {
setTimeout(() => {
resolve('Hello!');
}, 200);
});
p1.then(() => 'Hi').then(console.log); // Hi!
p1.then(constant('Hi')).then(console.log); // Hi!
p1.then('Hi').then(console.log); // Hello!
複製程式碼
由於Promise.prototype.then
只接受函式,如果我僅僅只需要傳遞一個值時,那麼constant
便會提供這種便利。當然這個並沒有什麼功能上的提升,但是的確提高了可閱讀性,也是函數語言程式設計的一個優點。
spreadArgs
& gatherArgs
const spreadArgs = fn => argsArr => fn( ...argsArr );
const gatherArgs = fn => (...argsArr) => fn( argsArr );
複製程式碼
嗯這兩個函式見名知義。分別用於展開一個函式的所有引數和收集一個函式所有引數,這兩個函式明顯對立,那麼它們的應用場景又是什麼呢?
spreadArgs函式示例如下:
function cube(x, y, z) {
return x * y * z;
}
function make(fn, points) {
return fn(points);
}
console.log(make(cube, [3, 4, 5])); // NaN
console.log(make(spreadArgs(cube), [3, 4, 5])); // 60
複製程式碼
gatherArgs函式示例如下:
function combineFirstTwo([v1, v2]) {
return v1 + v2;
}
console.log([1, 2, 3, 4, 5].reduce(combineFirstTwo)); // Uncaught TypeError
console.log([1, 2, 3, 4, 5].reduce(gatherArgs(combineFirstTwo))); // 15
複製程式碼
看完以上程式碼,簡單的兩個工具函式,輕易的做到了對一個函式的轉換,從而使其適用於另一個場景。如果從此應該可以瞥見函數語言程式設計的一點點魅力,那麼下面的兩個函式將給大家帶來更多的驚喜。
partial
& curry
const partial = (fn, ...presetArgs) => (...laterArgs) =>
fn(...presetArgs, ...laterArgs);
const curry = (fn, arity = fn.length, nextCurried) =>
(nextCurried = prevArgs => nextArg => {
const args = [...prevArgs, nextArg];
if (args.length >= arity) {
return fn(...args);
} else {
return nextCurried(args);
}
})([]);
複製程式碼
相信大家對函式柯里化應該或多或少有點了解。維基百科定義:
在電腦科學中,柯里化(英語:Currying),又譯為卡瑞化或加里化,是把接受多個引數的函式變換成接受一個單一引數(最初函式的第一個引數)的函式,並且返回接受餘下的引數而且返回結果的新函式的技術。
當然得益於閉包的強大威力,柯里化這個武器得以誕生於Javascript世界。請大家先精讀以上關於partiel、curry函式的程式碼。
喝一杯咖啡~
先模擬一個ajax函式如下:
function ajax(url, params, callback) {
setTimeout(() => {
callback(
`GET ${url} \nparams: ${params} \ndata: Hello! ${params} `
);
});
}
複製程式碼
考慮partial使用場景如下:
const fetchPerson = partial( ajax, "http://some.api/person" );
fetchPerson('Teddy Bear', console.log);
/*
GET http://some.api/person
params: Teddy Bear
data: Hello! Teddy Bear
*/
複製程式碼
考慮curry使用場景如下:
const fetchPerson = curry(ajax)('http://some.api/person');
const fetchUncleBarney = fetchPerson('Uncle Barney');
fetchUncleBarney(console.log);
/*
GET http://some.api/person
params: Uncle Barney
data: Hello! Uncle Barney
*/
複製程式碼
partial和curry函式功能相似,但又有具體的不同應用場景,但總體來說curry會比partial更自動化一點。
但是!相信看完示例的同學又會有一連串問號?為什麼好好地引數不一次性傳入,而非要分開多次傳入這麼麻煩?原因如下:
- 最首要的原因是partial和curry函式都允許我們通過引數控制將一個函式的呼叫在時間和空間上分開了。傳統函式需要一次性將引數湊齊才能呼叫,但是有時候我們可以提前預置部分引數,在最終需要觸發此函式時,才將剩餘引數傳入。這時候partial和curry就會變得十分有用。
- partial和curry的存在讓函式組合(compose)會更加便利。(函式組合也計劃之後和大家分享,這裡就不詳細說了)。
- 當然最重要是也提升了可閱讀性!一開始可能不這麼以為,但是如果你實踐操作感受之後,也許會改觀。
P.S. 關於函數語言程式設計的實踐,大家可以使用lodash/fp
模組進行入門實踐。
一些思考
因為我也是函數語言程式設計的初學者,如有不正確的地方,歡迎大家糾正~
接下來還是會繼續整理FP的學習資料,學習實踐,連載一些我對於函數語言程式設計的學習與思考,希望和大家一起進步~
謝謝大家(●´∀`●)~