第 18 章 程式碼應該是易理解的
模糊性是第 2.3 節所述的複雜性的兩個主要成因之一。當新開發人員無法輕易看出系統的重要資訊時,就會產生模糊性。解決辦法是讓程式碼易於理解。本章討論哪些因素會讓程式碼更易理解,哪些會讓程式碼更難理解。
本章概覽什麼讓程式碼難理解,什麼讓它易理解?
如果程式碼易於理解,人們就能快速讀懂,無需多想,對程式碼行為或含義的最初判斷就是正確的。讀者不需要花費太多時間或精力,就能收集到使用程式碼所需的全部資訊。如果程式碼難以理解,讀者就必須花費大量時間和精力來弄懂它。這不僅會降低效率,還會增加誤解和引入缺陷的可能性。易理解的程式碼比難理解的程式碼需要的註解更少。
這裡說的“易理解”取決於讀者的感受:發現別人程式碼裡難以理解的地方,比發現自己程式碼中的問題容易得多。因此,確定程式碼是否易理解的最佳方法是程式碼審查。如果閱讀你程式碼的人說它難以理解,那麼它就是難以理解的,無論你自己覺得它多麼清晰。試著弄清楚程式碼為什麼難以理解,你就能學會如何在今後寫出更好的程式碼。
18.1 使程式碼更易理解的事情
前面的章節已經討論了使程式碼易理解的兩種最重要的技術。首先是選取好名稱(第 14 章)。精確而有意義的名稱可以闡明程式碼的行為,並減少對文件的需求。如果名稱含糊不清,讀者就不得不通讀程式碼,以推斷被命名實體的含義,這既費時又容易出錯。第二種技術是一致性(第 17 章)。如果總是以相似的方式完成相似的事情,讀者就可以識別出以前見過的模式,無需詳細分析程式碼就能立即得出可靠的結論。
以下是其它一些使程式碼更易理解的通用技術:
明智地使用空格。程式碼的排版會影響它是否容易理解。看看以下參數文件,其中的空格已被壓縮:
/**
* ...
* @param numThreads The number of threads that this manager should
* spin up in order to manage ongoing connections. The MessageManager
* spins up at least one thread for every open connection, so this
* should be at least equal to the number of connections you expect
* to be open at once. This should be a multiple of that number if
* you expect to send a lot of messages in a short amount of time.
* @param handler Used as a callback in order to handle incoming
* messages on this MessageManager's open connections. See
* {@code MessageHandler} and {@code handleMessage} for details.
*/
很難看出一個參數的文件在哪裡結束,下一個參數的文件又在哪裡開始,甚至看不出有多少個參數、它們的名稱是什麼。新增一些空格後,結構就會變得清晰,文件也更容易閱讀:
/**
* @param numThreads
* The number of threads that this manager should spin up in
* order to manage ongoing connections. The MessageManager spins
* up at least one thread for every open connection, so this
* should be at least equal to the number of connections you
* expect to be open at once. This should be a multiple of that
* number if you expect to send a lot of messages in a short
* amount of time.
* @param handler
* Used as a callback in order to handle incoming messages on
* this MessageManager's open connections. See
* {@code MessageHandler} and {@code handleMessage} for details.
*/
空白行也可用於分隔方法中的主要程式碼塊,例如以下範例:
void* Buffer::allocAux(size_t numBytes) {
// Round up the length to a multiple of 8 bytes, to ensure alignment.
uint32_t numBytes32 = (downCast<uint32_t>(numBytes) + 7) & ~0x7;
assert(numBytes32 != 0);
// If there is enough memory at firstAvailable, use that. Work down
// from the top, because this memory is guaranteed to be aligned
// (memory at the bottom may have been used for variable-size chunks).
if (availableLength >= numBytes32) {
availableLength -= numBytes32;
return firstAvailable + availableLength;
}
// Next, see if there is extra space at the end of the last chunk.
if (extraAppendBytes >= numBytes32) {
extraAppendBytes -= numBytes32;
return lastChunk->data + lastChunk->length + extraAppendBytes;
}
// Must create a new space allocation; allocate space within it.
uint32_t allocatedLength;
firstAvailable = getNewAllocation(numBytes32, &allocatedLength);
availableLength = allocatedLength - numBytes32;
return firstAvailable + availableLength;
}
如果每個空白行之後的第一行是描述下一個程式碼塊的註解,這種方法就特別有效:空白行使註解更加醒目。
語句中的空格有助於呈現語句的結構。比較以下兩個語句,一個加了空格,另一個沒有:
for(int pass=1;pass>=0&&!empty;pass--) {
for (int pass = 1; pass >= 0 && !empty; pass--) {
註解。有時無法避免難以理解的程式碼。這時,重要的是用註解補充讀者缺少的資訊。要做好這一點,你必須站在讀者的角度,弄清楚什麼可能使他們感到困惑,以及哪些資訊可以消除這種困惑。下一節將介紹幾個範例。
18.2 使程式碼難理解的事情
有很多因素會使程式碼變得難以理解,本節提供了一些範例。其中一些做法,例如事件驅動程式設計,在某些情況下很有用,所以你可能仍會使用它們。這時,補充文件有助於儘量減少讀者的困惑。
事件驅動程式設計。在事件驅動程式設計中,應用程式響應外部事件,例如收到網路資料包或滑鼠按鈕被按下。一個模組負責報告傳入的事件;應用程式的其他部分向事件模組註冊自己對某些事件的關注,要求它在這些事件發生時呼叫指定的函式或方法。
事件驅動程式設計使控制流程難以跟蹤。事件處理函式不會被直接呼叫,而是由事件模組間接呼叫,通常透過函式指標或介面來實作。即使你在事件模組中找到了呼叫點,也仍然無法確定具體會呼叫哪個函式:這取決於執行時註冊了哪些處理常式。正因為如此,很難推斷事件驅動程式碼的行為,也很難確信它能正確工作。
為了彌補這種模糊性,可以在每個處理函式的介面註解中說明它何時會被呼叫,如以下範例所示:
/**
* This method is invoked in the dispatch thread by a transport if a
* transport-level error prevents an RPC from completing.
*/
void Transport::RpcNotifier::failed() {
...
}
譯者圖解呼叫點看不到的執行時連線
危險訊號:難理解的程式碼
如果無法透過快速閱讀來理解程式碼的含義和行為,這就是一個危險訊號。通常,這意味著閱讀程式碼的人無法立即瞭解某些重要資訊。
通用容器。許多語言提供了將兩個或多個元素組合到一個物件中的通用類,例如 Java 中的 Pair 或 C++ 中的 std::pair。這些類很有吸引力,因為使用它們可以方便地透過單個變數傳遞多個物件。最常見的用途之一是從一個方法回傳多個值,如以下 Java 範例所示:
return new Pair<Integer, Boolean>(currentTerm, false);
不幸的是,通用容器會導致程式碼不清晰,因為組合在一起的元素使用的是含義模糊的通用名稱。在上面的範例中,呼叫者必須使用 result.getKey() 和 result.getValue() 引用回傳的兩個值,而這些名稱無法說明值的實際含義。
因此,最好不要使用通用容器。如果需要容器,請為特定用途定義新的類或結構。這樣,你就可以為元素使用有意義的名稱,並在宣告中提供額外的文件,而使用通用容器無法做到這些。
此範例說明了一條通用規則:軟體應設計得易於閱讀,而不是易於編寫。通用容器對編寫程式碼的人很方便,卻會給所有後續讀者帶來困惑。編寫程式碼時,多花幾分鐘定義專用的容器結構是更好的選擇,這樣寫出來的程式碼更容易理解。
宣告型別與分配物件時使用的型別不同。考慮以下 Java 範例:
private List<Message> incomingMessageList;
...
incomingMessageList = new ArrayList<Message>();
該變數被宣告為 List,但實際分配的物件是 ArrayList。這段程式碼是合法的,因為 List 是 ArrayList 的超類,但是它會誤導只看到宣告、沒看到實際物件分配的讀者。實際型別可能會影響變數的使用方式(ArrayList 與 List 的其他子類相比,具有不同的效能和執行緒安全屬性),因此最好讓宣告型別與分配物件時使用的型別一致。
譯者注(勘誤):在 Java 中,
List是介面,ArrayList實作該介面,其直接父類是AbstractList。原文這裡的“超類/子類”稱謂不準確,正文保留原文表述。參見 Java API 文件。
不符合讀者預期的程式碼。考慮以下程式碼,這是 Java 應用程式的主程式:
public static void main(String[] args) {
...
new RaftClient(myAddress, serverAddresses);
}
大多數應用程式會在主程式回傳時退出,因此讀者可能會認為這裡也是如此。然而,事實並非如此。RaftClient 的建構函式建立了額外的執行緒,即使應用程式的主執行緒結束,這些執行緒仍會繼續執行。應該在 RaftClient 建構函式的介面註解中記錄這一行為。但由於這一行為不夠明顯,也值得在 main 方法的末尾新增簡短註解,說明應用程式將繼續在其他執行緒中執行。如果程式碼遵循的約定符合讀者的預期,就容易理解。否則,就有必要記錄其行為,以免讀者感到困惑。
譯者圖解main 回傳,不等於這個程式結束
18.3 結論
也可以從資訊的角度來思考程式碼是否易理解。如果程式碼難以理解,通常意味著讀者不瞭解與程式碼有關的某些重要資訊:在 RaftClient 範例中,讀者可能不知道 RaftClient 建構函式建立了新執行緒;在 Pair 範例中,讀者可能不知道 result.getKey() 回傳當前任期的編號。
為了使程式碼容易理解,你必須確保讀者始終掌握理解程式碼所需的資訊。可以透過三種方式做到這一點。最好的方法是使用抽象、消除特殊情況等設計技術,減少讀者需要了解的資訊量。其次,可以利用讀者在其他場景中已經掌握的資訊,例如遵循約定、符合預期,這樣讀者就不必為理解你的程式碼而學習新資訊。第三,可以透過好名稱和有針對性的註解,在程式碼中向讀者提供重要資訊。