JavaScript 函數(shù)式編程初窺

編程范式

編程范式是:解決編程中的問題的過程中使用到的一種模式,體現(xiàn)在思考問題的方式和代碼風(fēng)格上。這點很像語言,語言本身會體現(xiàn)出不同國家的人的思考方式和行為模式。

常見的編程范式有下面幾種:

  • 命令式編程
  • 面向?qū)ο缶幊?/li>
  • 函數(shù)式編程

除了這三個之外,我們還會接觸到其他的編程范式,如:聲明式。

編程范式之間不是互斥關(guān)系,而是可以結(jié)合在一起使用的。我們往往需要結(jié)合各種編程范式來完成一個程序功能。

在學(xué)習(xí)寫代碼的過程中,我們一般先接觸命令式編程,然后學(xué)習(xí)面向?qū)ο缶幊?,面向?qū)ο缶幊炭梢宰屛覀兒芊奖愕靥幚砀鼜?fù)雜的問題。這篇文章里,我們會介紹函數(shù)式編程。

不同的編程范式有不同的代碼表現(xiàn)

image.png

比如從來沒有坐過電梯的人,第一次坐電梯,電梯在 10 樓,人在 1 樓,他會按下,讓電梯下來。按錯按鈕是因為他用了祈使語,而不是把自己的想法提交出去。

相似地,你寫的代碼就像電梯的按鈕界面,是讓自己或者他人閱讀的。只有達(dá)成了相同的共識才能更好地理解。通過這次文章可以讓大家更好地理解函數(shù)式編程。

下面是幾種編程范式的代碼片段:

const app = 'github';
const greeting = 'Hi, this is ';
console.log(greeting + app);

這是命令式編程,通過調(diào)用 constconsole.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ù)式編程相比于命令式編程有更好的可讀性。

image.png

從命令式的編程到函數(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 聲明,作為全局變量,也就是這里說的“第一公民”。我們不再使用 varconst 或者 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);

mapreduce 是高階函數(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、pushshiftunshiftreverse 以及 fill 修改數(shù)組
  • 不修改對象屬性或方法

使用不可變數(shù)據(jù)的弊端

不可變數(shù)據(jù)有更多內(nèi)存開銷。

image.png

修改數(shù)據(jù)的情況下,直接替換了變量的值,內(nèi)存開銷不變。

image.png

使用不可變數(shù)據(jù)后,我們復(fù)制了一個對象,內(nèi)存開銷翻倍。

image.png

使用 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,reverseArgscurryuncurry 是用來進(jìn)行參數(shù)操作的。composepipeasyncPipe 是用來進(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ù)。

參考資料

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,333評論 6 531
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 98,491評論 3 416
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事?!?“怎么了?”我有些...
    開封第一講書人閱讀 176,263評論 0 374
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經(jīng)常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,946評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當(dāng)我...
    茶點故事閱讀 71,708評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 55,186評論 1 324
  • 那天,我揣著相機(jī)與錄音,去河邊找鬼。 笑死,一個胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,255評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 42,409評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 48,939評論 1 335
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 40,774評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,976評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,518評論 5 359
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 44,209評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,641評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,872評論 1 286
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機(jī)就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,650評論 3 391
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 47,958評論 2 373

推薦閱讀更多精彩內(nèi)容

  • 原文鏈接:https://github.com/EasyKotlin 值就是函數(shù),函數(shù)就是值。所有函數(shù)都消費(fèi)函數(shù),...
    JackChen1024閱讀 6,015評論 1 17
  • 第2章 基本語法 2.1 概述 基本句法和變量 語句 JavaScript程序的執(zhí)行單位為行(line),也就是一...
    悟名先生閱讀 4,184評論 0 13
  • 函數(shù)和對象 1、函數(shù) 1.1 函數(shù)概述 函數(shù)對于任何一門語言來說都是核心的概念。通過函數(shù)可以封裝任意多條語句,而且...
    道無虛閱讀 4,611評論 0 5
  • 上期防組三中 獨(dú)膽9 雙膽89 三碼589 四碼5689防組三及豹子 五碼56789防組三及豹子 小復(fù)式78/56...
    南宮尹馨閱讀 400評論 2 3
  • 朔風(fēng)初起送清寒,曾經(jīng)繁華又寂然。萬木枯落盡霜染,向晚殘照戀暮山。 樽酒淡,意闌珊,如鉤弦月冷穹天。夜深幽夢應(yīng)...
    柏蓮華閱讀 969評論 0 3