天天看點

編寫「可讀」代碼的實踐

變量命名是編寫可讀代碼的基礎。隻有變量被賦予了一個合适的名字,才能表達出它在環境中的意義。

命名必須傳遞足夠的資訊,形如 <code>getData</code> 這樣的函數命名就沒能提供足夠的資訊,讀者也完全無法猜測這個函數會做出些什麼事情。而 <code>fetchUserInfoAsync</code> 也許就好很多,讀者至少會猜測出,這個函數大約會遠端地擷取使用者資訊;而且因為它有一個 <code>Async</code> 字尾,讀者甚至能猜出這個函數會傳回一個 Promise 對象。

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

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

有時候,我們需要表示某種集合概念,比如數組或哈希對象。這時可以通過名詞的複數形式來表示,比如用 <code>bananas</code> 表示一個數組,這個數組的每一項都是一個 <code>banana</code>。如果需要特别強調這種集合的形式,也可以加上 <code>List</code> 或 <code>Map</code> 字尾來顯式表示出來,比如用 <code>bananaList</code> 表示數組。

有些單詞的複數形式和單數形式相同,有些不可數的單詞沒有複數形式(比如 data,information),這時我也會使用 <code>List</code> 等字尾來表示集合概念。

變量都是處在上下文(作用域)之内,變量的命名應與上下文相契合,同一個變量,在不同的上下文中,命名可以不同。舉個例子,假設我們的程式需要管理一個動物園,程式的代碼裡有一個名為 <code>feedAnimals</code> 的函數來喂食動物園中的所有動物:

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

<code>Monkey#eat</code> 方法内部就不一樣了,這個方法很可能是下面這樣的(假設 <code>eat</code> 是 <code>Monkey</code> 的基類 <code>Animal</code> 的方法):

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

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

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

分支是代碼裡最常見的結構,一段結構清晰的代碼單元應當是像二叉樹一樣,呈現下面的結構。

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

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

如果某個分支是空的,我也傾向于留下一個空行,這個空行明确地告訴代碼的讀者,如果走到這個 <code>else</code>,我什麼都不會做。如果你不告訴讀者,讀者就會産生懷疑,并嘗試自己去弄明白。

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

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

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

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

萬事非絕對。對于一種情況,我不反對将多個條件複合起來,那就是當被複合的多個條件聯系十分緊密的時候,比如 <code>if(foo &amp;&amp; foo.bar)</code>。

這種風格的代碼很容易出現在那些屢經修補的代碼檔案中,很可能一開始是沒有這個 <code>if</code> 代碼塊的,後來發現了一個 bug,于是加上了這個 <code>if</code> 代碼塊,在某些條件下對 <code>foo</code> 做一些特殊的處理。如果你希望項目在疊代過程中,風險越積越高,那麼這個習慣絕對算得上「最佳實踐」了。

事實上,這樣的「更新檔」積累起來,很快就會摧毀代碼的可讀性和可維護性。怎麼說呢?當我們在寫下上面這段代碼中的 <code>if</code> 分支以試圖修複 bug 的時候,我們内心存在這樣一個假設:我們是知道程式在執行到這一行時,<code>foo</code> 什麼樣子的;但事實是,我們根本不知道,因為在這一行之前,<code>foo</code> 很可能已經被另一個人所寫的嘗試修複另一個 bug 的另一個 if 分支所篡改了。是以,當代碼出現問題的時候,我們應當完整地審視一段獨立的功能代碼(通常是一個函數),并且多花一點時間來修複他,比如:

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

我認為,項目的疊代再快,其代碼品質和可讀性都應當有一個底線。這個底線是,當我們在修改代碼的時候,應當完整了解目前修改的這個函數的邏輯,然後修改這個函數,以達到添加功能的目的。注意,這裡的「修改一個函數」和「在函數某個位置添加幾行代碼」是不同的,在「修改一個函數」的時候,為了保證函數功能獨立,邏輯清晰,不應該畏懼在這個函數的任意位置增删代碼。

有時,我們會自作聰明地寫出一些很「通用」的函數。比如,我們有可能寫出下面這樣一個擷取使用者資訊的函數 <code>fetchUserInfo</code>:其邏輯是:

1) 當傳入的參數是使用者ID(字元串)時,傳回單個使用者資料;

2) 而傳入的參數是使用者ID的清單(數組)時,傳回一個數組,其中的每一項是一個使用者的資料。

這個函數能夠做兩件事:1)擷取多個使用者的資料清單;2)擷取單個使用者的資料。在項目的其他地方調用 <code>fetchUserInfo</code> 函數時,也許我們确實能感到「友善」了一些。但是,代碼的讀者一定不會有相同的體會,當讀者在某處讀到 <code>fetchUserInfo(['1011', '1013'])</code> 這句調用的代碼時,他就會立刻對 <code>fetchUserInfo</code> 産生「第一印象」:這個函數需要傳入使用者ID數組;當他讀到另外一種調用形式時,他一定會懷疑自己之前是不是眼睛花了。讀者并不了解背後的「潛規則」,除非規則是預先設計好并且及時地更新到文檔中。總之,我們絕不該一時興起就寫出上面這種函數。

遵循一個函數隻做一件事的原則,我們可以将上述功能拆成兩個函數<code>fetchMultipleUser</code> 和 <code>fetchSingleUser</code> 來實作。在需要擷取使用者資料時,隻需要選擇調用其中的一個函數。

上述改良不僅改善了代碼的可讀性,也改善了可維護性。舉個例子,假設随着項目的疊代,擷取單一使用者資訊的需求不再存在了。

如果是改良前,我們會删掉那些「傳入單個使用者ID來調用 <code>fetchUserInfo</code>」的代碼,同時保留剩下的那些「傳入多個使用者ID調用 <code>fetchUserInfo</code>」的代碼, 但是 <code>fetchUserInfo</code>函數幾乎一定不會被更改。這樣,函數内部 <code>isSingle</code> 為 <code>true</code> 的分支,就留在了代碼中,成了永遠都不會執行的「髒代碼」,誰願意看到自己的項目中充斥着永遠不會執行的代碼呢?

對于改良後的代碼,我們(也許借助IDE)能夠輕松檢測到 <code>fetchSingleUser</code> 已經不會被調用了,然後放心大膽地直接删掉這個函數。

那麼,如何界定某個函數做的是不是一件事情?我的經驗是這樣:如果一個函數的參數僅僅包含輸入資料(交給函數處理的資料),而沒有混雜或暗含有指令(以某種約定的方式告訴函數該怎麼處理資料),那麼函數所做的應當就是一件事情。比如說,改良前的 <code>fetchUserInfo</code> 函數的參數是「多個使用者的ID數組或單個使用者的ID」,這個「或」字其實就暗含了某種指令。

有時候,我們會陷入一種很不好的習慣中,那就是,總是去嘗試寫出永遠不會報錯的函數。我們會給參數配上預設值,在很多地方使用 <code>||</code> 或者 <code>&amp;&amp;</code> 來避免代碼運作出錯,仿佛如果你的函數報錯會成為某種恥辱似的。而且,當我們嘗試去修複一個運作時報錯的函數時,我們往往傾向于在報錯的那一行添加一些相容邏輯來避免報錯。

舉個例子,假設我們需要編寫一個擷取使用者詳情的函數,它要傳回一個完整的使用者資訊對象:不僅包含ID,名字等基本資訊,也包含諸如「收藏的書籍」等通過額外接口傳回的資訊。這些額外的接口也許不太穩定:

假設 <code>fetchUserFavorites</code> 會時不時地傳回 <code>undefined</code>,那麼讀取其 <code>books</code> 屬性自然就會報錯。為了修複該問題,我們很可能會這樣做:

這樣做看似解決了問題:的确,<code>getUserDetail</code> 不會再報錯了,但同時埋下了更深的隐患。

當 <code>fetchUserFavorites</code> 傳回 <code>undefined</code> 時,程式已經處于一種異常狀态了,我們沒有任何理由放任程式繼續運作下去。試想,如果後面的某個時刻(比如使用者點選「我收藏的書」頁籤),程式試圖周遊 <code>user.favoriteBooks</code> 屬性(它被指派成了<code>undefined</code>),那時也會報錯,而且那時排查起來會更加困難。

如何處理上述的情況呢?我認為,如果被我們依賴的 <code>fetchUserFavorits</code> 屬于目前的項目,那麼 <code>getUserDetail</code> 對此報錯真的沒什麼責任,因為 <code>fetchUserFavorits</code> 就不應該傳回 <code>undefined</code>,我們應該去修複 <code>fetchUserFavorits</code>,任務失敗時顯式地告知出來,或者直接抛出異常。同時,<code>getUserDetail</code> 稍作修改:

那麼如果 <code>fetchUserFavorits</code> 不在目前項目中,而是依賴的外部子產品呢?我認為,這時你就該為選擇了這樣一個不可靠的子產品負責,在 <code>getUserDetail</code> 中增加一些「擦屁股」代碼,來避免你的項目的其他部分受到侵害。

無副作用的函數,是不依賴上下文,也不改變上下文的函數。長久依賴,我們已經習慣了去寫「有副作用的函數」,畢竟 JavaScript 需要通過副作用去操作環境的 API 完成任務。這就導緻了,很多原本可以用純粹的、無副作用的函數完成任務的場合,我們也會不自覺地采取有副作用的方式。

雖然看上去有點可笑,但我們有時候就是會寫出下面這樣的代碼!

上面,<code>addFavoritesToUser</code> 函數就是一個「有副作用」的函數,它改變了 <code>users</code>,給它新增了幾個個字段。問題在于,僅僅閱讀 <code>getUserData</code> 函數的代碼完全無法知道,user 會發生怎樣的改變。

一個無副作用的函數應該是這樣的:

偉大的文學作品都是建立在廢紙堆上的,不斷删改作品的過程有助于寫作者培養良好的「語感」。當然,代碼畢竟不是藝術品,程式員沒有精力也不一定有必要像作家一樣反複打磨自己的代碼/作品。但是,如果我們能夠在編寫代碼時稍稍多考慮一下實作的合理性,或者在添加新功能的時候稍稍回顧一下之前的實作,我們就能夠培養出一些「代碼語感」。這種「代碼語感」會非常有助于我們寫出高品質的可讀的代碼。

源位址:http://taobaofed.org/blog/2017/01/05/writing-readable-code/