編程范式
編程范式是:解決編程中的問題的過程中使用到的一種模式,體現(xiàn)在思考問題的方式和代碼風(fēng)格上。這點很像語言,語言本身會體現(xiàn)出不同國家的人的思考方式和行為模式。
常見的編程范式有下面幾種:
- 命令式編程
- 面向?qū)ο缶幊?/li>
- 函數(shù)式編程
除了這三個之外,我們還會接觸到其他的編程范式,如:聲明式。
編程范式之間不是互斥關(guān)系,而是可以結(jié)合在一起使用的。我們往往需要結(jié)合各種編程范式來完成一個程序功能。
在學(xué)習(xí)寫代碼的過程中,我們一般先接觸命令式編程,然后學(xué)習(xí)面向?qū)ο缶幊?,面向?qū)ο缶幊炭梢宰屛覀兒芊奖愕靥幚砀鼜?fù)雜的問題。這篇文章里,我們會介紹函數(shù)式編程。
不同的編程范式有不同的代碼表現(xiàn)
比如從來沒有坐過電梯的人,第一次坐電梯,電梯在 10 樓,人在 1 樓,他會按下,讓電梯下來。按錯按鈕是因為他用了祈使語,而不是把自己的想法提交出去。
相似地,你寫的代碼就像電梯的按鈕界面,是讓自己或者他人閱讀的。只有達(dá)成了相同的共識才能更好地理解。通過這次文章可以讓大家更好地理解函數(shù)式編程。
下面是幾種編程范式的代碼片段:
const app = 'github';
const greeting = 'Hi, this is ';
console.log(greeting + app);
這是命令式編程,通過調(diào)用 const
和 console.log
進(jìn)行賦值和輸出。
const Program = function() {
this.app = 'github';
this.greeting = function() {
console.log('Hi, this is ' + this.app);
};
};
const program = new Program();
program.greeting();
這是面向?qū)ο缶幊?,我們把整個程序抽象成現(xiàn)實生活中的一個對象,這個對象會包含屬性和方法。通過類的概念,我們有了生成對象的一個工廠。使用 new
關(guān)鍵字創(chuàng)建一個對象,最后調(diào)用對象的方法,也能完成剛才我們用命令式編程完成的程序功能。
const greet = function(app) {
return 'Hi, this is ' + app;
};
console.log(greet('github'));
這是簡單的函數(shù)式編程,通過函數(shù)的調(diào)用完成程序的功能。但是一般情況下的函數(shù)式編程會更復(fù)雜一些,會包含函數(shù)的組合。
不同的編程范式適用的場景不同
- 命令式編程:流程順序
- 面向?qū)ο缶幊蹋何矬w
- 函數(shù)式語言:數(shù)據(jù)處理
我們往往會在不同場景下使用不同的編程范式,通過編程范式的結(jié)合來實現(xiàn)一個程序。
我們通過命令式編程去讓程序按步驟地執(zhí)行操作。
面向?qū)ο缶幊虅t是把程序抽象成和現(xiàn)實生活中的物體一樣的對象,對象上有屬性和方法,通過對象之間的修改屬性和調(diào)用方法來完成程序設(shè)計。
而函數(shù)式編程則適用于數(shù)據(jù)運(yùn)算和處理。
再仔細(xì)看下之前的代碼,我們就會發(fā)現(xiàn)這些編程范式往往是要結(jié)合起來使用的。
const app = 'github';
const greeting = 'Hi, this is ';
console.log(greeting + app);
這個例子里面,除了命令式之外,我們還可以把前兩句語句賦值解讀成聲明式編程。
const Program = function(app) {
this.app = app;
this.greeting = function() {
console.log('Hi, this is ' + this.app);
};
};
const program = new Program('github');
program.greeting();
這里例子里面,我們看到在類的 greeting
方法里面也用到了命令式的 console.log
。在最后的執(zhí)行過程中的 program.greeing()
也是命令式的。
const greet = function(app) {
return 'Hi, this is ' + app;
};
console.log(greet('github'));
函數(shù)式編程
使用函數(shù)式編程可以大大提高代碼的可讀性。
函數(shù)式編程的學(xué)習(xí)曲線
你編寫的每一行代碼之后都要有人來維護(hù),這個人可能是你的團(tuán)隊成員,也可能是未來的你。如果代碼寫的太過復(fù)雜,那么無論誰來維護(hù)都會對你炫技式的故作聰明的做法倍感壓力。
對于復(fù)雜計算的場景下,使用函數(shù)式編程相比于命令式編程有更好的可讀性。
從命令式的編程到函數(shù)式編程轉(zhuǎn)換的道路上,可讀性會變低,但是一旦度過了一個坎,也就是在你大量使用函數(shù)式編程時,可讀性便會大大提升??墒俏覀兺鶗贿@個坎阻撓,在發(fā)現(xiàn)可讀性下降后放棄學(xué)習(xí)函數(shù)式編程。
因此除了學(xué)習(xí)函數(shù)式編程本身的知識之外,我們還需要明白學(xué)習(xí)可能經(jīng)歷的過程和最終的結(jié)果。
函數(shù)式編程定義
函數(shù)是第一公民。
JavaScript 是一門在設(shè)計之處就完全支持函數(shù)式編程的語言,在 JavaScript 里面,函數(shù)可以用 function
聲明,作為全局變量,也就是這里說的“第一公民”。我們不再使用 var
、const
或者 let
等關(guān)鍵字聲明函數(shù)。我們也會大大減少變量的聲明,通過函數(shù)的形參來替代變量的聲明。
函數(shù)式編程大量通過函數(shù)的組合進(jìn)行邏輯處理,因此我們在后面會看到很多輔助函數(shù)。通過這些輔助函數(shù),我們可以更方便得修改和組合函數(shù)。
什么是函數(shù)?
一個函數(shù)就是包含輸入和輸出的方程。數(shù)據(jù)流方向是從輸入到輸出。
在數(shù)學(xué)里面我們學(xué)到的函數(shù)是這樣的:
y = f(x)
在 JavaScript 里面,函數(shù)是這樣表示的:
function(x) { return y; }
代碼中的函數(shù)和數(shù)學(xué)意義上的函數(shù)概念是一樣的。
函數(shù)和程序的區(qū)別
- 程序是任意功能的合集,可以沒有輸入值,可以沒有輸出值。
- 函數(shù)必須有輸入值和輸出值。
函數(shù)適合的場景
函數(shù)適合:數(shù)學(xué)運(yùn)算。不適合:與真實世界互動。
實際的編程需要修改硬盤等。如果不改變東西,等于什么都沒做。也就沒辦法完成任務(wù)了。
JavaScript 和函數(shù)式編程
JavaScript 支持函數(shù)式編程。使用 JavaScript 進(jìn)行函數(shù)式編程時,我們要使用 JavaScript 的子集。不使用 for 循環(huán), Math.random, Date, 不修改數(shù)據(jù),來避免副作用,做到函數(shù)式編程。
下面,面向 JavaScript 開發(fā)者,介紹在 JavaScript 函數(shù)式編程中用到的一些概念。
高階函數(shù)
高階函數(shù)是由一個或多個函數(shù)作為輸入的函數(shù),或者是輸出一個函數(shù)的函數(shù)。
[1, 2, 3].map(function(item, index, array) {
return item * 2;
});
[1, 2, 3].reduce(function(accumulator, currentValue, currentIndex, array) {
return accumulator + currentValue;
}, 0);
map
和 reduce
是高階函數(shù),它接收一個函數(shù)作為參數(shù)。
純函數(shù)
純函數(shù)有兩個特點:
- 純函數(shù)是冪等的
- 純函數(shù)沒有副作用
純函數(shù)是可靠的,可預(yù)測結(jié)果。帶來了可讀性和可維護(hù)性。
冪等
冪等是指函數(shù)任意多次執(zhí)行所產(chǎn)生的影響和一次執(zhí)行的影響相同。函數(shù)的輸入和輸出都需要冪等。
function add(a, b) {
return a + b;
}
上面的函數(shù)是冪等的。
function add(a) {
return a + Math.random();
}
上面使用了隨機(jī)數(shù),每次執(zhí)行得到的結(jié)果不同,所以這個函數(shù)不冪等。
var a = 1;
function add(b) {
return a + b;
}
上面使用到函數(shù)外部的數(shù)據(jù),當(dāng)外部數(shù)據(jù)變化時,函數(shù)執(zhí)行的結(jié)果不再相同,所以這個函數(shù)不冪等。
var c = 1;
function add(a, b) {
c++;
return a + b;
}
上面的函數(shù)修改了函數(shù)外部的數(shù)據(jù),所以也不冪等。
副作用
副作用是當(dāng)調(diào)用函數(shù)時,除了返回函數(shù)值之外,還對主調(diào)用函數(shù)產(chǎn)生附加的影響。
最常見的副作用是 I/O(輸入/輸出)。對于前端來說,用戶事件(鼠標(biāo)、鍵盤)是 JS 編程者在瀏覽器中使用的典型的輸入,而輸出的則是 DOM。如果你使用 Node.js 比較多,你更有可能接觸到和輸出到文件系統(tǒng)、網(wǎng)絡(luò)系統(tǒng)和/或者 stdin / stdout(標(biāo)準(zhǔn)輸入流/標(biāo)準(zhǔn)輸出流)的輸入和輸出。
純函數(shù)
一個純函數(shù)需要滿足下面兩個條件:
- 純函數(shù)是冪等的
- 純函數(shù)沒有副作用
不可變數(shù)據(jù)
不可變數(shù)據(jù)是指保持一個對象狀態(tài)不變。
值的不可變性并不是不改變值。它是指在程序狀態(tài)改變時,不直接修改當(dāng)前數(shù)據(jù),而是創(chuàng)建并追蹤一個新數(shù)據(jù)。這使得我們在讀代碼時更有信心,因為我們限制了狀態(tài)改變的場景,狀態(tài)不會在意料之外或不易觀察的地方發(fā)生改變。
在函數(shù)式和非函數(shù)式編程中,不可變數(shù)據(jù)對我們都有幫助。
使用不可變數(shù)據(jù)的準(zhǔn)則
- 使用
const
,不使用let
- 不使用
splice
、pop
、push
、shift
、unshift
、reverse
以及fill
修改數(shù)組 - 不修改對象屬性或方法
使用不可變數(shù)據(jù)的弊端
不可變數(shù)據(jù)有更多內(nèi)存開銷。
修改數(shù)據(jù)的情況下,直接替換了變量的值,內(nèi)存開銷不變。
使用不可變數(shù)據(jù)后,我們復(fù)制了一個對象,內(nèi)存開銷翻倍。
使用 immutableJS 等輔助庫后,可以更好地利用之前的數(shù)據(jù),優(yōu)化了內(nèi)存開銷。
閉包 vs 對象
閉包和對象是一樣?xùn)|西的兩種表達(dá)方式。一個沒有閉包的編程語言可以用對象來模擬閉包;一個沒有對象的編程語言可以用閉包來模擬對象。兩者都可以用來維護(hù)數(shù)據(jù)。
var obj = {
one: 1,
two: 2
};
function run() {
return this.one + this.two;
}
var three = run.bind(obj);
three(); // => 3
function getRun() {
var one = 1;
var two = 2;
return function run(){
return one + two;
};
}
var three = getRun();
three(); // => 3
上面兩種方式都可以用來完成程序功能,對象和函數(shù)之間可以轉(zhuǎn)換。
常見的輔助函數(shù)
unary
reverseArgs
curry
uncurry
compose
pipe
asyncPipe
unary
,reverseArgs
,curry
和 uncurry
是用來進(jìn)行參數(shù)操作的。compose
,pipe
和 asyncPipe
是用來進(jìn)行函數(shù)組合的。
unary
unary
是用來限制某個函數(shù)只接收一個參數(shù)的。常見的使用場景是處理 parseInt
函數(shù):
['1', '2', '3'].map(parseInt);
// => [1, NaN, NaN]
['1', '2', '3'].map(unary(parseInt));
// => [1, 2, 3]
unary
的實現(xiàn)方式可以是:
const unary = (fn) => {
return (arg) => {
return fn(arg);
};
};
reverseArgs
reverseArgs
是用來講函數(shù)參數(shù)反轉(zhuǎn)的,實現(xiàn)方式如下:
const reverseArgs = (fn) => {
return (...args) => {
return fn(...args.reverse());
};
};
curry
curry
是用來把函數(shù)執(zhí)行滯后的,讓我們可以逐步把參數(shù)傳入這個函數(shù),當(dāng)參數(shù)完整之后,目標(biāo)函數(shù)才會執(zhí)行。常見的用法如下:
function add(a, b) {
return a + b;
}
function add10(a) {
return add(10, a);
}
add10(1); // => 11
通過 curry
函數(shù),可以把上面的代碼優(yōu)化一下:
function add(a, b) {
return a + b;
}
const curriedAdd = curry(add);
const add10 = curriedAdd(10);
add10(1); // => 11
curry
的實現(xiàn)思路如下:
把 args
,保存起來,每個 curried
函數(shù)接受一個參數(shù),將參數(shù)拼在之前的參數(shù)后面。
const curry = (fn) => {
const curried = (curArg) => {
const args = [...prevArgs, curArg];
return curried;
};
return curried;
};
修改成用閉包保存參數(shù)。
const curry = (fn) => {
return (curArg) => {
const args = [...prevArgs, curArg];
return nextCurried(...args);
};
};
遞歸調(diào)用 nextCurried
,第一次柯里化的函數(shù)不傳入?yún)?shù)。
const curry = (fn) => {
const nextCurried = (...prevArgs) => {
return (curArg) => {
const args = [...prevArgs, curArg];
return nextCurried(...args);
};
};
return nextCurried();
};
最后補(bǔ)全 arity
參數(shù),來定義目標(biāo)函數(shù)的參數(shù)數(shù)量。這樣,我們可以定義柯里化后的參數(shù)數(shù)量,如果傳入的參數(shù)數(shù)量到了函數(shù)需要的數(shù)量,則直接執(zhí)行函數(shù),并傳入所有的參數(shù)。
const curry = (fn, arity = fn.length) => {
const nextCurried = (...prevArgs) => {
return (curArgs) => {
const args = [...prevArgs, curArgs];
if (args.length >= arity) {
return fn(...args);
}
return nextCurried(...args);
};
};
return nextCurried();
};
或者我們可以實現(xiàn)一個支持傳入多個參數(shù)的柯里化函數(shù):
const curry = (fn, arity = fn.length) => {
const nextCurried = (...prevArgs) => {
return (...curArgs) => {
const args = [...prevArgs, ...curArgs];
if (args.length >= arity) {
return fn(...args);
}
return nextCurried(...args);
};
};
return nextCurried();
};
compose
compose
用來串聯(lián)執(zhí)行函數(shù),執(zhí)行順序是從后向前的。與之對應(yīng)的是 pipe
函數(shù),同樣是串聯(lián)執(zhí)行函數(shù),但是執(zhí)行順序是從前向后的。
compose
的用法:
function add10(value) {
return value + 10;
}
function multiple10(value) {
return value * 10;
}
const add10AndMultiple10 = compose(multiple10, add10);
add10AndMultiple10(1); // => 110
compose
的實現(xiàn):
const compose = (...fns) => {
return fns.reduce((a, b) => {
return (...args) => {
return a(b(...args));
};
});
};
或者通過 reduceRight
簡單地從右邊向左執(zhí)行,這是更好理解的一種實現(xiàn),但是有參數(shù)個數(shù)的限制。
const compose = (...fns) => {
return (input) => {
return fns.reduceRight((value, fn) => {
return fn(value);
}, input);
};
};
pipe
pipe
也是用來組合函數(shù)的,串聯(lián)執(zhí)行的順序是從前向后,與 compose
相反。pipe
的實現(xiàn)可以是:
const pipe = (...fns) => {
return fns.reduceRight((a, b) => {
return (...args) => {
return a(b(...args));
}
});
};
pipe
的用法如下:
const addA = (value) => {
return value + 'A';
};
const addB = (value) => {
return value + 'B';
};
pipe(addA, addB)('1') // => 1AB
asyncPipe
對于異步函數(shù)來說,如果我們要串聯(lián)執(zhí)行,可以使用 asyncPipe
。實現(xiàn)可以是:
const asyncPipe = (...fns) => {
return fns.reduceRight((next, fn) => {
return (...args) => {
fn(...args, next);
};
}, () => {});
};
用法是:
const addA = (value, next) => {
next(value + 'A', 'a');
};
const addB = (value, anotherValue, next) => {
console.log(anotherValue); // => a
next(value + 'B');
};
const consoleLog = (value, next) => {
console.log(value);
};
asyncPipe(addA, addB, consoleLog)('1'); // => 1AB
函數(shù)式編程在數(shù)據(jù)結(jié)構(gòu)上的運(yùn)用
實現(xiàn)鏈表
主要思路是用函數(shù)閉包代替對象保存數(shù)據(jù)。
const createNode = (value, next) => {
return (x) => {
if (x) {
return value;
}
return next;
};
};
const getValue = (node) => {
return node(true);
};
const getNext = (node) => {
return node(false);
};
const append = (next, value) => {
if (next === null) {
return createNode(value, null);
}
return createNode(getValue(next), append(getNext(next), value));
};
const reverse = (linkedList) => {
if (linkedList === null) {
return null;
}
return append(reverse(getNext(linkedList)), getValue(linkedList));
};
const linkedList1 = createNode(1, createNode(2, createNode(3, null)));
const linkedList2 = reverse(linkedList1);
getValue(linkedList1); // => 1
getValue(getNext(linkedList1)); // => 2
getValue(getNext(getNext(linkedList1))); // => 3
getValue(linkedList2); // => 3
getValue(getNext(linkedList2)); // => 2
getValue(getNext(getNext(linkedList2))); // => 1
同樣可以用函數(shù)式編程實現(xiàn)二叉樹。
總結(jié)
希望大家能夠通過學(xué)習(xí)函數(shù)式編程范式,加深對軟件研發(fā)的理解,開拓視野,找到更多組織代碼方式。
函數(shù)式編程能夠更好地組織業(yè)務(wù)代碼中的數(shù)據(jù)處理,更多地復(fù)用了函數(shù),減少了中間變量。
但是函數(shù)式編程也有缺點,它增加了學(xué)習(xí)成本,需要大家理解高階函數(shù)。
參考資料
- Anjana Vakil: Learning Functional Programming with JavaScript - JSUnconf 2016
- Anjana Vakil: Immutable data structures for functional JS - JSConf EU 2017
- JavaScript 輕量級函數(shù)式編程
- Douglas Crockford: Monads and Gonads (YUIConf Evening Keynote)
- A Brief Intro to Functional Programming
- JavaScript 中的函數(shù)式編程