別讓你的偏愛拖了後腿:快擁抱箭頭函式吧!
我以教 JavaScript 為生。最近我給學生上了柯里化箭頭函式這個課程——這還是最開始的幾節課。我認為它是一個很好用的技能,因此將這個內容提到了課程的前面。而學生們沒有讓我失望,比我想象中地更快地掌握了使用箭頭函式進行柯里化。
如果學生們能夠理解它,並且能儘快由它獲益,為什麼不早點將箭頭函式教給他們呢?
Note:我的課程並不適合那些從來沒有接觸過程式碼的人。大多數學生在加入我們的課程之前至少有幾個月的程式設計經歷——無論他們是自學,還是通過培訓班學習,或者本身就是專業的。然而,我發現許多隻有一點經驗或者沒有經驗的年輕開發者們能夠很快地接受這些主題。
我看到很多的學生在上了 1 小時的課之後就能很熟練地使用箭頭函式工作了。(如果你是“和 Eric Elliott 一起學習 JavaScript”培訓班的同學,你可以看這個約 55 分鐘的視訊——ES6 的柯里化與組合)。
看到學生們如此之快地掌握與應用他們新發現的柯里化方法,我想起了我在推特上發了柯里化箭頭函式的帖子,然後被一群人噴“可讀性差”的事。我很驚訝為什麼他們會堅持這個觀點。
首先,我們先來看看這個例子。我在推特發了這個函式,然後我發現有人強烈反對這種寫法:
const secret = msg => () => msg;複製程式碼
我對有人在推特上指責我在誤導別人感到不可思議。我寫這個函式是為了示範在 ES6 中寫柯里化函式是多麼的簡單。它是我能想到的在 JavaScript 中最簡單的實際運用與閉包表示式了。(相關閱讀:什麼是閉包)
它和下面的函式表示式等價:
const secret = function (msg) {
return function () {
return msg;
};
};複製程式碼
secret()
是一個函式,它需要傳入 msg
這個引數,然後會返回一個新的函式,這個函式將會返回 msg
的值。無論你向 secret()
中傳入什麼值,它都會利用閉包固定 msg
的值。
你可以這麼用它:
const mySecret = secret('hi');
mySecret(); // 'hi'複製程式碼
事實證明,雙箭頭並沒有讓人感到困惑。我堅信:
對於熟悉的人來說,單行的箭頭函式是 JavaScript 表達柯里化函式最具有可讀性的方法了。
有許多人指責我,告訴我將程式碼寫的長一些比簡短的程式碼更容易閱讀。他們有時也許是對的,但是大多數情況都錯了。更長、更詳細的程式碼不一定更容易閱讀——至少,對熟悉箭頭函式的人來說就是如此。
我在推特上看到的持反對意見的人,並沒有像我的學生一樣享受平滑的學習箭頭函式的過程。在我的經驗裡,學生學習柯里化箭頭函式就像魚在水裡生活一樣。僅僅學了幾天,他們就開始使用箭頭了。它幫助學生們輕鬆地跨過了各種程式設計問題的鴻溝。
我沒有看到學習、閱讀、理解箭頭函式對那些學生造成了任何的“困難”——一旦他們決定學習,只要上個大概一小時的課就能基本掌握。
他們能夠很輕鬆地讀懂柯里化箭頭函式,儘管他們從來沒有見過這類的東西,他們還是能夠告訴我這些函式做了什麼事。當我給他們佈置任務後他們也能夠很自如地自己完成任務。
從另一方面說,他們能夠很快熟悉柯里化箭頭函式,並且沒有為此產生任何問題。他們閱讀這些函式就像你讀一句話一樣,他們對其的理解讓他們寫出了更簡單、更少 bug 的程式碼。
為什麼一些人認為傳統的函式表示式看起來“更具有可讀性”?
偏愛是一種顯著的人類認知偏差,它會讓我們在有更好的選擇的情況下做出自暴自棄的選擇。我們會因此無視更舒服更好的方法,習慣性地選用以前使用過的老方法。
你可以從這本書中更詳細地瞭解“偏愛”這種心理:《The Undoing Project: A Friendship that Changed Our Minds》(很多情況都是我們自欺欺人)。每個軟體工程師都應該讀一讀這本書,因為它會鼓勵你辯證地去看待問題,以及鼓勵你多對假設進行實驗,以免掉入各種認知陷阱中。書中那些發現認知陷阱的故事也很有趣。
傳統的函式表示式可能會在你的程式碼中導致 Bug 的出現
今天我用 ES5 的語法重寫了一個 ES6 寫的柯里化箭頭函式,以便釋出開源模組讓人們無需編譯就能在老瀏覽器中用。然而 ES5 版本讓我震驚。
ES6 版本的程式碼非常簡短、簡介、優雅——僅僅只需要 4 行。
我覺得,這件事可以發個推特,告訴大家箭頭函式是一種更加優越的實現,是時候如同放棄自己的壞習慣一樣,放棄傳統函式表示式的寫法了。
所以我發了一條推特:
為了防止你看不清圖片,下面貼上這個函式的文字:
// 使用箭頭函式柯里化
const composeMixins = (...mixins) => (
instance = {},
mix = (...fns) => x => fns.reduce((acc, fn) => fn(acc), x)
) => mix(...mixins)(instance);
// 對比一下 ES5 風格的程式碼:
var composeMixins = function () {
var mixins = [].slice.call(arguments);
return function (instance, mix) {
if (!instance) instance = {};
if (!mix) {
mix = function () {
var fns = [].slice.call(arguments);
return function (x) {
return fns.reduce(function (acc, fn) {
return fn(acc);
}, x);
};
};
}
return mix.apply(null, mixins)(instance);
};
};複製程式碼
這裡的函式封裝了一個 pipe()
,它是標準的函數語言程式設計的工具函式,通常用於組合函式。這個 pipe()
函式在 lodash 中是 lodash/flow
,在 Ramda 中是 R.pipe()
,在一些函數語言程式設計語言中它甚至本身就是一個運算子號。
每個熟悉函數語言程式設計的人都應該很熟悉它。它的實現主要依賴於Reduce。
在這個例子中,它用來組合混合函式,不過這點無關緊要(有專門寫這方面的部落格文章)。我們需要注意是以下幾個重要的細節:
這個函式可以將任何數量的函式混合,最終返回一個函式,這個函式在管道中應用了其它的函式——就像流水線一樣。每個混合函式都將例項(instance
)作為輸入,然後在將自己傳遞給管道中下一個函式之前,將一些變數傳入。
如果你沒有傳入 instance
,它將會為你建立一個新的物件。
有時你可能會想用別的混合方式。例如,使用 compose()
代替 pipe()
來傳遞函式,讓組合順序反過來。
如果你不需要自定義函式混合時的行為,你可以簡單地使用預設設定,使用 pipe()
來完成過程。
事實
除了可讀性的區別之外,以下列舉了一些與這個例子有關的客觀事實:
- 我有多年的 ES5 與 ES6 程式設計經驗,無論是箭頭函式表示式還是別的函式表示式我都很熟悉。因此“偏愛”對我來說不是一個變化無常的因素。
- 我沒幾秒就寫好了 ES6 版本的程式碼,它沒有任何 bug(它通過了所有的單元測試,因此我敢肯定這點)。
- 寫 ES5 版本的程式碼花了我好幾分鐘。一個是幾秒,一個是幾分鐘,差距還是挺大的。寫 ES5 程式碼時,我有 2 次弄錯了函式的作用範圍;寫出了 3 個 bug,然後要花時間去分別除錯與修復;還有 2 次我不得不使用
console.log()
來弄清函式執行的情況。 - ES6 版本程式碼僅僅只有 4 行。
- ES5 版本程式碼有 21 行(其中真正有程式碼的有 17 行)。
- 儘管 ES5 版本的程式碼更加冗長,但是它比起 ES6 版本的程式碼來說仍然缺少了一些資訊。它雖然長,但是表達的東西更少。這個問題在後面會提到。
- ES6 版本程式碼在程式碼中有 2 個 speard 運算子。而 ES5 版本程式碼中沒有這個運算子,而是使用了意義晦澀的
arguments
物件,它將嚴重影響函式內容的可讀性。(不推薦原因之一) - ES6 版本程式碼在函式片段中定義了
mix
的預設值,由此你可以很清楚地看到它是引數的值。而 ES5 版本程式碼卻混淆了這個細節問題,將它隱藏在函式體中。(不推薦原因之二) - ES6 版本程式碼僅有 2 層程式碼塊,這將會幫助讀者理解程式碼結構,以及知道如何去閱讀這個程式碼。而 ES5 程式碼有 6 層程式碼塊,複雜的層級結構會讓函式結構的可讀性變得很差。(不推薦原因之三)
在 ES5 版本程式碼中,pipe()
佔據了函式體的大部分內容——要把它們放到同一行中去簡直是個荒唐的想法。非常有必要將 pipe()
這個函式單獨抽離出來,讓我們的 ES5 版本程式碼更具有可讀性:
var pipe = function () {
var fns = [].slice.call(arguments);
return function (x) {
return fns.reduce(function (acc, fn) {
return fn(acc);
}, x);
};
};
var composeMixins = function () {
var mixins = [].slice.call(arguments);
return function (instance, mix) {
if (!instance) instance = {};
if (!mix) mix = pipe;
return mix.apply(null, mixins)(instance);
};
};複製程式碼
這樣,我覺得它更具可讀性,並且更容易理解它的意思了。
讓我們看看如果我們對 ES6 版本程式碼做一些可讀性“優化”會怎麼樣:
const pipe = (...fns) => x => fns.reduce((acc, fn) => fn(acc), x);
const composeMixins = (...mixins) => (
instance = {},
mix = pipe
) => mix(...mixins)(instance);複製程式碼
就像 ES5 版本程式碼的優化一樣,這個“優化”後的程式碼更加冗長(它加入了之前沒有的新變數)。與 ES5 版本程式碼不同,這個版本在將管道的概念抽象出來後並沒有明顯的提高程式碼可讀性。不過畢竟函式裡已經清楚的寫明瞭 mix
這個變數,它還是更容易讓人理解一些。
mix
的定義本身在它的那一行就已經存在了,它不太可能會讓閱讀程式碼的人找不到何時結束 mix
、剩下的程式碼何時執行。
而現在我們用了 2 個變數來表示同一個東西。我們因此而獲益了嗎?完全沒有。
那麼為什麼 ES5 函式在對函式進行抽象之後會變得更具可讀性呢?
因為之前 ES5 版本的程式碼明顯更復雜。這種複雜度的來源是我們討論的問題重點。我可以斷言,它的複雜度的來源歸根結底就是語法干擾,這種語法干擾只會讓函式的本身含義變得費解,並沒有別的用處。
讓我們換種方法,把一些多餘的變數去掉,在例子中都使用 ES6 程式碼,只比較箭頭函式與傳統函式表示式:
var composeMixins = function (...mixins) {
return function (
instance = {},
mix = function (...fns) {
return function (x) {
return fns.reduce(function (acc, fn) {
return fn(acc);
}, x);
};
}
) {
return mix(...mixins)(instance);
};
};複製程式碼
現在,至少我覺得它的可讀性顯著的提升了。我們利用 rest 語法以及預設引數語法對它進行了修改。當然,你得對 rest 語法和預設引數語法很熟悉才會覺得這個版本的程式碼更可讀。不過即使你不瞭解這些,我覺得這個版本也會看起來更加有條理。
現在已經改進了許多了,但是我覺得這個版本還是比較簡潔。將 pipe()
抽象出來,寫到它自己的函式裡可能會有所幫助:
const pipe = function (...fns) {
return function (x) {
return fns.reduce(function (acc, fn) {
return fn(acc);
}, x);
};
};
// 傳統函式表示式
const composeMixins = function (...mixins) {
return function (
instance = {},
mix = pipe
) {
return mix(...mixins)(instance);
};
};複製程式碼
這樣是不是更好了?現在 mix
只佔了單獨的一樣,函式結構也更加的清晰——但是這樣做不符合我的胃口,它的語法干擾實在是太多了。在現在的 composeMixins()
中,我覺得描述一個函式在哪結束、另一個函式從哪開始還不夠清楚。
除了呼叫函式體之外,funcion
這個關鍵字似乎和其它的程式碼混淆在一起了。我的函式的真正的功能被隱藏了起來!引數的呼叫和函式體的起始到底在哪裡?如果我仔細看也能夠分析出來,但是它對我來說實在是不容易閱讀。
那麼如果我們去掉 function
這個關鍵字,然後通過一個大箭頭 =>
指向返回值來代替 return
關鍵字,避免它們和其它關鍵部分混在一起,現在會怎麼樣呢?
我們當然可以這麼做,程式碼會是這樣的:
const composeMixins = (...mixins) => (
instance = {},
mix = pipe
) => mix(...mixins)(instance);複製程式碼
現在應該可以很清楚這段程式碼做了什麼事了。composeMixins()
是一個函式,它傳入了任意數量的 mixins
,最終會返回一個得到兩個額外引數(instance
與 mix
)的函式。它返回了通過 mixins
管道組合的 instance
的結果。
還有一件事……如果我們對 pipe()
進行同樣的優化,可以神奇地將它寫到一行中:
const pipe = (...fns) => x => fns.reduce((acc, fn) => fn(acc), x);複製程式碼
當它在一行內被定義的時候,將它抽象成一個函式這件事反而變得不那麼明瞭了。
另外請記住,這個函式在 Lodash、Ramda 以及其它庫中都有用到,但是僅僅為了用這個函式就去 import 這些庫並不是一件划得來的事。
那麼我們自己寫一行這個函式有必要嗎?應該有的。它實際上是兩個不同的函式,把它們分開會讓程式碼更加清晰。
另一方面,如果將其寫在一行中,當你看引數命名的時候,你就已經明瞭了其型別以及用例。我們將它寫在一行,就如下面程式碼所示:
const composeMixins = (...mixins) => (
instance = {},
mix = (...fns) => x => fns.reduce((acc, fn) => fn(acc), x)
) => mix(...mixins)(instance);複製程式碼
現在讓我們回頭看看最初的函式。無論我們後面做了什麼調整,我們都沒有丟棄任何本來就有的資訊。並且,通過在行內宣告變數和預設值,我們還給這個函式增加了資訊量,描述了這個函式是怎麼使用的以及引數值是什麼樣子的。
ES5 版本中增加的額外的程式碼其實都是語法干擾。這些程式碼對於熟悉柯里化箭頭函式的人來說沒有任何有用之處。
只要你熟悉柯里化箭頭函式,你就會覺得最開頭的程式碼更加清晰並具有可讀性,因為它沒有多餘的語法糊弄人。
柯里化箭頭函式還能減少錯誤的藏身之處,因為它能讓 bug 隱藏的部分更少。我猜想,在傳統函式表示式中一定隱藏了許多的 bug,一旦你升級使用箭頭函式就能找到並排除這些 bug。
我希望你的團隊也能支援、學習與應用 ES6 的更加簡潔的程式碼風格,提高工作效率。
有時,在程式碼中詳細地進行描述是正確的行為,但通常來說,程式碼越少越好。如果更少的程式碼能夠實現同樣的東西,能夠傳達更多的資訊,不用丟棄任何資訊量,那麼它明顯更加優越。認知這些不同點的關鍵就是看它們表達的資訊。如果加上的程式碼沒有更多的意義,那麼這種程式碼就不應該存在。這個道理很簡單,就和自然語言的風格規範一樣(不說廢話)。將這種表達風格規範應用到程式碼中。擁抱它,你將能寫出更好的程式碼。
一天過去,天色已黑,仍然有其它推特的回覆在說 ES6 版本的程式碼更加缺乏可讀性:
我只想說:是時候熟練去掌握 ES6、柯里化與組合函式了。
下一步
“與 Eric Elliott 一起學習 JavaScript”會員現在可以看這個大約 55 分鐘的視訊課程——ES6 柯里化與組合。
如果你還不是我們的會員,你可會遺憾地錯過這個機會哦!
作者簡介
Eric Elliott 是 O'Reilly 出版的《Programming JavaScript Applications》書籍、“與 Eric Elliott 學習 JavaScript”課程作者。他曾經幫助 Adobe、萊美、華爾街日報、ESPN、BBC 進行軟體開發,以及幫助 Usher、Frank Ocean、Metallica 等著名音樂家做網站。
最後喂狗糧:
他與世界上最美麗的女人在舊金山灣區共度一生。
掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 Android、iOS、React、前端、後端、產品、設計 等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃。