軟體設計的哲學:第十八章 程式碼的可見性
目錄
- 18.1 使程式碼更簡單的東西
- 18.2 使程式碼不那麼明顯的事情
- 18.3 結論
晦澀是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>();
變數被宣告為一個列表,但是實際的值是一個ArrayList。這段程式碼是合法的,因為List是ArrayList的一個超類,但是它會誤導那些只看到宣告而沒有看到實際分配的讀者。實際型別可能會影響變數的使用方式(與List的其他子類相比,arraylist具有不同的效能和執行緒安全屬性),因此最好將宣告與分配匹配起來。
違反讀者期望的程式碼。考慮以下程式碼,它是Java應用程式的主程式:
public static void main(String[] args) {
...
new RaftClient(myAddress, serverAddresses);
}
大多數應用程式在它們的主程式返回時退出,所以讀者可能會認為這將在這裡發生。然而,事實並非如此。RaftClient的建構函式建立了額外的執行緒,即使應用程式的主執行緒已經結束,這些執行緒仍然繼續執行。這種行為應該被記錄在RaftClient建構函式的介面註釋中,但是這種行為還不夠明顯,值得在main的末尾加上簡短的註釋。註釋應該表明應用程式將繼續在其他執行緒中執行。如果程式碼符合讀者期望的約定,那麼它就是最明顯的;如果沒有,那麼記錄這種行為很重要,這樣讀者就不會感到困惑。
18.3 結論
另一種思考簡單易解性的方式是資訊。如果程式碼不明顯,這通常意味著有關於程式碼的重要資訊讀者沒有得到:在RaftClient示例中,讀者可能不知道RaftClient建構函式建立了新執行緒;在結對示例中,讀者可能不知道result.getKey()返回當前項的編號。
為了使程式碼更容易理解,您必須確保讀者始終擁有他們需要的資訊。有三種方法可以做到這一點:
- 最好的方法是使用抽象和消除特殊情況等設計技術來減少所需的資訊量。
- 其次,您可以利用讀者在其他上下文中已經獲得的資訊(例如,通過遵循約定和遵從期望),這樣讀者就不必為您的程式碼學習新的資訊。
- 第三,您可以使用良好的名稱和策略註釋等技術,在程式碼中向他們顯示重要的資訊。