第 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!同樣,僅憑宣告就能寫出這些註解,無需瞭解變數的方法,因此它們沒有價值。
譯者注(原文疑點):原文此處為
methods of variables,即“變數的方法”。結合本節討論的方法和變數註解,疑應為methods or variables,即“方法或變數”。正文保留原文表述。
同時,註解中缺少一些重要資訊:例如,什麼是規範化的資源名稱(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;
這段註解補充了宣告本身沒有說明的資訊:單位是畫素,填充位於每行兩側。考慮到讀者可能不熟悉“填充”這個術語,註解沒有直接使用它,而是解釋了它的含義。
譯者圖解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)”的含義,還補充了一條重要資訊:要讓索引檢索繼續推進,最終必須呼叫此方法。
譯者圖解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的介面文件無需提及。如果崩潰會暴露給應用程式,介面文件就需要說明應用程式會看到什麼,而不是詳細介紹崩潰恢復的工作方式。