第 14 章 選取名稱
為變數、方法和其他實體選擇名稱,是軟體設計中最容易被低估的工作之一。好的名稱本身就是一種文件:它們使程式碼更易於理解,減少了對其他文件的需求,也讓錯誤更容易被發現。相反,名稱選擇不當會增加程式碼的複雜性,產生歧義和誤解,進而可能導致程式碼缺陷。命名選擇體現了複雜性逐步累積這一原則。為某個變數選擇一個平庸而非最佳的名稱,可能不會對系統的整體複雜性產生太大影響。但軟體系統有成千上萬個變數,為它們都選好名稱,就會顯著影響系統的複雜性和可管理性。
本章概覽怎樣為變數和方法選一個好名稱?
14.1 範例:壞名稱會導致程式碼缺陷
有時,即使只有一個變數命名不當,也會造成嚴重後果。我修復過的最棘手的程式碼缺陷,就是由命名不當造成的。在 1980 年代末和 1990 年代初,我和研究生們建立了一個名為 Sprite 的分散式作業系統。後來,我們注意到檔案偶爾會丟失資料:即使使用者沒有修改檔案,其中一個數據塊也會突然全部變成零。這個問題很少發生,因此很難追查。一些研究生嘗試查詢缺陷,卻一直沒有進展,最終放棄了。但在我看來,任何未解決的缺陷都是對我個人難以容忍的冒犯,因此我決定繼續追查。
花了六個月,我終於找到並修復了這個缺陷。問題實際上很簡單,和大多數缺陷一樣,找出原因後就不難理解了。檔案系統程式碼將變數名 block 用於兩種不同的用途:有時,block 表示磁碟上的物理塊號;有時,block 表示檔案中的邏輯塊號。不幸的是,某處程式碼把一個儲存邏輯塊號的變數誤用在了需要物理塊號的地方,結果將磁碟上一個無關的塊清零了。
追查這個缺陷時,包括我在內的幾個人都讀過有問題的程式碼,卻始終沒有發現問題。看到變數 block 被用作物理塊號,我們就本能地以為它儲存的確實是物理塊號。經過長時間新增診斷程式碼進行追蹤,確定資料損壞必然發生在某條語句後,我才擺脫名稱帶來的思維誤導,檢查這個值究竟來自哪裡。如果對不同型別的塊使用不同的變數名(例如 fileBlock 和 diskBlock),這個缺陷很可能就不會發生,因為程式設計師會知道哪些地方不能使用 fileBlock。更好的做法是為這兩種塊號定義不同的型別,使它們不能被混用。
不幸的是,大多數開發人員不會花太多時間思考名稱,往往採用最先想到的那個,只要含義與所指事物大致相符就行。例如,“塊”既可以指磁碟上的物理塊,也可以指檔案中的邏輯塊,看起來並不是一個很糟糕的名稱。即便如此,它還是讓我們花了大量時間追查一個微妙的缺陷。因此,不要滿足於“大致相符”的名稱,要多花一點時間,選出準確、明確而直觀的名稱。這點投入很快就能得到回報,隨著經驗積累,你也會學會更快地選好名稱。
譯者圖解同一個數字,兩個地址域
14.2 創造畫面
選擇名稱時,目標是讓讀者對所指事物的本質形成清晰的印象。好的名稱能傳達許多資訊,說明這個實體是什麼,以及同樣重要的,它不是什麼。考慮一個名稱時,請問自己:“如果讀者只看到這個名稱,沒有看到宣告、文件或任何使用它的程式碼,能多準確地猜出它指的是什麼?換一個名稱,能否讓這個印象更清晰?”當然,名稱能承載的資訊有限,單字超過兩三個就會顯得累贅。因此,難點在於只用幾個詞,就表達出實體最重要的特徵。
名稱是一種抽象形式:名稱提供了一種簡化的方式來思考更複雜的底層實體。與其他抽象形式一樣,最好的名稱會突出底層實體最重要的東西,而忽略那些次要的細節。
14.3 名稱應該是精確的
好的名稱具有兩個屬性:精確性和一致性。先看精確性。名稱最常見的問題是過於籠統或含糊,讓讀者難以判斷它指的是什麼,甚至產生與實際情況不符的理解,如前面的 block 缺陷所示。考慮以下方法宣告:
/**
* Returns the total number of indexlets this object is managing.
*/
int IndexletManager::getCount() {...}
術語 count 太籠統了:對什麼計數?如果有人看到此方法的呼叫,除非他們閱讀了它的文件,否則他們不太可能知道它的作用。像 numActiveIndexlets 這樣的更精確的名稱會更好:很多讀者可能無需查看其文件就能猜測該方法回傳的內容。
危險訊號:模糊的名稱
如果變數或方法的名稱過於寬泛,可以指代許多不同的事物,就無法向開發人員傳達足夠的資訊,所指的實體也很可能被誤用。
以下是其他一些來自學生專案的名稱不夠精確的範例:
一個圖形介面文字編輯器專案使用
x和y表示字元在檔案中的位置。這兩個名稱太籠統,可以有許多含義,例如字元在螢幕上的座標(以畫素為單位)。只看到名稱x的讀者,不太可能想到它表示字元在一行文字中的位置。如果使用charIndex和lineIndex這樣的名稱,體現程式碼所實作的具體抽象,程式碼就會清楚得多。另一個編輯器專案包含以下程式碼:
// Blink state: true when cursor visible. private boolean blinkStatus = true;blinkStatus這個名稱沒有傳達足夠的資訊。對於布林值,status太含糊,無法說明真和假各代表什麼。blink同樣含糊,因為它沒有說明究竟是什麼在閃爍。下面是更好的選擇:// Controls cursor blinking: true means the cursor is visible, // false means the cursor is not displayed. private boolean cursorVisible = true;名稱
cursorVisible傳達了更多資訊,例如,讀者可以據此猜出真值的含義(通常,布林變數應採用謂詞式命名)。名稱中不再包含blink,所以讀者要了解為什麼游標有時不可見,就必須查閱文件;這是相對次要的資訊。一個實作共識協議的專案包含以下程式碼:
// Value representing that the server has not voted (yet) for // anyone for the current election term. private static final String VOTED_FOR_SENTINEL_VALUE = "null";此值的名稱表示它是特殊的,但沒有說明特殊含義是什麼。使用更具體的名稱(例如
NOT_YET_VOTED)會更好。一個沒有回傳值的方法使用了名為
result的變數。這個名稱有多個問題。首先,它讓人誤以為變數將作為方法的回傳值。其次,除了暗示它儲存某種計算結果,名稱沒有說明變數裡究竟是什麼。名稱應體現result實際代表的內容,例如mergedLine或totalChars。如果方法確實有回傳值,使用result就是合理的。雖然名稱仍然有些籠統,但讀者可以查閱方法文件瞭解其含義,而且這個名稱能讓讀者知道哪個值最終會被回傳。Linux 核心包含兩個描述網路套接字結構的結構:
struct socket和struct sock。struct sock包含一個struct socket作為其第一個元素,它實際上是struct socket的子類。這些名稱如此相似,以至於很難記住哪個是哪個。選擇易於區分的並闡明了這兩個型別之間的關係的名稱會更好,例如struct sock_base和struct inet_sock。
像所有規則一樣,有關選擇精確名稱的規則也有一些例外。例如,如果迴圈僅包含幾行程式碼,也可以將通用名稱(如 i 和 j)用作迴圈迭代變數。如果你可以直接看到一個變數的完整使用範圍,那麼該變數的含義可能在程式碼中就很明顯了,因此你不需要長名稱。例如以下程式碼:
for (i = 0; i < numLines; i++) {
...
}
這段程式碼清楚地表明,i 用來遍歷某個實體中的每一行。如果迴圈太長,無法一次看全,或者很難從程式碼中判斷迭代變數的含義,就應該使用更具描述性的名稱。
名稱也可能過於具體,例如下面這個刪除一段文字的方法宣告:
void delete(Range selection) {...}
selection 這個參數名過於具體,暗示要刪除的是使用者介面中當前選中的文字。但這個方法可以刪除任意範圍的文字,無論是否選中。因此,參數名應更通用,例如 range。
如果很難為某個變數想出精確、直觀又不太長的名稱,這就是一個危險訊號:變數的定義或用途可能不夠清楚。此時應考慮其他拆分方式。例如,也許你正試圖用一個變量表示多個事物;若拆成多個變數,每個變數的定義可能會更簡單。為變數命名的過程能幫助發現設計中的薄弱之處,從而改進設計。
危險訊號:難以選取名稱
如果很難用一個簡單的名稱清楚描述某個變數或方法,就說明其設計可能不夠整潔。
譯者圖解名稱的範圍要與概念的範圍吻合
14.4 命名要確保一致性
好名稱的第二個重要屬性是一致性。程式中某些變數的用途會反覆出現,例如檔案系統經常要操作塊號。對於每種常見用途,應選定一個名稱,並在各處一致使用。例如,檔案系統可以始終用 fileBlock 儲存檔案中的塊索引。命名一致與複用同一個類一樣,都能減輕認知負荷:讀者在一處瞭解名稱的含義後,在其他上下文中遇到它時,就能利用已有知識立即作出判斷。
一致性有三個要求:首先,對給定用途始終使用統一約定的名稱;其次,不把這個名稱用於其他用途;第三,該用途必須限定在足夠小的範圍內,讓所有采用該名稱的變數都具有相同的行為。本章開頭的檔案系統缺陷違反了第三項要求:檔案系統用 block 表示兩種行為不同的變數(檔案塊和磁碟塊),讓人誤判變數的含義,進而導致程式碼缺陷。
有時需要多個變量表示同一類事物。例如,複製檔案資料的方法需要兩個塊號,一個表示來源,另一個表示目標。這時,各變數仍應使用統一約定的名稱,但要新增字首加以區分,例如 srcFileBlock 和 dstFileBlock。
一致命名在迴圈中也很有幫助。如果用 i 和 j 這樣的名稱表示迴圈變數,就始終將 i 用於最外層迴圈,將 j 用於巢狀迴圈。這樣,讀者一看到名稱,就能立即對程式碼的行為作出可靠的判斷。
14.5 避免多餘的單字
名稱中的每個單字都應提供有用的資訊。無助於說明變數含義的詞只會增加混亂,例如讓更多程式碼行需要換行。一個常見的錯誤是新增籠統的名詞,如 field 或 object,fileObject 就是一個例子。其中的 Object 可能沒有提供有用資訊(還有不是物件的檔案嗎?),因此應當省略。
一些編碼風格會在名稱中包含型別資訊,例如 filePtr 表示一個指向檔案物件的指標變數。一個極端的例子是微軟 C 程式設計中使用的匈牙利命名法:每個變數名都有表示完整型別的字首。例如,arru8NumberList 表示由無符號 8 位整陣列成的陣列。雖然我過去也在變數名中包含型別資訊,但現在不再推薦這樣做。現代 IDE 可以方便地從變數名跳轉到宣告(IDE 甚至可以自動顯示型別),因此不必在名稱中重複這些資訊。
另一個例子是實例變數名重複了類名,例如 File 類中有一個名為 fileBlock 的實例變數。上下文已經表明該變數屬於 File 類,變數名中再包含類名並沒有提供額外資訊,直接命名為 block 即可(除非該類包含多種不同型別的塊)。
14.6 不同的觀點:Go 樣式指南
並非所有人都同意我對命名的看法。參與開發 Go 語言的一些人員認為,名稱應該非常簡短,通常只用一個字元。在一場關於 Go 語言命名的演示中,Andrew Gerrand 指出:“長名稱模糊了程式碼的作用。” [1] 他展示了下面這段使用單字母變數名的程式碼:
func RuneCount(b []byte) int {
i, n := 0, 0
for i < len(b) {
if b[i] < RuneSelf {
i++
} else {
_, size := DecodeRune(b[i:])
i += size
}
n++
}
return n
}
並認為它比以下使用更長名稱的版本更具可讀性:
func RuneCount(buffer []byte) int {
index, count := 0, 0
for index < len(buffer) {
if buffer[index] < RuneSelf {
index++
} else {
_, size := DecodeRune(buffer[index:])
index += size
}
count++
}
return count
}
就個人而言,我不覺得第二個版本更難讀。例如,與 n 相比,count 更能提示變數的含義。閱讀第一個版本時,我得通讀程式碼才能弄清 n 代表什麼,第二個版本就不需要。不過,如果整個系統始終用 n 表示計數,不用於任何其他含義,那麼其他開發人員很可能也能清楚理解這個短名稱。
Go 文化鼓勵在多個不同的事物上使用相同的短名稱:ch 用於字元或通道,d 用於資料、差異或距離,等等。對我來說,像這樣的模稜兩可的名稱很可能導致混亂和錯誤,就像在檔案塊的範例中一樣。
總的來說,可讀性應該由讀者而不是作者來判斷。如果你使用簡短的變數名,而讀者很容易理解,那就沒有問題。如果讀者開始抱怨程式碼含糊不清,你就應該考慮更長的名稱(在網路上搜索 go language short names 會發現一些這樣的抱怨)。同樣,如果讀者抱怨長變數名讓我的程式碼難以閱讀,我也會考慮使用更短的名稱。
Gerrand 的一條評論我很贊同:“名稱宣告與其使用之間的距離越大,名稱就應該越長。”前面關於迴圈變數 i 和 j 的討論,就是這條規則的例子。
14.7 結論
精心選取的名稱能讓程式碼更容易理解:讀者第一次遇到變數時,不必多想,就能正確猜到它的行為。選好名稱體現了第 3 章討論的投資思維:現在多花一點時間,將來處理程式碼就會更容易,引入程式碼缺陷的可能性也會更小。培養命名能力本身也是一項投資。剛決定不再滿足於平庸名稱時,你可能會覺得想出好名稱既耗時又令人沮喪。但隨著經驗積累,命名會越來越容易。最終,你幾乎不需要多花時間就能選出好名稱,獲得這些好處也就幾乎不必付出額外成本。
[1] https://talks.golang.org/2014/names.slide#1 ↩