第 13 章 註釋應該描述程式碼中難以理解的內容
編寫註釋的原因是,使用程式語言編寫的語句無法表達編寫程式碼時開發人員想到的所有重要資訊。註釋記錄了這些資訊,以便後來的開發人員可以輕鬆地理解和修改程式碼。註釋的指導原則是,註釋應該描述程式碼中難以理解的內容。
從程式碼來看,有許多事情並不容易理解。有時,是底層細節不容易理解。例如,當用一對索引描述一個範圍時,由索引給出的元素是在範圍之內還是之外並不明顯。有時不清楚為什麼需要一些程式碼,或者為什麼要以特定方式實現程式碼。有時,開發人員遵循一些規則,例如 “總是在 b 之前呼叫 a”。您可能可以透過檢視所有程式碼來猜測出規則,但這很痛苦且容易出錯。註釋可以使規則清晰明瞭。
寫註釋的最重要原因之一是抽象,其中包括許多從程式碼中看不到的資訊。抽象的思想是提供一種思考問題的簡單方法,但是程式碼是如此詳細,以至於很難僅透過閱讀程式碼就看到抽象。註釋可以提供一個更簡單、更高層級的檢視(比如:呼叫此方法後,網路流量將被限制為每秒 maxBandwidth
位元組)。即使可以透過閱讀程式碼推斷出此資訊,我們也不想強迫模組使用者這樣做:閱讀程式碼很耗時,並且會迫使他們考慮很多使用該模組不需要的資訊。開發人員無需閱讀除其外部可見宣告以外的任何程式碼就應該能夠理解模組提供的抽象。 唯一的方法是用註釋來補充宣告。
本章討論需要在註釋中描述哪些資訊以及如何編寫良好的註釋。就像您將看到的那樣,好的註釋通常以與程式碼不同的詳細程度來解釋事物,在某些情況下,註釋會更詳細,而在某些情況下,會不那麼詳細(更抽象)。
13.1 選擇約定
編寫註釋的第一步是確定註釋的約定,例如您要註釋的內容和註釋的格式。如果您正在使用已經有文件編譯工具的語言進行程式設計,例如 Java 的 Javadoc, C++ 的 Doxygen 或 Go!的 godoc,請遵循這些工具的約定。這些約定都不是完美的,但是這些工具可提供足夠的好處來彌補這一缺點。如果在沒有現有約定可遵循的環境中進行程式設計,請嘗試採用其他類似的語言或專案中的約定,這將使其他開發人員更容易理解和遵守您的約定。
約定有兩個目的。首先,它們確保一致性,這使得註釋更易於閱讀和理解。其次,它們有助於確保您實際編寫註釋。如果您不清楚要註釋的內容以及寫註釋的方式,那麼最終很容易根本不寫註釋。
大多數註釋屬於以下類別之一:
介面:在模組宣告(例如類、資料結構、函式或方法)之前的註釋塊。該註釋描述模組的介面。對於一個類,註釋描述了該類提供的整體抽象。對於方法或函式,註釋描述其整體行為、其引數和返回值(如果有)、其產生的任何副作用或異常、以及呼叫者在呼叫該方法之前必須滿足的任何其他要求。
資料結構成員:資料結構中欄位宣告旁邊的註釋,例如類的例項變數或靜態變數。
實現註釋:方法或函式程式碼內部的註釋,它描述程式碼在內部的工作方式。
跨模組註釋:描述跨模組邊界的依賴項的註釋。
最重要的註釋是前兩個類別中的註釋。每個類都應有一個介面註釋,每個類變數應有一個註釋,每個方法都應有一個介面註釋。有時,變數或方法的宣告是如此明顯,以至於在註釋中新增任何內容都沒有實際幫助(getter
和 setter
有時都屬於此類),但這很少見。註釋所有這些內容要比花精力擔心是否需要註釋容易得多。具體實現的註釋通常是不必要的(請參閱下面的第 13.6 節)。跨模組註釋是最罕見的,而且編寫起來很成問題,但是當需要它們時,它們就很重要。第 13.7 節將更詳細地討論它們。
13.2 不要重複程式碼
不幸的是,許多註釋並不是特別有用。最常見的原因是註釋重複了程式碼:可以輕鬆地從註釋旁邊的程式碼中推斷出註釋中的所有資訊。這是最近研究論文中出現的程式碼示例:
ptr_copy = get_copy(obj) # Get pointer copy
if is_unlocked(ptr_copy): # Is obj free?
return obj # return current obj
if is_copy(ptr_copy): # Already a copy?
return obj # return obj
thread_id = get_thread_id(ptr_copy)
if thread_id == ctx.thread_id: # Locked by current ctx
return ptr_copy # Return copy
這些註釋中幾乎沒有任何有用的資訊,除了 Locked by
,該註釋暗示了某些執行緒相關的資訊可能在程式碼中並不明顯。請注意,這些註釋的詳細程度與程式碼大致相同:每行程式碼有一個註釋,用於描述該行。這樣的註釋基本沒用。
以下是註釋重複了程式碼的更多示例:
// Add a horizontal scroll bar
hScrollBar = new JScrollBar(JScrollBar.HORIZONTAL);
add(hScrollBar, BorderLayout.SOUTH);
// Add a vertical scroll bar
vScrollBar = new JScrollBar(JScrollBar.VERTICAL);
add(vScrollBar, BorderLayout.EAST);
// Initialize the caret-position related values
caretX = 0;
caretY = 0;
caretMemX = null;
這些註釋均未提供任何價值。對於前兩個註釋,程式碼已經很清楚了,它實際上不需要註釋。第三個註釋可能有用,但是當前註釋沒有提供足夠的細節來提供幫助。
編寫註釋後,請問自己以下問題:從未看過程式碼的人能否僅透過檢視註釋旁邊的程式碼來寫出這樣的註釋?如果答案是肯定的(如上述示例所示),則註釋不會使程式碼更易於理解。像這樣的註釋是為什麼有些人認為註釋毫無價值的原因。
另一個常見的錯誤是在註釋中使用與被註釋實體相同名稱的詞:
/*
* Obtain a normalized resource name from REQ.
*/
private static String[] getNormalizedResourceNames(HTTPRequest req) ...
/*
* Downcast PARAMETER to TYPE.
*/
private static Object downCastParameter(String parameter, String type) ...
/*
* The horizontal padding of each line in the text.
*/
private static final int textHorizontalPadding = 4;
危險訊號:註釋重複了程式碼
如果註釋中的資訊可以很明顯的從旁邊的程式碼中看出,則註釋是沒有幫助的。這樣的一個例子是,當註釋使用與所描述事物名稱相同的單詞時。
這些註釋只是從方法或變數名中提取單詞,或者從引數名稱和型別中新增幾個單詞,然後將它們組成一個句子。例如,第二個註釋中唯一不在程式碼中的是單詞 to
!再一次,這些註釋可以僅透過檢視宣告來編寫,無需對變數的方法有任何瞭解,所以它們沒有價值。
同時,註釋中缺少一些重要資訊:例如,什麼是規範化的資源名稱(normalized resource name)?getNormalizedResourceNames
返回的陣列的元素是什麼?“downcast” 是什麼意思?填充(padding)的單位是什麼,是僅在每行的一邊填充還是兩邊都填充?在註釋中描述這些內容將很有幫助。
編寫良好註釋的第一步是 在註釋中使用與被描述實體名稱不同的詞。為註釋選擇單詞,以提供有關實體含義的更多資訊,而不僅僅是重複其名稱。例如,以下是針對 textHorizontalPadding 的更好註釋:
/*
* The amount of blank space to leave on the left and
* right sides of each line of text, in pixels.
*/
private static final int textHorizontalPadding = 4;
該註釋提供了從宣告本身看不出來的額外資訊,例如單位(畫素)以及填充適用於每行兩邊的事實。考慮到讀者可能不熟悉術語“填充”,註釋沒有直接使用這個術語,而是解釋了什麼是填充。
13.3 更低層級的註釋可提高精確度
現在您知道了不應該做的事情,讓我們討論應該在註釋中新增哪些資訊。註釋透過提供不同詳細程度的資訊來增強程式碼。 一些註釋提供了比程式碼更低層級、更詳細的資訊。這些註釋透過闡明程式碼的確切含義來增加精確度。其他註釋提供了比程式碼更高層級、更抽象的資訊。這些註釋反映了直覺,例如程式碼背後的考量,或者更簡單、更抽象的程式碼思考方式。與程式碼處於同一層級的註釋可能會重複該程式碼。本節將詳細地討論更低層級的方式,而下一節將討論更高層級的方式。
在註釋變數宣告(例如類例項變數、方法引數和返回值)時,精確度最有用。變數宣告中的名稱和型別通常不是很精確。註釋可以填寫缺少的詳細資訊,例如:
- 此變數的單位是什麼?
- 邊界條件是包含還是排除?
- 如果允許使用空值,那麼它意味著什麼?
- 如果變數引用了最終必須釋放或關閉的資源,那麼誰負責釋放或關閉該資源?
- 是否存在某些對於變數始終不變的屬性(不變數),例如“此列表始終包含至少一個條目”?
這部分資訊可以透過檢查使用該變數的所有程式碼推斷出來。但是,這很耗時且容易出錯。宣告的註釋應清晰和完整,使讀者沒必要透過檢查使用該變數的所有程式碼來了解這些資訊。另外,當我說宣告的註釋應描述程式碼中難以理解的內容時,這裡的“程式碼”是指註釋旁邊的程式碼(即宣告),而不是“應用程式中的所有程式碼”。
變數註釋最常見的問題是註釋太模糊。這是兩個不夠精確的註釋示例:
// Current offset in resp Buffer
uint32_t offset;
// Contains all line-widths inside the document and
// number of appearances.
private TreeMap<Integer, Integer> lineWidths;
在第一個示例中,Current
的含義不清晰。在第二個示例中,不清楚 TreeMap
中的鍵是不是線寬、值是不是出現次數。另外,寬度是以畫素或字元為單位嗎?以下修訂後的註釋提供了更多詳細資訊:
// Position in this buffer of the first object that hasn't
// been returned to the client.
uint32_t offset;
// Holds statistics about line lengths of the form <length, count>
// where length is the number of characters in a line (including
// the newline), and count is the number of lines with
// exactly that many characters. If there are no lines with
// a particular length, then there is no entry for that length.
private TreeMap<Integer, Integer> numLinesWithLength;
第二個宣告使用一個較長的名稱 numLinesWithLength
來傳達更多資訊。它還將“寬度(Width)”更改為“長度(Length)”,因為該術語更可能使人們認為單位是字元而不是畫素。請注意,第二條註釋不僅記錄了每個條目的詳細資訊,還記錄了缺失條目的含義。
在為變數添加註釋時,請考慮使用名詞而不是動詞。換句話說,關注變數代表什麼,而不是如何被操縱。考慮以下注釋:
/* FOLLOWER VARIABLE: indicator variable that allows the Receiver and the
* PeriodicTasks thread to communicate about whether a heartbeat has been
* received within the follower's election timeout window.
* Toggled to TRUE when a valid heartbeat is received.
* Toggled to FALSE when the election timeout window is reset.
*/
private boolean receivedValidHeartbeat;
該文件描述瞭如何透過類中的幾段程式碼來修改變數。如果註釋描述變數代表什麼而不是重複程式碼邏輯,則註釋將更短且更有用:
/* True means that a heartbeat has been received since the last time
* the election timer was reset. Used for communication between the
* Receiver and PeriodicTasks threads.
*/
private boolean receivedValidHeartbeat;
基於該文件,很容易推斷出,當接收到心跳訊號時,變數必須設定為真;而當重置選舉計時器時,則必須將變數設定為假。
13.4 更高層級的註釋可增強直覺
註釋可以增加程式碼可讀性的第二種方式是提供直覺。這些註釋是在比程式碼更高的層級上編寫的。它們忽略了細節,並幫助讀者理解了程式碼的整體意圖和結構。這種方式通常用於方法內部的註釋以及介面註釋。例如,考慮以下程式碼:
// If there is a LOADING readRpc using the same session
// as PKHash pointed to by assignPos, and the last PKHash
// in that readRPC is smaller than current assigning
// PKHash, then we put assigning PKHash into that readRPC.
int readActiveRpcId = RPC_ID_NOT_ASSIGNED;
for (int i = 0; i < NUM_READ_RPC; i++) {
if (session == readRpc[i].session
&& readRpc[i].status == LOADING
&& readRpc[i].maxPos < assignPos
&& readRpc[i].numHashes < MAX_PKHASHES_PERRPC) {
readActiveRpcId = i;
break;
}
}
該註釋太底層也太詳細。一方面,它部分重複了程式碼:比如 if there is a LOADING readRPC
只是重複了 readRpc[i].status == LOADING
。另一方面,註釋沒能解釋程式碼的總體目的,也不能解釋其在包含它的程式碼中的作用。如此一來,這個註釋不能幫助讀者理解程式碼。
這是一個更好的註釋:
// Try to append the current key hash onto an existing
// RPC to the desired server that hasn't been sent yet.
此註釋不包含任何詳細資訊。相反,它在更高層級上描述了程式碼的整體功能。有了這些高層級的資訊,讀者就可以解釋程式碼中發生的幾乎所有事情:迴圈一定是在遍歷所有已經存在的遠端過程呼叫(RPC);會話測試可能用於檢視特定的 RPC 是否發往正確的伺服器;LOADING
測試表明 RPC 可以具有多個狀態,在某些狀態下新增更多的雜湊值是不安全的;MAX_PKHASHES_PERRPC
測試表明在單個 RPC 中可以傳送多少個雜湊值是有限制的。註釋中唯一沒有解釋的是 maxPos
測試。此外,新註釋為讀者評估程式碼提供了基礎:它可以完成將雜湊金鑰新增到一個現有 RPC 所需的一切嗎?而原始的註釋並未描述程式碼的整體意圖,因此,讀者很難確定程式碼是否行為正確。
更高層級的註釋比更低層級的註釋更難編寫,因為您必須以不同的方式考慮程式碼。問問自己:這段程式碼要做什麼?您能以哪種最簡單的方式來解釋程式碼中的所有內容?這段程式碼最重要的是什麼?
工程師往往非常注重細節。我們喜歡細節,善於管理其中的許多細節,這對於成為一名優秀的工程師至關重要。但是,優秀的軟體設計師也可以從細節退後一步,從更高的層級考慮系統。這意味著要確定系統的哪些方面最重要,並且能夠忽略底層細節,僅根據系統的最基本特徵來考慮系統。這是抽象的本質(找到一種思考複雜實體的簡單方法),這也是您在編寫更高層級註釋時必須要做的。一個好的更高層級註釋表達了一個或幾個簡單的想法,這些想法提供了一個概念框架,例如“追加到現有的 RPC”。使用該框架,可以很容易地看到特定的程式碼語句與總體目標之間的關係。
這是另一個程式碼示例,具有更高層級的註釋:
if (numProcessedPKHashes < readRpc[i].numHashes) {
// Some of the key hashes couldn't be looked up in
// this request (either because they aren't stored
// on the server, the server crashed, or there
// wasn't enough space in the response message).
// Mark the unprocessed hashes so they will get
// reassigned to new RPCs.
for (size_t p = removePos; p < insertPos; p++) {
if (activeRpcId[p] == i) {
if (numProcessedPKHashes > 0) {
numProcessedPKHashes--;
} else {
if (p < assignPos)
assignPos = p;
activeRpcId[p] = RPC_ID_NOT_ASSIGNED;
}
}
}
}
此註釋做了兩件事。第二句話提供了程式碼功能的抽象描述。而第一句話是不同的:它以高層級的方式解釋了為什麼要執行這些程式碼。類似於“我們是如何到達這裡的”的註釋對於幫助人們理解程式碼非常有用。例如,在為方法添加註釋時,描述最有可能在什麼情況下呼叫該方法(特別是僅在非正常場景下才需要呼叫該方法的時候)會非常有幫助。
13.5 介面文件
註釋最重要的作用之一就是定義抽象。回想一下第 4 章,抽象是實體的簡化檢視,它保留了基本資訊,但省略了可以安全忽略的細節。程式碼不適合描述抽象,它的層級太低,它包含了不應該在抽象中看到的實現細節。描述抽象的唯一方法是使用註釋。如果您想要呈現良好抽象的程式碼,則必須用註釋記錄這些抽象。
文件化抽象的第一步是將介面註釋與實現註釋分開。介面註釋提供了使用類或方法時需要知道的資訊,它們定義了抽象。實現註釋則描述了類或方法如何在內部工作以實現抽象。區分這兩種註釋很重要,這樣就不會對介面的使用者暴露實現細節。此外,這兩種形式最好有所不同。如果介面註釋也必須描述實現,則該類或方法是淺的。 這意味著編寫註釋的行為可以提供有關設計質量的線索,第 15 章將回到這個想法。
類的介面註釋提供了該類提供的抽象的高層級描述,例如:
/**
* This class implements a simple server-side interface to the HTTP
* protocol: by using this class, an application can receive HTTP
* requests, process them, and return responses. Each instance of
* this class corresponds to a particular socket used to receive
* requests. The current implementation is single-threaded and
* processes one request at a time.
*/
public class Http {...}
該註釋描述了類的整體功能,沒有任何實現細節,甚至沒有特定方法的細節。它還描述了該類的每個例項代表什麼。最後,註釋描述了該類的限制(它不支援從多個執行緒的併發訪問),這對於考慮是否使用它的開發人員可能很重要。
方法的介面註釋既包括用於抽象的高層級資訊,又包括用於精確度的低層級細節:
- 註釋通常以一兩個句子開頭,描述呼叫者能感知到的方法的行為。這是更高層級的抽象。
- 註釋必須描述每個引數和返回值(如果有)。這些註釋必須非常精確,並且必須描述對引數值的任何約束以及引數之間的依賴關係。
- 如果該方法有任何副作用,則必須在介面註釋中記錄這些副作用。副作用是該方法對系統的未來行為的影響,但又不是結果的一部分。例如,如果該方法將一個值新增到內部資料結構中,可以透過將來的方法呼叫來檢索該值,則這是副作用。寫入檔案系統也是一種副作用。
- 方法的介面註釋必須描述該方法可能產生的任何異常。
- 如果有任何在呼叫方法之前必須滿足的前提條件,則必須對其進行描述(也許必須先呼叫其他方法;對於二分查詢方法,被查詢的列表必須是已排序的)。儘量減少前提條件是一個好主意,但是任何留下來的都必須記錄在案。
這是一個從 Buffer
物件複製資料的方法的介面註釋:
/**
* Copy a range of bytes from a buffer to an external location.
*
* \param offset
* Index within the buffer of the first byte to copy.
* \param length
* Number of bytes to copy.
* \param dest
* Where to copy the bytes: must have room for at least
* length bytes.
*
* \return
* The return value is the actual number of bytes copied,
* which may be less than length if the requested range of
* bytes extends past the end of the buffer. 0 is returned
* if there is no overlap between the requested range and
* the actual buffer.
*/
uint32_t
Buffer::copy(uint32_t offset, uint32_t length, void* dest)
...
此註釋的語法(例如 \return
)遵循 Doxygen 的約定,該程式從 C / C++ 程式碼中提取註釋並將其編譯為 Web 頁面。註釋的目的是提供開發人員呼叫該方法所需的所有資訊,包括特殊情況的處理方式(請注意此方法是如何遵循第 10 章的建議並透過定義來規避與範圍指定相關的任何錯誤的)。開發人員不必為了呼叫它而閱讀方法的主體,並且介面註釋也沒有提供關於如何實現該方法的任何資訊,比如它是如何掃描其內部資料結構以查詢所需的資料。
接下來是一個更全面的示例,讓我們考慮一個稱為 IndexLookup
的類,該類是分散式儲存系統的一部分。儲存系統擁有一個表集合,每個表包含許多物件。另外,每個表可以具有一個或多個索引;每個索引都基於物件的特定欄位提供對錶中物件的高效訪問。例如,一個索引可以用於根據物件的名稱欄位查詢物件,而另一個索引可以用於根據物件的年齡欄位查詢物件。使用這些索引,應用程式可以快速提取具有特定名稱或者具有給定範圍內的年齡的所有物件。
IndexLookup
類為執行索引查詢提供了一個方便的介面。這是一個如何在應用程式中使用它的示例:
query = new IndexLookup(table, index, key1, key2);
while (true) {
object = query.getNext();
if (object == NULL) {
break;
}
... process object ...
}
應用程式首先構造一個 IndexLookup
型別的物件,並提供用於選擇表、索引和索引範圍的引數(例如,如果索引基於年齡欄位,則 key1
和 key2
可以指定為 21 和 65 選擇年齡介於這些值之間的所有物件)。然後,應用程式重複呼叫 getNext
方法。每次呼叫都返回一個位於所需範圍內的物件。一旦返回所有匹配的物件,getNext
將返回 NULL
。因為儲存系統是分散式的,所以此類的實現有些複雜。表中的物件可以分佈在多個伺服器上,每個索引也可以分佈在一組不同的伺服器上。IndexLookup
類中的程式碼必須首先與所有相關的索引伺服器通訊,以收集指定範圍內物件的資訊,然後必須與實際儲存物件的伺服器通訊,以檢索它們的值。
現在,讓我們考慮該類的介面註釋中需要包含哪些資訊。對於下面給出的每條資訊,問問自己,開發人員是否需要知道該資訊才能使用該類(我對這些問題的回答在本章的結尾):
IndexLookup
類傳送給索引伺服器和物件伺服器的訊息格式。- 用於確定特定物件是否在所需範圍內的比較功能(比較是使用整數、浮點數還是字串來完成的?)。
- 用於在伺服器上儲存索引的資料結構。
IndexLookup
是否同時向多個伺服器發出多個請求。- 處理伺服器崩潰的機制。
這是 IndexLookup
類的介面註釋的原始版本;摘錄還包括了類定義裡的幾行內容,將在註釋中被引用到:
/*
* This class implements the client side framework for index range
* lookups. It manages a single LookupIndexKeys RPC and multiple
* IndexedRead RPCs. Client side just includes "IndexLookup.h" in
* its header to use IndexLookup class. Several parameters can be set
* in the config below:
* - The number of concurrent indexedRead RPCs
* - The max number of PKHashes a indexedRead RPC can hold at a time
* - The size of the active PKHashes
*
* To use IndexLookup, the client creates an object of this class by
* providing all necessary information. After construction of
* IndexLookup, client can call getNext() function to move to next
* available object. If getNext() returns NULL, it means we reached
* the last object. Client can use getKey, getKeyLength, getValue,
* and getValueLength to get object data of current object.
*/
class IndexLookup {
...
private:
/// Max number of concurrent indexedRead RPCs
static const uint8_t NUM_READ_RPC = 10;
/// Max number of PKHashes that can be sent in one
/// indexedRead RPC
static const uint32_t MAX_PKHASHES_PERRPC = 256;
/// Max number of PKHashes that activeHashes can
/// hold at once.
static const size_t MAX_NUM_PK = (1 << LG_BUFFER_SIZE);
}
在進一步閱讀之前,看看你是否能找出這個註釋的問題。這些是我發現的問題:
- 第一段的大部分與實現有關,而不是介面。舉一個例子,使用者不需要知道用於與伺服器通訊的特定遠端過程呼叫的名稱。在第一段的後半部分中提到的配置引數都是私有變數,它們僅與類的維護者相關,而與類的使用者無關。所有這些實現資訊都應從註釋中省略。
- 該註釋還包括一些顯而易見的事情。例如,不需要告訴使用者包括
IndexLookup.h
:任何編寫 C++ 程式碼的人都可以猜測這是必要的。另外,“透過提供所有必要的資訊(by providing all necessary information)”實際上什麼也沒說,因此也可以省略。
一個更簡短的註釋對這個類就足夠了(並且更可取):
/*
* This class is used by client applications to make range queries
* using indexes. Each instance represents a single range query.
*
* To start a range query, a client creates an instance of this
* class. The client can then call getNext() to retrieve the objects
* in the desired range. For each object returned by getNext(), the
* caller can invoke getKey(), getKeyLength(), getValue(), and
* getValueLength() to get information about that object.
*/
此註釋的最後一段不是嚴格必需的,因為它主要是對幾個方法的註釋的重複。但是,在類文件中提供示例來說明其方法如何協同工作可能會有所幫助,特別是對於使用模式不明顯的深類尤其如此。注意,新的註釋未提及 getNext
的 NULL
返回值。此註釋無意記錄每個方法的每個細節;它只是提供高層級的資訊,以幫助讀者瞭解這些方法如何協同工作以及何時可以呼叫每個方法。讀者可以參考各個方法的介面註釋來了解更多細節。此註釋也沒有提到伺服器崩潰;這是因為伺服器崩潰對於該類的使用者是不可見的(系統會自動從中恢復)。
危險訊號:實現文件汙染了介面
當介面文件(例如方法的文件)記錄了使用過程中不需要知道的詳細實現資訊時,就會出現此危險訊號。
現在考慮以下程式碼,該程式碼顯示了 IndexLookup
類中 isReady
方法的文件的第一版:
/**
* Check if the next object is RESULT_READY. This function is
* implemented in a DCFT module, each execution of isReady() tries
* to make small progress, and getNext() invokes isReady() in a
* while loop, until isReady() returns true.
*
* isReady() is implemented in a rule-based approach. We check
* different rules by following a particular order, and perform
* certain actions if some rule is satisfied.
*
* \return
* True means the next Object is available. Otherwise, return
* false.
*/
bool IndexLookup::isReady() { ... }
同樣的問題,本文件中的大多數內容,例如對 DCFT
的引用以及整個第二段,都與實現有關,因此不應該放在這裡,這是介面註釋中最常見的錯誤之一。某些實現文件很有用,但應放在方法內部,應將其與介面文件明確分開。此外,文件的第一句話是含糊的(RESULT_READY
是什麼意思?),並且缺少一些重要資訊。最後,無需在此處描述 getNext
的實現。這是該註釋的更好版本:
/*
* Indicates whether an indexed read has made enough progress for
* getNext to return immediately without blocking. In addition, this
* method does most of the real work for indexed reads, so it must
* be invoked (either directly, or indirectly by calling getNext) in
* order for the indexed read to make progress.
*
* \return
* True means that the next invocation of getNext will not block
* (at least one object is available to return, or the end of the
* lookup has been reached); false means getNext may block.
*/
此註釋版本提供了更精確的關於“就緒(ready)”的資訊,還提供了一個重要資訊:如果要繼續進行索引檢索,則最終必須呼叫此方法。
13.6 實現註釋:做什麼以及為什麼這麼做,而不是如何做
實現註釋是出現在方法內部的註釋,用來幫助讀者瞭解它們在內部的工作方式。大多數方法是如此簡短,簡單,以至於它們不需要任何實現註釋:有了程式碼和介面註釋,就很容易弄清楚方法的工作原理。
實現註釋的主要目的是幫助讀者理解程式碼在做什麼(而不是程式碼如何工作)。一旦讀者知道了程式碼要做什麼,通常就很容易理解程式碼的工作原理。對於簡短的方法,程式碼只做一件事,既然已經在其介面註釋中進行了描述,就不需要實現註釋了。較長的方法具有多個程式碼塊,這些程式碼塊作為方法的整體任務的一部分執行不同的操作。在每個主要塊之前添加註釋,以提供對該塊的作用的高層級(更抽象)描述。這是一個例子:
// Phase 1: Scan active RPCs to see if any have completed.
這樣的註釋可以幫助讀者在程式碼中找到他們關心的部分。對於迴圈語句,在迴圈前加一個註釋來描述每次迭代中發生的事情是有幫助的:
// Each iteration of the following loop extracts one request from
// the request message, increments the corresponding object, and
// appends a response to the response message.
請注意此註釋如何更抽象和直觀地描述迴圈。它沒有詳細介紹如何從請求訊息中提取請求或物件如何遞增。僅對於更長或更複雜的迴圈才需要迴圈註釋,在這種情況下,迴圈的作用可能並不明顯。許多迴圈足夠短且簡單,以至於其行為已經很明顯。
除了描述程式碼在做什麼之外,實現註釋還有助於解釋為什麼這麼做。如果程式碼中有些複雜的地方很難直接從程式碼中看出來,則應將它們記錄下來。例如,如果一個缺陷修復需要新增目的不是很明顯的程式碼,請添加註釋以說明為什麼需要該程式碼。對於缺陷修復,如果已經有缺陷報告很好地描述了這個問題,該註釋可以引用缺陷跟蹤資料庫中的問題編號,而不是重複其所有詳細資訊(比如“修復 RAM-436,與 Linux 2.4.x 中的裝置驅動程式崩潰有關。)。開發人員可以在缺陷資料庫中查詢更多詳細資訊(這是一個避免註釋重複的示例,這將在第 16 章中進行討論)。
對於更長的方法,為一些最重要的區域性變數寫註釋會有幫助。但是,如果大多數區域性變數都有比較好的名稱,也不需要文件。如果變數的所有用法在幾行程式碼之內都是可見的,則通常無需註釋即可輕鬆理解變數的用途。在這種情況下,可以讓讀者閱讀程式碼來弄清楚變數的含義。但是,如果在大量程式碼中使用了該變數,則應考慮添加註釋以描述該變數。在對變數進行文件化時,應關注變量表示的內容,而不是程式碼中如何對其進行操作。
13.7 跨模組設計決策
在理想環境中,每個重要的設計決策都將封裝在一個類中。不幸的是,真實系統中的設計決策不可避免地最終會影響到多個類。例如,網路協議的設計將影響傳送方和接收方,並且它們可以在不同的地方實現。跨模組決策通常是複雜而微妙的,並且容易導致程式碼缺陷,因此,為它們提供良好的文件至關重要。
跨模組文件的最大挑戰是找到一個放置它的位置,以便開發人員能自然地發現它。有時,會有一個明顯的中心位置用於放置此類文件。例如,RAMCloud 儲存系統定義了一個狀態值,每個請求均返回該狀態值以指示成功或失敗。為新的錯誤狀況新增狀態需要修改許多不同的檔案(一個檔案將狀態值對映到異常,另一個檔案為每個狀態提供人類可讀的訊息,等等)。幸運的是,在新增新的狀態值時,有一個顯而易見的地方是開發人員必須去的,那就是狀態列舉的宣告。我們利用了這一點,在該列舉中添加了註釋,以標識所有其他必須同步修改的地方。
typedef enum Status {
STATUS_OK = 0,
STATUS_UNKNOWN_TABLET = 1,
STATUS_WRONG_VERSION = 2,
...
STATUS_INDEX_DOESNT_EXIST = 29,
STATUS_INVALID_PARAMETER = 30,
STATUS_MAX_VALUE = 30,
// Note: if you add a new status value you must make the following
// additional updates:
// (1) Modify STATUS_MAX_VALUE to have a value equal to the
// largest defined status value, and make sure its definition
// is the last one in the list. STATUS_MAX_VALUE is used
// primarily for testing.
// (2) Add new entries in the tables "messages" and "symbols" in
// Status.cc.
// (3) Add a new exception class to ClientException.h
// (4) Add a new "case" to ClientException::throwException to map
// from the status value to a status-specific ClientException
// subclass.
// (5) In the Java bindings, add a static class for the exception
// to ClientException.java
// (6) Add a case for the status of the exception to throw the
// exception in ClientException.java
// (7) Add the exception to the Status enum in Status.java, making
// sure the status is in the correct position corresponding to
// its status code.
}
新的狀態值將新增到現有列表的末尾,因此註釋也放在了最有可能被看到的末尾。
不幸的是,在許多情況下,並沒有一個明顯的中心位置用來放置跨模組文件。RAMCloud 儲存系統中的一個例子是處理殭屍伺服器的程式碼,殭屍伺服器是系統認為已經崩潰但實際上仍在執行的伺服器。使殭屍伺服器無效需要幾個不同模組中的程式碼,這些程式碼都相互依賴。沒有一段程式碼是明顯的放置文件的中心位置。一種可能性是在每個依賴文件的位置複製部分的文件。然而,這是不合適的,並且隨著系統的演進,很難使這樣的文件保持最新。或者,文件可以位於需要它的位置之一,但是在這種情況下,開發人員不太可能看到文件或者知道在哪裡查詢它。
我最近一直在嘗試一種方法,該方法將跨模組問題記錄在一個名為 designNotes
的中央檔案中。該檔案分為幾個清晰標識的部分,每個部分針對一個主題。例如,以下是該檔案的摘錄:
...
Zombies
-------
A zombie is a server that is considered dead by the rest of the
cluster; any data stored on the server has been recovered and will
be managed by other servers. However, if a zombie is not actually
dead (e.g., it was just disconnected from the other servers for a
while) two forms of inconsistency can arise:
* A zombie server must not serve read requests once replacement
servers have taken over; otherwise it may return stale data that
does not reflect writes accepted by the replacement servers.
* The zombie server must not accept write requests once replacement
servers have begun replaying its log during recovery; if it does,
these writes may be lost (the new values may not be stored on the
replacement servers and thus will not be returned by reads).
RAMCloud uses two techniques to neutralize zombies. First,
...
然後,在與這些問題之一相關的任何程式碼段中,都有一條簡短的註釋引用了 designNotes
檔案:
// See "Zombies" in designNotes.
使用這種方法,文件只有一個副本,因此開發人員在需要時可以相對容易地找到它。但是,這樣做的缺點是,文件離依賴它的任何程式碼段都不近,因此隨著系統的演進,可能難以保持最新。
13.8 結論
註釋的目的是確保系統的結構和行為對讀者來說是易理解的,因此他們可以快速找到所需的資訊,並有信心對系統進行修改,確信這些修改能夠正常工作。某些資訊可以透過程式碼本身直觀地展現給讀者,但是還有大量資訊無法從程式碼中輕易推斷出來。註釋將補充這些資訊。
當遵循註釋應描述程式碼中難以理解的內容的規則時,是否“難以理解”是從第一次閱讀您程式碼的人(而不是您自己)的角度出發的。在編寫註釋時,請嘗試使自己進入讀者的心態,並問自己他或她需要知道哪些關鍵事項。如果您的程式碼正在接受稽核,並且稽核者告訴您某些內容難以理解,請不要與他們爭論。如果讀者認為它難以理解,那麼它就是難以理解的。與其爭論,不如嘗試瞭解他們發現的令人困惑的地方,並看看是否可以透過更好的註釋或更好的程式碼來澄清它們。
13.9 回答第 13.5 節中的問題
開發人員是否需要了解以下每條資訊才能使用 IndexLookup
類?
IndexLookup
類傳送給索引伺服器和物件伺服器的訊息格式。否:這是應該隱藏在類中的實現細節。- 用於確定特定物件是否在所需範圍內的比較功能(比較是使用整數、浮點數還是字串來完成的?)。是:該類的使用者需要了解此資訊。
- 用於在伺服器上儲存索引的資料結構。否:此資訊應封裝在伺服器上;甚至
IndexLookup
的實現都不需要知道這一點。 IndexLookup
是否同時向多個伺服器發出多個請求。有可能:如果IndexLookup
使用特殊技術來提高效能,則文件應提供相關的一些高層級資訊,因為使用者可能會在意效能。- 處理伺服器崩潰的機制。否:RAMCloud 可從伺服器崩潰中自動恢復,因此崩潰對於應用程式級軟體不可見;因此,在
IndexLookup
的介面文件中無需提及崩潰。如果崩潰反映到應用程式中,則介面文件將需要描述它們會如何表現出來(而不是崩潰恢復如何工作的詳細資訊)。