如何提高程式碼的可讀性? - 讀《編寫可讀程式碼的藝術》

J_Knight_發表於2017-11-13

《編寫可讀程式碼的藝術》封面

一. 為什麼讀這本書

很多同行在編寫程式碼的時候往往只關注一些巨集觀上的主題:架構,設計模式,資料結構等等,卻忽視了一些更細節上的點:比如變數如何命名與使用,控制流的設計,以及註釋的寫法等等。以上這些細節上的東西可以用程式碼的可讀性來概括。

不同於巨集觀上的架構,設計模式等需要好幾個類,好幾個模組才能看出來:程式碼的可讀性是能夠立刻從微觀上的,一個變數的命名,函式的邏輯劃分,註釋的資訊質量裡面看出來的。

巨集觀層面上的東西固然重要,但是程式碼的可讀性也屬於評價程式碼質量的一個無法讓人忽視的指標:它影響了閱讀程式碼的成本(畢竟程式碼主要是給人看的),甚至會影響程式碼出錯的概率!

這裡引用《編寫可讀程式碼的藝術》這本書裡的一句話:

對於一個整體的軟體系統而言,既需要巨集觀的架構決策,設計與指導原則,也必須重視微觀上的的程式碼細節。在軟體歷史中,有許多影響深遠的重大失敗,其根源往往是編碼細節出現了疏漏。

因此筆者認為程式碼的可讀性可以作為考量一名程式設計師專業程度的指標。

或許已經有很多同行也正在努力提高自己程式碼的可讀性。然而這裡有一個很典型的錯覺(筆者之前就有這種錯覺)是:越少的程式碼越容易讓人理解。

但是事實上,並不是程式碼越精簡就越容易讓人理解。相對於追求最小化程式碼行數,一個更好的提高可讀性方法是最小化人們理解程式碼所需要的時間。

這就引出了這本中的一個核心定理:

可讀性基本定理:程式碼的寫法應當使別人理解它所需要的時間最小化。

這本書講的就是關於“如何提高程式碼的可讀性”。 筆者總結下來,這本書從淺入深,在三個層次告訴了我們如何讓程式碼易於理解:

  • 表層上的改進:在命名方法(變數名,方法名),變數宣告,程式碼格式,註釋等方面的改進。
  • 控制流和邏輯的改進:在控制流,邏輯表示式上讓程式碼變得更容易理解。
  • 結構上的改進:善於抽取邏輯,藉助自然語言的描述來改善程式碼。

二. 表層的改進

首先來講最簡單的一層如何改進,涉及到以下幾點:

  • 如何命名
  • 如何宣告與使用變數
  • 如何簡化表示式
  • 如何讓程式碼具有美感
  • 如何寫註釋

如何命名

關於如何命名,作者提出了一個關鍵思想:

關鍵思想:把儘可能多的資訊裝入名字中。

這裡的多指的是有價值的多。那麼如何做到有價值呢?作者介紹了以下幾個建議:

  • 選擇專業的詞彙,避免泛泛的名字
  • 給名字附帶更多資訊
  • 決定名字最適合的長度
  • 名字不能引起歧義

選擇專業的詞彙,避免泛泛的名字

一個比較常見的反例:get

get這個詞最好是用來做輕量級的取方法的開頭,而如果用到其他的地方就會顯得很不專業。

舉個書中的例子:

getPage(url)

通過這個方法名很難判斷出這個方法是從快取中獲取頁面資料還是從網頁中獲取。如果是從網頁中獲取,更專業的詞應該是fetchPage(url)或者downloadPage(url)

還有一個比較常見的反例:returnValueretval。這兩者都是“返回值”的意思,他們被濫用在各個有返回值的函式裡面。其實這兩個詞除了攜帶他們本來的意思返回值以外並不具備任何其他的資訊,是典型的泛泛的名字。

那麼如何選擇一個專業的詞彙呢?答案是在非常貼近你自己的意圖的基礎上,選擇一個富有表現力的詞彙。

舉幾個例子:

  • 相對於make,選擇create,generate,build等詞彙會更有表現力,更加專業。
  • 相對於find,選擇search,extract,recover等詞彙會更有表現力,更加專業。
  • 相對於retval,選擇一個能充分描述這個返回值的性質的名字,例如:
var euclidean_norm = function (v){
    var retval = 0.0;
    for (var i = 0; i < v.length; i += 1;)
       retval += v[i] * v[i];
    return Match.sqrt(retval);
}
複製程式碼

這裡的retval表示的是“平方的和”,因此sum_squares這個詞更加貼切你的意圖,更加專業。

但是,有些情況下,泛泛的名字也是有意義的,例如一個交換變數的情景:

if (right < left){
    tmp = right;
    right = left;
    left = tmp;
}
複製程式碼

像上面這種tmp只是作為一個臨時儲存的情況下,tmp表達的意思就比較貼切了。因此,像tmp這個名字,只適用於短期存在而且特性為臨時性的變數。

給名字附帶更多資訊

除了選擇一個專業,貼切意圖的詞彙,我們也可以通過新增一些前字尾來給這個詞附帶更多的資訊。這裡所指的更多的資訊有三種:

  • 變數的單位
  • 變數的屬性
  • 變數的格式

為變數新增單位

有些變數是有單位的,在變數名的後面新增其單位可以讓這個變數名攜帶更多資訊:

  • 一個表達時間間隔的變數,它的單位是秒:相對於duractionducation_secs攜帶了更多的資訊
  • 一個表達記憶體大小的變數,它的單位是mb:相對於sizecache_mb攜帶了更多的資訊。

為變數新增重要屬性

有些變數是具有一些非常重要的屬性,其重要程度是不允許使用者忽略的。例如:

  • 一個UTF-8格式的html位元組,相對於htmlhtml_utf8更加清楚地描述了這個變數的格式。
  • 一個純文字,需要加密的密碼字串:相對於passwordplaintext_password更清楚地描述了這個變數的特點。

為變數選擇適當的格式

對於命名,有些既定的格式需要注意:

  • 使用大駝峰命名來表示類名:HomeViewController
  • 使用小駝峰命名來表示屬性名:userNameLabel
  • 使用下劃線連線詞來表示變數名:product_id
  • 使用kConstantName來表示常量:kCacheDuraction
  • 使用MACRO_NAME來表示巨集:SCREEN_WIDTH

決定名字最適合的長度

名字越長越難記住,名字越短所持有的資訊就越少,如何決定名字的長度呢?這裡有幾個原則:

  • 如果變數的作用域很小,可以取很短的名字
  • 駝峰命名中的單元不能超過3個
  • 不能使用大家不熟悉的縮寫
  • 丟掉不必要的單元

如果變數的作用域很小,可以取很短的名字

如果一個變數作用域很小:則給它取一個很短的名字也無妨。

看下面這個例子:

if(debug){
    map <string,int>m;
    LookUpNamesNumbers(&m);
    Print(m);
}
複製程式碼

在這裡,變數的型別和使用範圍一眼可見,讀者可以瞭解這段程式碼的所有資訊,所以即使是取m這個非常簡短的名字,也不影響讀者理解作者的意圖。

相反的,如果m是一個全域性變數,當你看到下面這段程式碼就會很頭疼,因為你不明確它的型別:

LookUpNamesNumbers(&m);
Print(m);
複製程式碼

駝峰命名中的單元不能超過3個

我們知道駝峰命名可以很清晰地體現變數的含義,但是當駝峰命名中的單元超過了3個之後,就會很影響閱讀體驗:

userFriendsInfoModel

memoryCacheCalculateTool

是不是看上去很吃力?因為我們大腦同時可以記住的資訊非常有限,尤其是在看程式碼的時候,這種短期記憶的侷限性是無法讓我們同時記住或者瞬間理解幾個具有3~4個單元的變數名的。所以我們需要在變數名裡面去除一些不必要的單元:

丟掉不必要的單元

有些單元在變數裡面是可以去掉的,例如:

convertToString可以省略成toString

不能使用大家不熟悉的縮寫

有些縮寫是大家熟知的:

  • doc 可以代替document
  • str 可以代替string

但是如果你想用BEManager來代替BackEndManager就比較不合適了。因為不瞭解的人幾乎是無法猜到這個名稱的真正意義的。

所以遇到類似這種情況我們不能偷懶,該是什麼就是什麼,否則會起到相反的效果。因為它看起來非常陌生,跟我們熟知的一些縮寫規則相去甚遠。

名字不能引起歧義

有些名字會引起歧義,例如:

  • filter:過濾這個詞,可以是過濾出符合標準的,也可以是減少不符合標準的:是兩種完全相反的結果,所以不推薦使用。
  • clip:類似的,到底是在原來的基礎上截掉某一段還是另外截出來某一段呢?同樣也不推薦使用。
  • 布林值:read_password:是表達需要讀取密碼,還是已經讀了密碼呢?所以最好使用need_password或者is_authenticated來代替比較好。通常來說,給布林值的變數加上is,has,can,should這樣的詞可以使布林值表達的意思更加明確

這一節講了很多關於如何起好一個變數名的方法。其實有一個很簡單的原則來判斷這個變數名起的是否是好的:那就是:團隊的新成員是否能迅速理解這個變數名的含義。如果是,那麼這個命名就是成功的;否則就不要偷懶了,起個好名字,對誰都好。其實如果你養成習慣多花幾秒鐘想出個好名字,漸漸地,你會發現你的“命名能力”會很快提升。

如何宣告與使用變數

在寫程式的過程中我們會宣告很多變數(成員變數,臨時變數),而我們要知道變數的宣告與使用策略是會對程式碼的可讀性造成影響的:

  • 變數越多,越難跟蹤它們的動向。
  • 變數的作用域越大,就需要跟蹤它們的動向越久。
  • 變數改變的越頻繁,就越難跟蹤它的當前值。

相對的,對於變數的宣告與使用,我們可以從這四個角度來提高程式碼的可讀性:

  1. 減少變數的個數
  2. 縮小變數的作用域
  3. 縮短變數宣告與使用其程式碼的距離
  4. 變數最好只寫一次

減少變數的個數

在一個函式裡面可能會宣告很多變數,但是有些變數的宣告是毫無意義的,比如:

  • 沒有價值的臨時變數
  • 表示中間結果的變數

沒有價值的臨時變數

有些變數的宣告完全是多此一舉,它們的存在反而加大了閱讀程式碼的成本:

let now = datetime.datatime.now()
root_message.last_view_time = now	
複製程式碼

上面這個now變數的存在是毫無意義的,因為:

  • 沒有拆分任何複雜的表示式
  • datetime.datatime.now已經很清楚地表達了意思
  • 只使用了一次,因此而沒有壓縮任何冗餘的程式碼

所以完全不用這個變數也是完全可以的:

root_message.last_view_time = datetime.datatime.now()
複製程式碼

表示中間結果的變數

有的時候為了達成一個目標,把一件事情分成了兩件事情來做,這兩件事情中間需要一個變數來傳遞結果。但往往這件事情不需要分成兩件事情來做,這個“中間結果”也就不需要了:

看一個比較常見的需求,一個把陣列中的某個值移除的例子:

var remove_value = function (array, value_to_remove){
    var index_to_remove = null;
    for (var i = 0; i < array.length; i+=1){
        if (array[i] === value_to_remove){
            index_to_remove = i;
            break;
        }
    }
    if (index_to_remove !== null){
        array.splice(index_to_remove,1);
    }
} 
複製程式碼

這裡面把這個事情分成了兩件事情來做:

  1. 找出要刪除的元素的序號,儲存在變數index_to_remove裡面。
  2. 拿到index_to_remove以後使用splice方法刪除它。(這段程式碼是JavaScript程式碼)

這個例子對於變數的命名還是比較合格的,但實際上這裡所使用的中間結果變數是完全不需要的,整個過程也不需要分兩個步驟進行。來看一下如何一步實現這個需求:

var remove_value = function (array, value_to_remove){
    for (var i = 0; i < array.length; i+=1){
        if (array[i] === value_to_remove){
            array.splice(i,1);
            return;
        }
    }
} 
複製程式碼

上面的方法裡面,當知道應該刪除的元素的序號i的時候,就直接用它來刪除了應該刪除的元素並立即返回。

除了減輕了記憶體和處理器的負擔(因為不需要開闢新的內容來儲存結果變數以及可能不用完全走遍整個的for語句),閱讀程式碼的人也會很快領會程式碼的意圖。

所以在寫程式碼的時候,如果可以“速戰速決”,就儘量使用最快,最簡潔的方式來實現目的。

縮小變數的作用域

變數的作用域越廣,就越難追蹤它,值也越難控制,所以我們應該讓你的變數對儘量少的程式碼可見

比如類的成員變數就相當於一個“小型區域性變數”。如果這個類比較龐大,我們就會很難追蹤它,因為所有方法都可以“隱式”呼叫它。所以相反地,如果我們可以把它“降格”為區域性變數,就會很容易追蹤它的行蹤:

//成員變數,比較難追蹤
class LargeCass{
  string str_;
  
  void Method1(){
     str_ = ...;
     Method2();
  }
  
  void Method2(){
     //using str_
  }
}
複製程式碼

降格:

//區域性變數,容易追蹤
class LargeCass{
  
  void Method1(){
     string str = ...;
     Method2(str);
  }
  
  void Method2(string str){
     //using str
  }
}
複製程式碼

所以在設計類的時候如果這個資料(變數)可以通過方法引數來傳遞,就不要以成員變數來儲存它。

縮短變數宣告與使用其程式碼的距離

在實現一個函式的時候,我們可能會宣告比較多的變數,但這些變數的使用位置卻不都是在函式開頭。

有一個比較不好的習慣就是無論變數在當前函式的哪個位置使用,都在一開始(函式的開頭)就宣告瞭它們。這樣可能導致的問題是:閱讀程式碼的人讀到函式後半部分的時候就忘記了這個變數的型別和初始值;而且因為在函式的開頭就宣告瞭好幾個變數,也對閱讀程式碼的人的大腦造成了負擔,因為人的短期記憶是有限的,特別是記一些暫時還不知道怎麼用的東西。

因此,如果在函式內部需要在不同地方使用幾個不同的變數,建議在真正使用它們之前再宣告它。

變數最好只寫一次

操作一個變數的地方越多,就越難確定它的當前值。所以在很多語言裡面有其各自的方式讓一些變數不可變(是個常量),比如C++裡的const和Java中的final

如何簡化表示式

有些表示式比較長,很難讓人馬上理解。這時候最好可以將其拆分成更容易的幾個小塊。可以嘗試下面的幾個方法:

  • 使用解釋變數
  • 使用總結變數
  • 使用德摩根定理

使用解釋變數

有些變數會從一個比較長的算式得出,這個表示式可能很難讓人看懂。這時候就需要用一個簡短的“解釋”變數來詮釋算式的含義。使用書中的一個例子:

if line.split(':')[0].strip() == "root"
複製程式碼

其實上面左側的表示式其實得出的是使用者名稱,我們可以用username來替換它:

username = line.split(':')[0].strip()
if username == "root"
複製程式碼

使用總結變數

除了以“變數”替換“算式”,還可以用“變數”來替換含有更多變數更復雜的內容,比如條件語句,這時候該變數可以被稱為"總結變數"。使用書中的一個例子:

if(request.user.id == document.owner_id){
   //do something 
}
複製程式碼

上面這條判斷語句所判斷的是:“該文件的所有者是不是該使用者”。我們可以使用一個總結性的變數user_owns_document來替換它:

final boolean user_owns_document = (request.user.id == document.owner_id);
if (user_owns_document){
   //do something
}
複製程式碼

使用德摩根定理

德摩根定理:

  1. not(a or b or c)等價於(not a) and (not b) and (not c)
  2. not(a and b and c)等價於(not a) or (not b) or (not c)

當我們條件語句裡面存在外部取反的情況,就可以使用德摩根定理來做個轉換。使用書中的一個例子:

//使用德摩根定理轉換以前
if(!(file_exists && !is_protected)){}

//使用德摩根定理轉換以後
if(!file_exists || is_protected){}
複製程式碼

如何讓程式碼具有美感

在讀過一些好的原始碼之後我有一個感受:好的原始碼往往都看上去都很漂亮,很有美感。這裡說的漂亮和美感不是指程式碼的邏輯清晰有條理,而是指感官上的視覺感受讓人感覺很舒服。這是從一種純粹的審美的角度來評價程式碼的:富有美感的程式碼讓人賞心悅目,也容易讓人讀懂。

為了讓程式碼更有美感,採取以下實踐會很有幫助:

  • 用換行和列對齊來讓程式碼更加整齊
  • 選擇一個有意義的順序
  • 把程式碼分成"段落"
  • 保持風格一致性

用換行和列對齊來讓程式碼更加整齊

有些時候,我們可以利用換行和列對齊來讓程式碼顯得更加整齊。

換行

換行比較常用在函式或方法的引數比較多的時候。

使用換行:

- (void)requestWithUrl:(NSString*)url 
  				method:(NSString*)method 
                params:(NSDictionary *)params 
               success:(SuccessBlock)success 
               failure:(FailuireBlock)failure{
    
}
複製程式碼

不使用換行:

- (void)requestWithUrl:(NSString*)url method:(NSString*)method params:(NSDictionary *)params success:(SuccessBlock)success failure:(FailuireBlock)failure{
    
}
複製程式碼

通過比較可以看出,如果不使用換行,就很難一眼看清楚都是用了什麼引數,而且程式碼整體看上去整潔乾淨了很多。

列對齊

在宣告一組變數的時候,由於每個變數名的長度不同,導致了在變數名左側對齊的情況下,等號以及右側的內容沒有對齊:

NSString *name = userInfo[@"name"];
NSString *sex = userInfo[@"sex"];
NSString *address = userInfo[@"address"];
複製程式碼

而如果使用了列對齊的方法,讓等號以及右側的部分對齊的方式會使程式碼看上去更加整潔:

NSString *name    = userInfo[@"name"];
NSString *sex     = userInfo[@"sex"];
NSString *address = userInfo[@"address"];
複製程式碼

這二者的區別在條目數比較多以及變數名稱長度相差較大的時候會更加明顯。

選擇一個有意義的順序

當涉及到相同變數(屬性)組合的存取都存在的時候,最好以一個有意義的順序來排列它們:

  • 讓變數的順序與對應的HTML表單中欄位的順序相匹配
  • 從最重要到最不重要排序
  • 按照字母排序

舉個例子:相同集合裡的元素同時出現的時候最好保證每個元素出現順序是一致的。除了便於閱讀這個好處以外,也有助於能發現漏掉的部分,尤其當元素很多的時候:

//給model賦值
model.name	  = dict["name"];
model.sex 	  = dict["sex"];
model.address = dict["address"];

 ...
  
//拿到model來繪製UI
nameLabel.text    = model.name;
sexLabel.text     = model.sex;
addressLabel.text = model.address;
複製程式碼

把程式碼分成"段落"

在寫文章的時候,為了能讓整個文章看起來結構清晰,我們通常會把大段文字分成一個個小的段落,讓表達相同主旨的語言湊到一起,與其他主旨的內容分隔開來。

而且除了讓讀者明確哪些內容是表達同一主旨之外,把文章分為一個個段落的好處還有便於找到你的閱讀“腳印”,便於段落之間的導航;也可以讓你的閱讀具有一定的節奏感。

其實這些道理同樣適用於寫程式碼:如果你可以把一個擁有好幾個步驟的大段函式,以空行+註釋的方法將每一個步驟區分開來,那麼則會對讀者理解該函式的功能有極大的幫助。這樣一來,程式碼既能有一定的美感,也具備了可讀性。其實可讀性又何嘗不是來自於規則,富有美感的程式碼呢?

BigFunction{
  
     //step1:*****
     ....
       
     //step2:*****
     ...
        
     //step3:*****
     ....
  
}
複製程式碼

保持風格一致性

有些時候,你的某些程式碼風格可能與大眾比較容易接受的風格不太一樣。但是如果你在你自己所寫的程式碼各處能夠保持你這種獨有的風格,也是可以對程式碼的可讀性有積極的幫助的。

比如一個比較經典的程式碼風格問題:

if(condition){

}
複製程式碼

or:

if(condition)
{

}
複製程式碼

對於上面的兩種寫法,每個人對條件判斷右側的大括號的位置會有不同的看法。但是無論你堅持的是哪一個,請在你的程式碼裡做到始終如一。因為如果有某幾個特例的話,是非常影響程式碼的閱讀體驗的。

我們要知道,一個邏輯清晰的程式碼也可以因為留白的不規則,格式不對齊,順序混亂而讓人很難讀懂,這是十分讓人痛心的事情。所以既然你的程式碼在命名上,邏輯上已經很優秀了,就不妨再費一點功夫把她打扮的漂漂亮亮的吧!

如何寫註釋

首先引用書中的一句話:

註釋的目的是儘量幫助讀者瞭解得和作者一樣多。

在你寫程式碼的時候,在腦海中可能會留下一些程式碼裡面很難體現出來的部分:這些部分在別人讀你的程式碼的時候可能很難體會到。而這些“不對稱”的資訊就是需要通過以註釋的方式來告訴閱讀程式碼的人。

想要寫出好的註釋,就需要首先知道:

  • 什麼不能作為註釋
  • 什麼應該作為註釋

什麼不能作為註釋

我們都知道註釋佔用了程式碼的空間,而且實際上對程式本身的執行毫無幫助,所以最好保證它是物有所值的

不幸的是,有一些註釋是毫無價值的,它無情的佔用了程式碼間的空間,影響了閱讀程式碼的人的閱讀效率,也浪費了寫註釋的人的時間。這樣的註釋有以下兩種:

  • 描述能立刻從程式碼自身就能立刻理解的程式碼意圖的註釋
  • 給不好的命名新增的註釋

描述能立刻從程式碼自身就能立刻理解的程式碼意圖的註釋

//add params1 and params2 and return sum of them
- (int)addParam1:(int)param1 param2:(int)param2
複製程式碼

上面這個例子舉的比較簡單,但反映的問題很明顯:這裡面的註釋是完全不需要的,它的存在反而增加了閱讀程式碼的人的工作量。因為他從方法名就可以馬上意會到這個函式的作用了。

給不好的命名新增的註釋

//get information from internet
- (NSString *)getInformation
複製程式碼

該函式返回的是從網路獲取的資訊。但這裡使用了get字首,無法看出資訊的來源。為了補充資訊,使用註釋來彌補。但其實這完全不必要。只要取一個適當的名字就好了:

- (NSString *)fetchInformation
複製程式碼

講完了註釋不應該是什麼內容,現在講一下注釋應該是什麼樣的內容:

什麼應該作為註釋

本書中介紹的註釋大概有以下幾種:

  • 寫程式碼時的思考

  • 對程式碼的評價

  • 常量

  • 全域性觀的概述

寫程式碼時的思考

你的程式碼可能不是一蹴而就的,它的產生可能會需要一些思考的過程。然而很多時候程式碼本身卻無法將這些思考表達出來,所以你就可能有必要通過註釋的方式來呈現你的思考,讓閱讀程式碼的人知道這段程式碼是哪些思考的結晶,從而也讓讀者理解了這段程式碼為什麼這麼寫。如果遇到了比你高明的高手,在他看到你的註釋之後興許會馬上設計出一套更加合適的方案。

對程式碼的評價

有些時候你知道你現在寫的程式碼是個臨時的方案:它可能確實是解決當前問題的一個方法,但是:

  • 你知道同時它也存在著某些缺陷,甚至是陷阱

  • 你不知道有其他的方案可以替代了

  • 你知道有哪個方案可以替代但是由於時間的關係或者自身的能力無法實現

也可能你知道你現在實現的這個方案几乎就是“完美的”,因為如果使用了其他的方案,可能會消耗更多的資源等等。

對於上面這些情況,你都有必要寫上幾個字作為註釋來誠實的告訴閱讀你的這段程式碼的人這段程式碼的情況,比如:

//該方案有一個很容易忽略的陷阱:****
//該方案是存在效能瓶頸,效能瓶頸在其中的**函式中
//該方案的效能可能並不是最好的,因為如果使用某某演算法的話可能會好很多
複製程式碼

常量

在定義常量的時候,在其後面最好新增一個關於它是什麼或者為什麼它是這個值的原因。因為常量通常是不應該被修改的,所以最好把這個常量為什麼是這個值說明一下:

例如:

image_quality = 0.72 // 最佳的size/quanlity比率
retry_limit   = 4    // 伺服器效能所允許的請求失敗的重試上限
複製程式碼

全域性觀的概述

對於一個剛加入團隊的新人來說,除了團隊文化,程式碼規範以外,可能最需要了解的是當前被分配到的專案的一些“全域性觀”的認識:比如組織架構,類與類之間如何互動,資料如何儲存,如何流動,以及模組的入口點等等。

有時僅僅新增了幾句話,可能就會讓新人迅速地瞭解當前系統或者當前類的結構以及作用,而且這些也同樣對開發過當前系統的人員迅速回憶出之前開發的細節有很大幫助。

這些註釋可以在一個類的開頭(介紹這個類的職責,以及在整個系統中的角色)也可以在一個模組入口處。書中舉了一個關於這種註釋的例子:

//這個檔案包含了一些輔助函式,尾門的檔案系統提供了更便利的介面
複製程式碼

再舉一個iOS開發裡眾所周知的網路框架AFNetworking的例子。在AFHTTPSessionManager的標頭檔案裡說明了這個類的職責:

//AFHTTPSessionManager` is a subclass of `AFURLSessionManager` with convenience methods for making HTTP requests. When a `baseURL` is provided, requests made with the `GET` / `POST` / et al. convenience methods can be made with relative paths
複製程式碼

在知道了什麼不應該是註釋以及什麼應該是註釋以後,我們來看一下一個真正合格的註釋應該是什麼樣子的:

註釋應當有很高的資訊/空間率

也就是說,註釋應該用最簡短的話來最明確地表達意圖。要做到這一點需要做的努力是:

  • 讓註釋保持緊湊:儘量用最簡潔的話來表達,不應該有重複的內容
  • 準確地描述函式的行為:要把函式的具體行為準確表達出來,不能停留在表明
  • 用輸入/輸出的例子來說明特別的情況:有時相對於文字,可能用一個實際的引數和返回值就能立刻體現出函式的作用。而且有些特殊情況也可以通過這個方式來提醒閱讀程式碼的人
  • 宣告程式碼的意圖:也就是說明這段程式碼存在的意義,你為什麼當時是這麼寫的原因

其實好的程式碼是自解釋的,由於其命名的合理以及架構的清晰,幾乎不需要註釋來向閱讀程式碼的人新增額外的資訊,書中有一個公式可以很形象地表明一個好的程式碼本身的重要性:

好程式碼 > (壞程式碼 + 註釋)

三. 控制流和邏輯的改進

控制流在編碼中佔據著很重要的位置,它往往代表著一些核心邏輯和演算法。因此,如果我們可以讓控制流變得看上去更加“自然”,那麼就會對閱讀程式碼的人理解這些邏輯甚至是整個系統提供很大的幫助。

那麼都有哪相關實踐呢?

  • 使用符合人類自然語言的表達習慣
  • if/else語句塊的順序
  • 使用return提前返回

使用符合人類自然語言的表達習慣

寫程式碼也是一個表達的過程,雖然表現形式不同,但是如果我們能夠採用符合人類自然語言習慣的表達習慣來寫程式碼,對閱讀程式碼的人理解我們的程式碼是很有幫助的。

這裡有兩個比較典型的情景:

  1. 條件語句中引數的順序
  2. 條件語句中的正負邏輯

條件語句中引數的順序:

首先比較一下下面兩段程式碼,哪一個更容易讀懂?

//code 1
if(length > 10)

//code 2
if(10 < length)
複製程式碼

大家習慣上應該會覺得code1容易讀懂。

再來看下面一個例子:

//code 3
if(received_number < standard_number) 

//code 4
if( standard_number< received_number)
複製程式碼

仔細看會發現,和上面那一組情況類似,大多數人還是會覺得code3更容易讀懂。

那麼code1 和 code3有什麼共性呢?

它們的共性就是:左側都是被詢問的內容(通常是一個變數);右側都是用來做比較的內容(通常是一個常量)

這應該是符合自然語言的一個順序。比如我們一般會說“今天的氣溫大於20攝氏度”,而不習慣說“20攝氏度小於今天的氣溫”。

條件語句中的正負邏輯:

在判斷一些正負邏輯的時候,建議使用if(result)而不是if(!result)

因為大腦比較容易處理正邏輯,比如我們可能比較習慣說“某某某是個男人”,而不習慣說“某某某不是個女人”。如果我們使用了負邏輯,大腦還要對它進行取反,相當於多做了一次處理。

if/else語句塊的順序

在寫if/else語句的時候,可能會有很多不同的互斥情況(好多個elseif)。那麼這些互斥的情況可以遵循哪些順序呢?

  • 先處理掉簡單的情況,後處理複雜的情況:這樣有助於閱讀程式碼的人循序漸進地地理解你的邏輯,而不是一開始就吃掉一個胖子,耗費不少精力。
  • 先處理特殊或者可疑的情況,後處理正常的情況:這樣有助於閱讀程式碼的人會馬上看到當前邏輯的邊界條件以及需要注意的地方。

使用return提前返回

在一個函式或是方法裡,可能有一些情況是比較特殊或者極端的,對結果的產生影響很大(甚至是終止繼續進行)。如果存在這些情況,我們應該把他們寫在前面,用return來提前返回(或者返回需要返回的返回值)。

這樣做的好處是可以減少if/else語句的巢狀,也可以明確體現出:“哪些情況是引起異常的”。

再舉一個JSONModel裡的例子,在initWithDictionary:error方法裡面就有很多return操作,它們都體現出了“在什麼情況下是不能成功將字典轉化為model物件”的;而且在方法的最後返回了物件,說明如果到了這一步,則在轉化的過程中通過了層層考驗:

-(id)initWithDictionary:(NSDictionary*)dict error:(NSError**)err
{
    //check for nil input
    if (!dict) {
        if (err) *err = [JSONModelError errorInputIsNil];
        return nil;
    }

    //invalid input, just create empty instance
    if (![dict isKindOfClass:[NSDictionary class]]) {
        if (err) *err = [JSONModelError errorInvalidDataWithMessage:@"Attempt to initialize JSONModel object using initWithDictionary:error: but the dictionary parameter was not an 'NSDictionary'."];
        return nil;
    }

    //create a class instance
    self = [self init];
    if (!self) {

        //super init didn't succeed
        if (err) *err = [JSONModelError errorModelIsInvalid];
        return nil;
    }

    //check incoming data structure
    if (![self __doesDictionary:dict matchModelWithKeyMapper:self.__keyMapper error:err]) {
        return nil;
    }

    //import the data from a dictionary
    if (![self __importDictionary:dict withKeyMapper:self.__keyMapper validation:YES error:err]) {
        return nil;
    }

    //run any custom model validation
    if (![self validate:err]) {
        return nil;
    }

    //model is valid! yay!
    return self;
}
複製程式碼

四. 程式碼組織的改進

關於程式碼組織的改進,作者介紹了以下三種方法:

  • 抽取出與程式主要目的“不相關的子邏輯”
  • 重新組織程式碼使它一次只做一件事情
  • 藉助自然語言描述來將想法變成程式碼

抽取出與程式主要目的“不相關的子邏輯”

一個函式裡面往往包含了其主邏輯與子邏輯,我們應該積極地發現並抽取出與主邏輯不相關的子邏輯。具體思考的步驟是:

  1. 首先確認這段程式碼的高層次目標是什麼(主要目標)?
  2. 對於每一行程式碼,都要反思一下:“它是直接為了目標而工作麼?”
  3. 如果答案是肯定的並且這些程式碼佔據著一定數量的行數,我們就應該將他們抽取到獨立的函式中。

比如某個函式的目標是為了尋找距離某個商家最近的地鐵口,那麼這其中一定會重複出現一些計算兩組經緯度之間距離的子邏輯。但是這些子邏輯的具體實現是不應該出現在這個主函式裡面的,因為這些細節與這個主函式的目標來講應該是無關的。

即是說,像這種類似於工具方法的函式其實是脫離於某個具體的需求的:它可以用在其他的主函式中,也可以放在其他的專案裡面。比如找到離運動場場最近的幾個公交站這個需求等等。

而像這種“抽取子邏輯或工具方法”的做法有什麼好處呢?

  • 提高了程式碼的可讀性:將函式的呼叫與原來複雜的實現進行替換,讓閱讀程式碼的人很快能瞭解到該子邏輯的目的,讓他們把注意力放在更高層的主邏輯上,而不會被子邏輯的實現(往往是複雜無味的)所影響。
  • 便於修改和除錯:因為一個專案中可能會多次呼叫該子邏輯(計算距離,計算匯率,保留小數點),當業務需求發生改變的時候只需要改變這一處就可以了,而且除錯起來也非常容易。
  • 便於測試:同理,也是因為可以被多次呼叫,在進行測試的時候就比較有針對性。

從函式擴大到專案,其實在一個專案裡面,有很多東西不是當前這個專案所專有的,它們是可以用在其他專案中的一些“通用程式碼”。這些通用程式碼可以對當前的專案一無所知,可以被用在其他任何專案中去。

我們可以養成這個習慣,“把一般程式碼與專案專有程式碼分開”,並不斷擴大我們的通用程式碼庫來解決更多的一般性問題。

重新組織程式碼使它一次只做一件事情

一個比較大的函式或者功能可能由很多工程式碼組合而來,在這個時候我們有必要將他們分為更小的函式來呼叫它們。

這樣做的好處是:我們可以清晰地看到這個功能是如何一步一步完成的,而且拆分出來的小的函式或許也可以用在其他的地方。

所以如果你遇到了比較難讀懂的程式碼,可以嘗試將它所做的所有任務列出來。可能馬上你就會發現這其中有些任務可以轉化成單獨的函式或者類。而其他的部分可以簡單的成為函式中的一個邏輯段落。

藉助自然語言描述來將想法變成程式碼

在設計一個解決方案之前,如果你能夠用自然語言把問題說清楚會對整個設計非常有幫助。因為如果直接從大腦中的想法轉化為程式碼,可能會露掉一些東西。

但是如果你可以將整個問題和想法滴水不漏地說出來,就可能會發現一些之前沒有想到的問題。這樣可以不斷完善你的思路和設計。

五. 最後想說的

這本書從變數的命名到程式碼的組織來講解了一些讓程式碼的可讀性提高的一些實踐方法。

其實筆者認為程式碼的可讀性也可以算作是一種溝通能力的一種體現。因為寫程式碼的過程也可以被看做是寫程式碼的人與閱讀程式碼的人的一種溝通,只不過這個溝通是單向的:程式碼的可讀性高,可以說明寫程式碼的人思路清晰,而且TA可以明確,高效地把自己的思考和工作內容以程式碼的形式表述出來。 所以筆者相信能寫出可讀性很高的程式碼的人,TA對於自己的思考和想法的描述能力一定不會很差。

如果你真的打算好好做程式設計這件事情,建議你從最小的事情上做起:好好為你的變數起個名字。不要再以“我英語不好”或者“沒時間想名字”作為託辭;把態度端正起來,平時多動腦,多查字典,多看原始碼,自然就會了。

如果你連起個好的變數名都懶得查個字典,那你怎麼證明你在遇到更難的問題的時候能夠以科學的態度解決它? 如果你連程式設計裡這種最小的事情都不好好做,那你又怎麼證明你對程式設計是有追求的呢?


本文已經同步到我的個人部落格:傳送門

---------------------------- 2018年7月17日更新 ----------------------------

注意注意!!!

筆者在近期開通了個人公眾號,主要分享程式設計,讀書筆記,思考類的文章。

  • 程式設計類文章:包括筆者以前釋出的精選技術文章,以及後續釋出的技術文章(以原創為主),並且逐漸脫離 iOS 的內容,將側重點會轉移到提高程式設計能力的方向上。
  • 讀書筆記類文章:分享程式設計類思考類心理類職場類書籍的讀書筆記。
  • 思考類文章:分享筆者平時在技術上生活上的思考。

因為公眾號每天釋出的訊息數有限制,所以到目前為止還沒有將所有過去的精選文章都發布在公眾號上,後續會逐步釋出的。

而且因為各大部落格平臺的各種限制,後面還會在公眾號上釋出一些短小精幹,以小見大的乾貨文章哦~

掃下方的公眾號二維碼並點選關注,期待與您的共同成長~

公眾號:程式設計師維他命

相關文章