編寫【可讀】代碼的實踐

此為摘自 ==淘寶前端團隊(FFD)==


編寫可讀的代碼:

對于以代碼為生的程序員而已,編寫可讀的代碼,是一件極其重要的事情。從某種角度來說,代碼最重要的功能是能夠被閱讀,其次才是能被正確執(zhí)行。一段無法正確執(zhí)行的代碼,也許會使項目延誤幾天,但造成的危害只是暫時和短暫的,而一段缺乏條理,難以閱讀的代碼,造成的危害卻是深遠而長久的。這里總結了下,在工作和業(yè)余生活中,我對如何編寫可讀代碼這個問題的具體體會。


  • 變量命名

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

命名必須傳遞足夠的信息,形如getData這個的函數命名就沒能夠提供足夠的信息,這讓讀者無法猜測這個函數會做出些什么事情。而fetchUserInfoAsync 也許就好很多,讀者至少能猜到,這個函數大約是遠程獲取用戶信息。

  • 命名的基礎

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

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

有時候,我們需要表示某種集合的概念,比如數組或者哈希對象。這是可以使用名詞的復數形式來表示,比如bananas 表示一個數組。如果需要特別強調這種集合的形式,也可以加上 ListMap 后綴來表示,比如 bananaList表示數組。 如果有些單詞的復數形式和單數形式相同,比如data , information 等,這時就用 List 為后綴表示集合概念。

  • 命名的上下文

變量都是處在 上下文 (作用域)內的,變量的命名應該和上下文相契合,同一個變量,在不同的上下文,命名可以不同。

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

如果你能夠時刻按照某種嚴格的規(guī)范來命名變量和函數,還能帶來一個潛在的好處,那就是你再也不用 記住 那些之前命名過或者其他人命名過的變量和函數了。比如,【獲取用戶信息】這個概念,就叫作fetchUserInfomation ,不管是在早晨還是晚上,家里還是在公司里,它都是命名為 fetchUserInfomation

  • 分支結構

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

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

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

不好的做法:在分支中return

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

這種代碼很常見,而且往往分支2的邏輯是先寫的,也是函數的主要邏輯。這種致命的問題就是,如果讀者沒有注意到分支1中的 return ,就不會意識到后面一段代碼(分支2)是有可能不被執(zhí)行的。建議是把分支2的代碼寫著else 模塊中,代碼就會清晰可讀:

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

如果某個分支為空,最好留一個空行,明確地告訴代碼的讀者,
如果走到這個 else ,什么也不做。這樣讀者就不會產生任何懷疑。

不好的做法:多個條件的復合

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

這種代碼也很常見:在若干條件同時滿足(或有任一滿足) 的時候做一些主要事情(分支1),否則做一些次要事情(分支2)。這樣籠統(tǒng)的使用同一段代碼來處理多個分支,那么就增加了閱讀者閱讀分支2時的負擔(需要考慮多個情況)。對于這種場景,通常這樣寫:

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

即使分支2和分支3完全一樣,我也認為有必要分開。不過對于那種多個復合條件聯系十分緊密,就沒必要分開寫,比如if(foo && foo.bar) 。

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

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

這種風格的代碼很容易出現在那些屢經修改的代碼文件中,很可能一開始是沒有這個 if 代碼塊的,后來發(fā)現了一個bug,于是加上了這個if 代碼塊。

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

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

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

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


  • 函數

一個函數只做一件事情

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

1、當傳入的參數是用戶ID(字符串)時,返回單個用戶的數據。
2、當傳入的參數是用戶ID的列表(數組)時,返回一個數組,其中每項包含一個用戶的數據

    sync function fetchUserInfo (id){
        const isSingle = typeof id ==="string";
        const idList = isSingle ? [id] : id;
        const result = await request.post('/api/userInfo',{idList});
        return isSingle ? result[0] :result;
    }
    
    //  可以這樣調用
    const userList = await fetchUserInfo(['1001','1013']);
    
    // 也可以這樣調用
    const user = await fetchUserInfo('1013')

這個函數能做兩件事:1)獲取多個用戶列表的數據;2)獲取單個用戶數據。這樣讀者在某處讀到 fetchUserInfo(['1001','1013']) 這句調用代碼時,會立刻對 fetchUserInfo 產生第一印象:這個函數是需要出入用戶ID數組的;而當讀到另一種調用方式時,就會懷疑自己之前的判斷。

遵循一個函數只做一件事 的原則,我們可以將上述功能拆分成兩個函數 fetchMultipleUserfetchSingleUser 來實現。

    async function fetchMultipleUser(idList){
        return await request.post('/api/users/',{idList})
    }
    
    async function fetchSingleUser(id){
        return await fetchMultipleUser([id])[0]
    }

改良后的代碼不僅改善了代碼的可讀性,也改善了可維護性,當不需要獲取單一用戶信息時,就可以放心大膽的直接刪掉整個函數。

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

函數應適當地處理異常

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

舉個例子,假設我們需要編寫一個獲取用戶詳情的函數,它要返回一個完整的用戶信息對象:不僅包含ID,名字等基本信息,也包含諸如「收藏的書籍」等通過額外接口返回的信息。這些額外的接口也許不太穩(wěn)定:

    async function getUserDetail(id) {
  const user = await fetchSingleUser(id);
  user.favoriteBooks = (await fetchUserFavorits(id)).books;
  // 上面這一行報錯了:Can not read property 'books' of undefined.
  // ...
}

假設 fetchUserFavorites 會時不時地返回 undefined,那么讀取其 books 屬性自然就會報錯。為了修復該問題,我們很可能會這樣做:

    const favorites = await fetchUserFavorits(id);
user.favoriteBooks = favorites && favorites.books;
// 這下不會報錯了

這樣做看似解決了問題:的確,getUserDetail 不會再報錯了,但同時埋下了更深的隱患。

當 fetchUserFavorites 返回 undefined 時,程序已經處于一種異常狀態(tài)了,我們沒有任何理由放任程序繼續(xù)運行下去。試想,如果后面的某個時刻(比如用戶點擊「我收藏的書」選項卡),程序試圖遍歷 user.favoriteBooks 屬性(它被賦值成了undefined),那時也會報錯,而且那時排查起來會更加困難。

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

    // 情況1:顯式告知,此時應認為獲取不到收藏數據不算致命的錯誤
const result = await fetchUserFavorits(id);
if(result.success) {
  user.favoriteBooks = result.data.books;
} else {
  user.favoriteBooks = []
}

// 情況2:直接拋出異常
user.favoriteBooks = (await fetchUserFavorits(id)).books;
// 這時 `getUserDetail` 不需要改動,任由異常沿著調用棧向上冒泡

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

    const favorites = await fetchUserFavorits(id);
if(favorites) {
  user.favoriteBooks = favorites.books;
} else {
  throw new Error('獲取用戶收藏失敗');
}

控制函數的副作用

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

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

async function getUserDetail(id) {
  const user = await fetchSingleUserInfo(id);
  await addFavoritesToUser(user);
  ...
}
async function addFavoritesToUser(user) {
  const result = await fetchUserFavorits(user.id);
  user.favoriteBooks = result.books;
  user.favoriteSongs = result.songs;
  user.isMusicFan = result.songs.length > 100;
}

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

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

    async function getUserDetail(id) {
  const user = await fetchSingleUserInfo(id);
  const {books, songs, isMusicFan} = await getUserFavorites(id);
  return Object.assign(user, {books, songs, isMusicFan})
}
async function getUserFavorites(id) {
  const {books, songs} = await fetchUserFavorits(user.id);
  return {
    books, songs, isMusicFan: result.songs.length > 100
  }
}

非侵入性地改造函數

函數是一段獨立和內聚的邏輯。在產品迭代的過程中,我們有時候不得不去修改函數的邏輯,為其添加一些新特性。之前我們也說過,一個函數只應做一件事,如果我們需要添加的新特性,與原先函數中的邏輯沒有什么聯系,那么決定是否通過改造這個函數來添加新功能,應當格外謹慎。

仍然用「向服務器查詢用戶數據」為例,假設我們有如下這樣一個函數(為了讓它看上去復雜一些,假設我們使用了一個更基本的 request 庫):

    const fetchUserInfo = (userId, callback) => {
  const param = {
    url: '/api/user',
    method: 'post',
    payload: {id: userId}
  };
  request(param, callback);
}

現在有了一個新需求:為 fetchUserInfo 函數增加一道本地緩存,如果第二次請求同一個 userId 的用戶信息,就不再重新向服務器發(fā)起請求,而直接以第一次請求得到的數據返回。

按照如下快捷簡單的解決方案,改造這個函數只需要五分鐘時間:

    const userInfoMap = {};
const fetchUserInfo = (userId, callback) => {
  if (userInfoMap[userId]) {            // 新增代碼
    callback(userInfoMap[userId]);    // 新增代碼
  } else {                              // 新增代碼
    const param = {
      // ... 參數
    };
    request(param, (result) => {
      userInfoMap[userId] = result;   // 新增代碼
      callback(result);
    });
  }
}

不知你有沒有發(fā)現,經此改造,這個函數的可讀性已經明顯降低了。沒有緩存機制前,函數很清晰,一眼就能明白,加上新增的幾行代碼,已經不能一眼就看明白了。

實際上,「緩存」和「獲取用戶數據」完全是獨立的兩件事。我提出的方案是,編寫一個通用的緩存包裝函數(類似裝飾器)memorizeThunk,對 fetchUserInfo 進行包裝,產出一個新的具有緩存功能的 fetchUserInfoCache,在不破壞原有函數可讀性的基礎上,提供緩存功能。

    const memorizeThunk = (func, reducer) => {
  const cache = {};
  return (...args, callback) => {
    const key = reducer(...args);
    if (cache[key]) {
      callback(...cache[key]);
    } else {
      func(...args, (...result) => {
        cache[key] = result;
        callback(...result);
      });
    }
  }
}
const fetchUserInfo = (userInfo, callback) => {
  // 原來的邏輯
}
const fetchUserInfoCache = memorize(fetchUserInfo, (userId) => userId);

也許實現這個方案需要十五分鐘,但是試想一下,如果將來的某個時候,我們又不需要緩存功能了(或者需要提供一個開關來打開/關閉緩存功能),修改代碼的負擔是怎樣的?第一種簡單方案,我們需要精準(提心吊膽地)地刪掉新增的若干行代碼,而我提出的這種方案,是以函數為單位增刪的,負擔要輕很多,不是嗎?

類的結構

避免濫用成員函數

總結

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

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

相關閱讀更多精彩內容

  • 本來想寫一個怎么寫好代碼的主題,昨天突然看到了淘寶團隊的這篇文章《編寫「可讀」代碼的實踐》,把我想到的沒想到的基本...
    唯泥Bernie閱讀 1,313評論 0 4
  • Android 自定義View的各種姿勢1 Activity的顯示之ViewRootImpl詳解 Activity...
    passiontim閱讀 179,058評論 25 709
  • 一、 人的一生中,每個人都避免不了的會有焦慮情緒出現,區(qū)別僅在于焦慮的時間與程度不同。列子不勝枚舉,最常見的是各類...
    萱源mama閱讀 1,707評論 2 5
  • 在人來人往的街頭,獨來獨往 在燈火繁鬧的街尾,摸黑獨行
    Yaweix閱讀 246評論 0 0
  • 今天“分答”免費聽,捧著手機聽職業(yè)生涯規(guī)劃師趙昂老師對于職場的各種解答,盡管沒有特別契合自己現狀的回答,但是還...
    簡約Dr閱讀 1,732評論 5 5

友情鏈接更多精彩內容