編寫「可讀」代碼(練習(xí)makedown)

編寫可讀的代碼,對于以代碼謀生的程序員而言,是一件極為重要的事。從某種角度來說,代碼最重要的功能是能夠被閱讀,其次才是能夠被正確執(zhí)行。一段無法正確執(zhí)行的代碼,也許會使項目延期幾天,但它造成的危害只是暫時和輕微的,畢竟這種代碼無法通過測試并影響最終的產(chǎn)品;但是,一段能夠正確執(zhí)行,但缺乏條理、難以閱讀的代碼,它造成的危害卻是深遠(yuǎn)和廣泛的:這種代碼會提高產(chǎn)品后續(xù)迭代和維護(hù)的成本,影響產(chǎn)品的穩(wěn)定,破壞團隊的團結(jié)(霧),除非我們花費數(shù)倍于編寫這段代碼的時間和精力,來消除它對項目造成的負(fù)面影響。

在最近的工作和業(yè)余生活中,我對「如何寫出可讀的代碼」這個問題頗有一些具體的體會,不妨記錄下來吧。

JavaScript 是動態(tài)和弱類型的語言,使用起來比較「輕松隨意」,在 IE6 時代,輕松隨意的習(xí)慣確實不是什么大問題,反而能節(jié)省時間,提高出活兒的速度。但是,隨著當(dāng)下前端技術(shù)的快速發(fā)展,前端項目規(guī)模的不斷膨脹,以往那種輕松隨意的編碼習(xí)慣,已經(jīng)成為項目推進(jìn)的一大阻力。

這篇文章討論的是 ES6/7 代碼,不僅因為 ES6/7 已經(jīng)在大部分場合替代了 JavaScript,還因為 ES6/7 中的很多特性也能幫助我們改善代碼的可讀性。

變量命名

變量命名是編寫可讀代碼的基礎(chǔ)。只有變量被賦予了一個合適的名字,才能表達(dá)出它在環(huán)境中的意義。

命名必須傳遞足夠的信息,形如 getData 這樣的函數(shù)命名就沒能提供足夠的信息,讀者也完全無法猜測這個函數(shù)會做出些什么事情。而 fetchUserInfoAsync 也許就好很多,讀者至少會猜測出,這個函數(shù)大約會遠(yuǎn)程地獲取用戶信息;而且因為它有一個 Async 后綴,讀者甚至能猜出這個函數(shù)會返回一個 Promise 對象。

命名的基礎(chǔ)

通常,我們使用名詞來命名對象,使用動詞來命名函數(shù)。比如:


monkey.eat(banana); // the money eats a banana const apple = pick(tree); // pick an apple from the tree


這兩句代碼與自然語言(右側(cè)的注釋)很接近,即使完全不了解編程的人也能看懂大概。

有時候,我們需要表示某種集合概念,比如數(shù)組或哈希對象。這時可以通過名詞的復(fù)數(shù)形式來表示,比如用 bananas 表示一個數(shù)組,這個數(shù)組的每一項都是一個 banana。如果需要特別強調(diào)這種集合的形式,也可以加上 List 或 Map 后綴來顯式表示出來,比如用 bananaList 表示數(shù)組。

有些單詞的復(fù)數(shù)形式和單數(shù)形式相同,有些不可數(shù)的單詞沒有復(fù)數(shù)形式(比如 data,information),這時我也會使用 List 等后綴來表示集合概念。

命名的上下文

變量都是處在上下文(作用域)之內(nèi),變量的命名應(yīng)與上下文相契合,同一個變量,在不同的上下文中,命名可以不同。舉個例子,假設(shè)我們的程序需要管理一個動物園,程序的代碼里有一個名為 feedAnimals 的函數(shù)來喂食動物園中的所有動物:


function feedAnimals(food, animals) { // ... // 上下文中有 bananas, peaches, monkey 變量 const banana = bananas.pop(); if (banana) { monkey.eat(banana); } else { const peach = peaches.pop(); monkey.eat(peach); } // ... }


負(fù)責(zé)喂食動物的函數(shù) feedAnimals 函數(shù)的主要邏輯就是:用各種食物把動物園里的各種動物喂飽。也許,每種動物能接受的食物種類不同,也許,我們需要根據(jù)各種食物的庫存來決定每種動物最終分到的食物,總之在這個上下文中,我們需要關(guān)心食物的種類,所以傳給 money.eat 方法的實參對象命名為 banana 或者 peach,代碼很清楚地表達(dá)出了它的關(guān)鍵邏輯:「猴子要么吃香蕉,要么吃桃子(如果沒有香蕉了)」。我們肯定不會這樣寫:


// 我們不會這樣寫 const food = bananas.pop(); if(food) { monkey.eat(food); } else { const food = peaches.pop(); monkey.eat(food); }


Monkey#eat 方法內(nèi)部就不一樣了,這個方法很可能是下面這樣的(假設(shè) eat 是 Monkey 的基類 Animal 的方法):


class Animal{ // ... eat(food) { this.hunger -= food.energy; } // ... } class Monkey extends Animal{ // ... }


如代碼所示,「吃」這個方法的核心邏輯就是根據(jù)食物的能量來減少動物(猴子)自身的饑餓度,至于究竟是吃了桃子還是香蕉,我們不關(guān)心,所以在這個方法的上下文中,我們直接將表示食物的函數(shù)形參命名為 food。

想象一下,假設(shè)我們正在編寫某個函數(shù),即將寫一段公用邏輯,我們會選擇去寫一個新的功能函數(shù)來執(zhí)行這段公用邏輯。在編寫這個新的功能函數(shù)過程中,往往會受到之前那個函數(shù)的影響,變量的命名也是按照其在之前那個函數(shù)中的意義來的。雖然寫的時候不感覺有什么阻礙,但是讀者閱讀的單元是函數(shù)(他并不了解之前哪個函數(shù)),會被深深地困擾。

嚴(yán)格遵循一種命名規(guī)范的收益

如果你能夠時刻按照某種嚴(yán)格的規(guī)則來命名變量和函數(shù),還能帶來一個潛在的好處,那就是你再也不用記住哪些之前命名過(甚至其他人命名過)的變量或函數(shù)了。特定上下文中的特定含義只有一種命名方式,也就是說,只有一個名字。比如,「獲取用戶信息」這個概念,就叫作 fetchUserInfomation,不管是在早晨還是傍晚,不管你是在公司還是家中,你都會將它命名為 fetchUserInfomation 而不是 getUserData。那么當(dāng)你再次需要使用這個變量時,你根本不用翻閱之前的代碼或依賴 IDE 的代碼提示功能,你只需要再命名一下「獲取用戶信息」這個概念,就可以得到 fetchUserInfomation 了,是不是很酷?

分支結(jié)構(gòu)

分支是代碼里最常見的結(jié)構(gòu),一段結(jié)構(gòu)清晰的代碼單元應(yīng)當(dāng)是像二叉樹一樣,呈現(xiàn)下面的結(jié)構(gòu)。


if (condition1) { if (condition2) { ... } else { ... } } else { if (condition3) { ... } else { ... } }


這種優(yōu)美的結(jié)構(gòu)能夠幫助我們在大腦中迅速繪制一張圖,便于我們在腦海中模擬代碼的執(zhí)行。但是,我們大多數(shù)人都不會遵循上面這樣的結(jié)構(gòu)來寫分支代碼。以下是一些常見的,在我看來可讀性比較差的分支語句的寫法:

不好的做法:在分支中 return


function foo() { if (condition) { // 分支1的邏輯 return; } // 分支2的邏輯 }


這種分支代碼很常見,而且往往分支 2 的邏輯是先寫的,也是函數(shù)的主要邏輯,分支 1 是后來對函數(shù)進(jìn)行修補的過程中產(chǎn)生的。這種分支代碼有一個很致命的問題,那就是,如果讀者沒有注意到分支1中的 return(我敢保證,在使用 IDE 把代碼折疊起來后,沒人能第一時間注意到這個 return),就不會意識到后面一段代碼(分支 2)是有可能不會執(zhí)行的。我的建議是,把分支 2 放到一個 else 語句塊中,代碼就會清晰可讀很多:


function foo() { if (condition) { // 分支 1 的邏輯 } else { // 分支 2 的邏輯 } }


如果某個分支是空的,我也傾向于留下一個空行,這個空行明確地告訴代碼的讀者,如果走到這個 else,我什么都不會做。如果你不告訴讀者,讀者就會產(chǎn)生懷疑,并嘗試自己去弄明白。

不好的做法:多個條件復(fù)合


if (condition1 && condition2 && condition3) { // 分支1:做一些事情 } else { // 分支2:其他的事情 }


這種代碼也很常見:在若干條件同時滿足(或有任一滿足)的時候做一些主要的事情(分支1,也就是函數(shù)的主邏輯),否則就做一些次要的事情(分支2,比如拋異常,輸出日志等)。雖然寫代碼的人知道什么是主要的事情,什么是次要的事情,但是代碼的讀者并不知道。讀者遇到這種代碼,就會產(chǎn)生困惑:分支2到底對應(yīng)了什么條件?

在上面這段代碼中,三種條件只要任意一個不成立就會執(zhí)行到分支 2,但這其實本質(zhì)上是多個分支:1)條件 1 不滿足,2)條件 1 滿足而條件 2 不滿足,3)條件 1 和 2 都滿足而條件 3 不滿足。如果我們籠統(tǒng)地使用同一段代碼來處理多個分支,那么就會增加閱讀者閱讀分支 2 時的負(fù)擔(dān)(需要考慮多個情況)。更可怕的是,如果后面需要增加一些額外的邏輯(比如,在條件 1 成立且條件 2 不成立的時候多輸出一條日志),整個 if-else 都可能需要重構(gòu)。

對這種場景,我通常這樣寫:


if (condition1) { if (condition2) { // 分支1:做一些事情 } else { // 分支2:其他的事情 } } else { // 分支3:其他的事情 }


即使分支 2 和分支 3 是完全一樣的,我也認(rèn)為有必要將其分開。雖然多了幾行代碼,收益卻是很客觀的。

萬事非絕對。對于一種情況,我不反對將多個條件復(fù)合起來,那就是當(dāng)被復(fù)合的多個條件聯(lián)系十分緊密的時候,比如 if(foo && foo.bar)。

不好的做法:使用分支改變環(huán)境


let foo = someValue; if (condition) { foo = doSomethingTofoo(foo); } // 繼續(xù)使用 foo 做一些事情


這種風(fēng)格的代碼很容易出現(xiàn)在那些屢經(jīng)修補的代碼文件中,很可能一開始是沒有這個 if 代碼塊的,后來發(fā)現(xiàn)了一個 bug,于是加上了這個 if 代碼塊,在某些條件下對 foo 做一些特殊的處理。如果你希望項目在迭代過程中,風(fēng)險越積越高,那么這個習(xí)慣絕對算得上「最佳實踐」了。

事實上,這樣的「補丁」積累起來,很快就會摧毀代碼的可讀性和可維護(hù)性。怎么說呢?當(dāng)我們在寫下上面這段代碼中的 if 分支以試圖修復(fù) bug 的時候,我們內(nèi)心存在這樣一個假設(shè):我們是知道程序在執(zhí)行到這一行時,foo 什么樣子的;但事實是,我們根本不知道,因為在這一行之前,foo 很可能已經(jīng)被另一個人所寫的嘗試修復(fù)另一個 bug 的另一個 if 分支所篡改了。所以,當(dāng)代碼出現(xiàn)問題的時候,我們應(yīng)當(dāng)完整地審視一段獨立的功能代碼(通常是一個函數(shù)),并且多花一點時間來修復(fù)他,比如:


const foo = condition ? doSomethingToFoo(someValue) : someValue;


我們看到,很多風(fēng)險都是在項目快速迭代的過程中積累下來的。為了「快速」迭代,在添加功能代碼的時候,我們有時候連函數(shù)這個最小單元的都不去了解,僅僅著眼于自己插入的那幾行,希望在那幾行中解決/hack掉所有問題,這是十分不可取的。

我認(rèn)為,項目的迭代再快,其代碼質(zhì)量和可讀性都應(yīng)當(dāng)有一個底線。這個底線是,當(dāng)我們在修改代碼的時候,應(yīng)當(dāng)完整了解當(dāng)前修改的這個函數(shù)的邏輯,然后修改這個函數(shù),以達(dá)到添加功能的目的。注意,這里的「修改一個函數(shù)」和「在函數(shù)某個位置添加幾行代碼」是不同的,在「修改一個函數(shù)」的時候,為了保證函數(shù)功能獨立,邏輯清晰,不應(yīng)該畏懼在這個函數(shù)的任意位置增刪代碼。

函數(shù)

函數(shù)只做一件事情

有時,我們會自作聰明地寫出一些很「通用」的函數(shù)。比如,我們有可能寫出下面這樣一個獲取用戶信息的函數(shù) fetchUserInfo:其邏輯是:

  1. 當(dāng)傳入的參數(shù)是用戶ID(字符串)時,返回單個用戶數(shù)據(jù);
  2. 而傳入的參數(shù)是用戶ID的列表(數(shù)組)時,返回一個數(shù)組,其中的每一項是一個用戶的數(shù)據(jù)。

async function fetchUserInfo(id) { const isSingle = typeof idList === 'string'; const idList = isSingle ? [id] : id; const result = await request.post('/api/userInfo', {idList}); return isSingle ? result[0] : result; }

// 可以這樣調(diào)用 const userList = await fetchUserInfo(['1011', '1013']); // 也可以這樣調(diào)用 const user = await fetchUserInfo('1017');


這個函數(shù)能夠做兩件事:1)獲取多個用戶的數(shù)據(jù)列表;2)獲取單個用戶的數(shù)據(jù)。在項目的其他地方調(diào)用 fetchUserInfo 函數(shù)時,也許我們確實能感到「方便」了一些。但是,代碼的讀者一定不會有相同的體會,當(dāng)讀者在某處讀到 fetchUserInfo(['1011', '1013']) 這句調(diào)用的代碼時,他就會立刻對 fetchUserInfo 產(chǎn)生「第一印象」:這個函數(shù)需要傳入用戶ID數(shù)組;當(dāng)他讀到另外一種調(diào)用形式時,他一定會懷疑自己之前是不是眼睛花了。讀者并不了解背后的「潛規(guī)則」,除非規(guī)則是預(yù)先設(shè)計好并且及時地更新到文檔中??傊?,我們絕不該一時興起就寫出上面這種函數(shù)。

遵循一個函數(shù)只做一件事的原則,我們可以將上述功能拆成兩個函數(shù)fetchMultipleUser 和 fetchSingleUser 來實現(xiàn)。在需要獲取用戶數(shù)據(jù)時,只需要選擇調(diào)用其中的一個函數(shù)。


async function fetchMultipleUser(idList) { return await request.post('/api/users/', {idList}); }

async function fetchSingleUser(id) { return await fetchMultipleUser([id])[0]; }


上述改良不僅改善了代碼的可讀性,也改善了可維護(hù)性。舉個例子,假設(shè)隨著項目的迭代,獲取單一用戶信息的需求不再存在了。

  • 如果是改良前,我們會刪掉那些「傳入單個用戶ID來調(diào)用 fetchUserInfo」的代碼,同時保留剩下的那些「傳入多個用戶ID調(diào)用 fetchUserInfo」的代碼, 但是 fetchUserInfo 函數(shù)幾乎一定不會被更改。這樣,函數(shù)內(nèi)部 isSingle 為 true 的分支,就留在了代碼中,成了永遠(yuǎn)都不會執(zhí)行的「臟代碼」,誰愿意看到自己的項目中充斥著永遠(yuǎn)不會執(zhí)行的代碼呢?
    
  • 對于改良后的代碼,我們(也許借助IDE)能夠輕松檢測到 fetchSingleUser 已經(jīng)不會被調(diào)用了,然后放心大膽地直接刪掉這個函數(shù)。
    

那么,如何界定某個函數(shù)做的是不是一件事情?我的經(jīng)驗是這樣:如果一個函數(shù)的參數(shù)僅僅包含輸入數(shù)據(jù)(交給函數(shù)處理的數(shù)據(jù)),而沒有混雜或暗含有指令(以某種約定的方式告訴函數(shù)該怎么處理數(shù)據(jù)),那么函數(shù)所做的應(yīng)當(dāng)就是一件事情。比如說,改良前的 fetchUserInfo 函數(shù)的參數(shù)是「多個用戶的ID數(shù)組或單個用戶的ID」,這個「或」字其實就暗含了某種指令。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

  • 轉(zhuǎn)至元數(shù)據(jù)結(jié)尾創(chuàng)建: 董瀟偉,最新修改于: 十二月 23, 2016 轉(zhuǎn)至元數(shù)據(jù)起始第一章:isa和Class一....
    40c0490e5268閱讀 2,030評論 0 9
  • Spring Cloud為開發(fā)人員提供了快速構(gòu)建分布式系統(tǒng)中一些常見模式的工具(例如配置管理,服務(wù)發(fā)現(xiàn),斷路器,智...
    卡卡羅2017閱讀 136,506評論 19 139
  • 原文: https://github.com/ecomfe/spec/blob/master/javascript...
    zock閱讀 3,475評論 2 36
  • 1. 1G 模擬 制式 大哥大 智能語音通話 2G CSM ,CDMA 收發(fā)短信和郵件 2.5G ...
    雷一凡閱讀 358評論 0 0
  • 我是誰 Who I am ,最近國內(nèi)各種靈修課程體系玲瑯滿目,我16年4月份到現(xiàn)在也參加了不少老師的課程。老師說:...
    Winner33閱讀 417評論 0 6

友情鏈接更多精彩內(nèi)容