1. 程式人生 > >軟體設計的哲學:第十三章 註釋應該描述程式碼中隱藏的內容

軟體設計的哲學:第十三章 註釋應該描述程式碼中隱藏的內容

目錄

  • 13.1 選擇約定
  • 13.2 不要重複程式碼
  • 13.3 低階註釋增加了精確性
  • 13.4 更高層次的註釋增強直覺
  • 13.5 介面文件
  • 13.6 建議:什麼和為什麼,而不是如何
  • 13.7 跨模組設計決策
  • 13.8 結論
  • 13.9 對第13.5節問題的回答

編寫註釋的原因是,在編寫程式碼時,程式語言中的語句無法捕獲開發人員頭腦中的所有重要資訊。註釋記錄了這些資訊,以便以後的開發人員能夠很容易地理解和修改程式碼。註釋的指導原則是註釋應該描述程式碼中不明顯的內容。

程式碼中有許多不明顯的地方。有時是不明顯的低階細節。例如,當一對指標描述一個範圍時,由指標給出的元素是在範圍內還是在範圍外並不明顯。有時不清楚為什麼需要程式碼,或者為什麼以特定的方式實現程式碼。有時開發人員會遵循一些規則,比如“總是在b之前呼叫a”。你可以通過檢視所有的程式碼來猜測規則,但這是痛苦的,而且容易出錯;註釋可以使規則變得明確和清晰。

註釋最重要的原因之一是抽象,它包含了很多程式碼中不明顯的資訊。 抽象的思想是提供一種簡單的方法來思考一些事情,但是程式碼是如此的詳細,以至於僅僅通過閱讀程式碼就很難看到抽象。註釋可以提供更簡單、更高階的檢視(“呼叫此方法後,網路流量將被限制為每秒最大頻寬位元組”)。即使通過讀取程式碼可以推斷出這些資訊,我們也不希望強迫模組的使用者這樣做:讀取程式碼非常耗時,並且強迫使用者考慮使用模組不需要的大量資訊。開發人員應該能夠理解模組提供的抽象,而不需要讀取除其外部可見宣告之外的任何程式碼。實現此目的的惟一方法是用註釋補充宣告。

本章討論了在註釋中需要描述哪些資訊,以及如何寫出好的註釋。正如您將看到的,好的註釋通常以與程式碼不同的細節級別解釋事情,在某些情況下,程式碼更詳細,而在其他情況下,程式碼更詳細(更抽象)。

13.1 選擇約定

編寫註釋的第一步是決定註釋的慣例,比如註釋的內容和格式。如果您使用的語言存在文件編譯工具,例如Javadoc (Java)、Doxygen (c++)或godoc (Go) ,請遵循工具的約定。這些約定都不是完美的,但是工具提供了足夠的好處來彌補這一點。如果您在一個沒有現有約定可遵循的環境中進行程式設計,請嘗試從其他類似的語言或專案中採用這些約定;這將使其他開發人員更容易理解和遵守您的約定。

約定有兩個目的。首先,它們確保一致性,這使得註釋更容易閱讀和理解。其次,它們有助於確保你確實寫了註釋。如果你不清楚你要註釋什麼,怎麼註釋,很容易最後什麼都不寫。

大多數註釋可分為以下幾類:

  • 介面:緊接在模組宣告之前的註釋塊,如類、資料結構、函式或方法。註釋描述了模組的介面。對於類,註釋描述了類提供的整體抽象。對於一個方法或函式,註釋描述了它的整體行為、引數和返回值(如果有的話)、它產生的任何副作用或異常,以及呼叫者在呼叫方法之前必須滿足的任何其他需求。
  • 資料結構成員:資料結構中欄位宣告旁邊的註釋,例如類的例項變數或靜態變數。
  • 實現註釋:方法或函式程式碼中的註釋,描述程式碼內部的工作方式。
  • 跨模組註釋:描述跨模組邊界的依賴項的註釋。

最重要的註釋是前兩類。每個類都應該有一個介面註釋,每個類變數都應該有一個註釋,每個方法都應該有一個介面註釋。有時,變數或方法的宣告非常明顯,以至於在註釋中新增任何有用的東西(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”。同樣,這些註釋可以只通過檢視宣告來編寫,而不需要理解變數的方法,因此,它們沒有價值。

危險訊號:註釋重複程式碼

如果註釋中的資訊在註釋旁邊的程式碼中已經很明顯,那麼註釋就沒有幫助。這方面的一個例子是,註釋使用與它所描述的事物名稱相同的單詞。

同時,註釋中還缺少一些重要的資訊:例如,什麼是“規範化的資源名稱”,以及getNormalizedResourceNames返回的陣列的元素是什麼?“沮喪”是什麼意思?填充的單位是什麼?每行的一邊是填充還是兩邊都是?在註釋中描述這些事情會很有幫助。

編寫好的註釋的第一步是在註釋中使用不同於被描述的實體名稱中的單詞。 為註釋選擇提供關於實體意義的附加資訊的單詞,而不是重複它的名字。例如,下面是對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;

該註釋提供了宣告本身不明顯的附加資訊,比如單位(畫素)和每行兩邊都有填充。這篇註釋沒有使用“padding”這個詞,而是解釋了padding是什麼,以防讀者不熟悉這個詞。

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;

二個宣告使用了一個更長的名稱,它傳遞了更多的資訊。它還將“寬度”改為“長度”,因為這個詞更容易讓人認為單位是字元而不是畫素。請注意,第二個註釋不僅記錄了每個條目的詳細資訊,還記錄瞭如果某個條目丟失了,它意味著什麼。

記錄變數時,考慮的是名詞,而不是動詞。換句話說,關注變數所表示的內容,而不是它是如何操作的。考慮一下下面的註釋:

/* 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;

有了這個文件,很容易推斷出變數在接收到心跳時必須設定為true,在重置選舉計時器時必須設定為false。

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;

      }

}

這個註釋太低階,太詳細了。一方面,它部分地重複程式碼:“如果有載入readRPC”只是重複了測試readRPC [i]。= =載入狀態。另一方面,註釋沒有解釋這段程式碼的總體目的,也沒有解釋它如何適合包含它的方法。因此,註釋並不能幫助讀者理解程式碼。

下面是一個更好的註釋:

// Try to append the current key hash onto an existing
// RPC to the desired server that hasn't been sent yet.

這條註釋沒有包含任何細節。相反,它在更高的層次上描述程式碼的整體功能。有了這些高階資訊,讀者幾乎可以解釋程式碼中發生的所有事情:迴圈必須遍歷所有現有的遠端過程呼叫(rpc);會話測試可能用於檢視某個特定RPC是否指向正確的伺服器;載入試驗表明,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 {...}

這個註釋描述了類的整體功能,沒有任何實現細節,甚至沒有特定方法的細節。它還描述了類的每個例項所代表的內容。最後,註釋描述了該類的侷限性(它不支援來自多個執行緒的併發訪問),這對於正在考慮是否使用該類的開發人員可能很重要。

方法的介面註釋包括用於抽象的高階資訊和用於精確的低階細節:

  • 註釋通常以一兩句話開頭,描述呼叫者所感知到的方法行為,這是更高層次的抽象。
  • 註釋必須描述每個引數和返回值(如果有的話)。這些註釋必須非常精確,並且必須描述關於引數值的任何約束以及引數之間的依賴關係。
  • 如果該方法有任何副作用,則必須在介面註釋中記錄這些副作用。副作用是影響系統未來行為的任何方法的結果,但不是結果的一部分。例如,如果該方法向內部資料結構新增一個值,該值可以通過將來的方法呼叫來檢索,這是一個副作用;寫入檔案系統也是一個副作用。
  • 方法的介面註釋必須描述從該方法產生的任何異常。
  • 如果在呼叫方法之前必須滿足任何先決條件,則必須對這些條件進行描述(可能必須先呼叫其他方法,對於二叉搜尋方法,要搜尋的列表必須排序)。將先決條件最小化是個好主意,但是任何保留的條件都必須記錄下來。

下面是一個方法的介面註釋,該方法複製緩衝區物件中的資料:

/**

 * 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的慣例,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類中的程式碼必須首先與所有相關的索引伺服器通訊,以收集關於範圍內物件的資訊,然後必須與實際儲存物件的伺服器通訊,以便檢索它們的值。

現在,讓我們考慮需要在這個類的介面註釋中包含哪些資訊。對於下面給出的每一條資訊,問問自己開發人員是否需要知道這些資訊才能使用這個類(我的答案在本章的最後):

  1. IndexLookup類傳送給儲存索引和物件的伺服器的訊息格式。
  2. 用於確定特定物件是否在期望範圍內的比較函式(是否使用整數、浮點數或字串進行比較?)
  3. 用於在伺服器上儲存索引的資料結構。
  4. IndexLookup是否同時向不同的伺服器發出多個請求。
  5. 處理伺服器崩潰的機制。

這是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++程式碼的人都能猜到這是必要的。此外,文字“通過提供所有必要的資訊”沒有說什麼,所以可以省略。

這個類的簡短註釋就足夠了(更可取):

* 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.

注意這個註釋是如何在更抽象和直觀的層次上描述迴圈的;它不涉及如何從請求訊息中提取請求或如何增加物件的任何細節。迴圈註釋只在較長或更復雜的迴圈中需要,在這種情況下,可能不清楚迴圈在做什麼;許多迴圈都很短很簡單,它們的行為已經很明顯了。

了描述程式碼在做什麼之外,實現註釋還有助於解釋其原因。如果程式碼中有一些難以處理的方面,通過閱讀不會很明顯,那麼您應該將它們記錄下來。例如,如果一個bug修復需要新增一些目的不太明顯的程式碼,那麼可以新增一條註釋,說明為什麼需要這些程式碼。對於編寫良好的bug報告描述問題的bug修復,註釋可以參考bug跟蹤資料庫中的問題,而不是重複它的所有細節(“修復RAM-436,與Linux 2.4.x中的裝置驅動程式崩潰有關”)。開發人員可以在bug資料庫中查詢更多細節(這是避免註釋重複的一個例子,將在第16章中討論)。

對於較長的方法,為一些最重要的區域性變數寫註釋是有幫助的。但是,大多數區域性變數如果名稱良好,則不需要文件。如果一個變數的所有用法都可以在彼此的幾行程式碼中看到,那麼無需註釋就可以很容易地理解這個變數的用途。在這種情況下,可以讓讀者閱讀程式碼來理解變數的含義。但是,如果變數在大範圍的程式碼中使用,那麼應該考慮添加註釋來描述變數。在記錄變數時,要關注變量表示什麼,而不是如何在程式碼中操作變數。

13.7 跨模組設計決策

在一個完美的世界中,每個重要的設計決策都被封裝在一個類中。不幸的是,實際系統不可避免地以影響多個類的設計決策而告終。例如,網路協議的設計將同時影響傳送方和接收方,這些可能在不同的地方實現。跨模組決策通常是複雜而微妙的,它們會導致許多bug,因此為它們編寫良好的文件是至關重要的。

跨模組文件的最大挑戰是找到一個地方將其放置在開發人員自然會發現的地方。有時,放置這樣的文件有一個明顯的中心位置。例如,RAMCloud儲存系統定義了一個狀態值,該值由每個請求返回,以指示成功或失敗。為新的錯誤條件新增狀態需要修改許多不同的檔案(一個檔案將狀態值對映到異常,另一個檔案為每個狀態提供人類可讀的訊息,等等)。幸運的是,當新增一個新的狀態值時,有一個地方是開發人員必須去的,那就是狀態enum的宣告。我們利用了這一點,在enum中添加了註釋,以確定所有其他必須修改的地方:

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儲存系統中的一個例子是處理殭屍伺服器的程式碼,殭屍伺服器是系統認為已經崩潰但實際上仍在執行的伺服器。中和zombie server需要幾個不同模組中的程式碼,這些程式碼都相互依賴。沒有一段程式碼明顯是放置文件的中心位置。一種可能性是在每個依賴文件的位置複製文件的部分。然而,這是令人尷尬的,並且隨著系統的發展,很難使這樣的文件保持最新。或者,文件可以位於需要它的位置之一,但是在這種情況下,開發人員不太可能看到文件或者知道在哪裡查詢它。

我最近試驗了一種方法,將跨模組問題記錄在一個名為designNotes的中心檔案中。ile被劃分為有明確標記的部分,每個主要主題對應一個部分。例如,以下是該檔案的摘錄:

...

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類,開發人員是否需要了解以下每個資訊片段:

  1. IndexLookup類傳送給儲存索引和物件的伺服器的訊息格式。這是一個實現細節,應該隱藏在類中。
  2. 用於確定特定物件是否在期望範圍內的比較函式(是否使用整數、浮點數或字串進行比較?)是的:類的使用者需要知道這些資訊。
  3. 用於在伺服器上儲存索引的資料結構。不:這些資訊應該封裝在伺服器上;甚至IndexLookup的實現也不需要知道這一點。
  4. IndexLookup是否同時向不同的伺服器發出多個請求。可::如果IndexLookup使用特殊技術來提高效能,那麼文件應該提供一些關於這方面的高階資訊,因為使用者可能關心效能。
  5. 處理伺服器崩潰的機制。RAMCloud從伺服器崩潰中自動恢復,因此應用程式級軟體看不到崩潰;因此,在IndexLookup的介面文件中不需要提到崩潰。如果崩潰反映到應用程式中,那麼介面文件需要描述它們如何顯示自己(而不是崩潰恢復工作的細節)。