編寫可讀性程式碼的藝術
原文地址:
http://www.cnblogs.com/greeenplace/articles/4667830.html
PDF檔案下載地址:
http://download.csdn.net/detail/aradin/8440247
譯者序
在做IT的公司裡,尤其是軟體開發部門,一般不會要求工程師衣著正式。在我工作過的一些環境相對寬鬆的公司裡,很多程式設計師的衣著連得體都算不上(搞笑的T恤、短褲、拖鞋或者乾脆不穿鞋)。我想,我本人也在這個行列裡面。雖然我現在改行做軟體開發方面的諮詢工作,但還是改不了這副德性。衣著體面的其中一個積極方面是它體現了對周圍人的尊重,以及對所從事工作的尊重。比如,那些研究市場的人要表現出對客戶的尊重。而大多數程式設計師基本上每天主要的工作就是和其他程式設計師打交道。那麼這說明程式設計師之間就不用互相尊重嗎?而且也不用尊重自己的工作嗎?
程式設計師之間的互相尊重體現在他所寫的程式碼中。他們對工作的尊重也體現在那裡。
在《Clean Code》一書中Bob大叔認為在程式碼閱讀過程中人們說髒話的頻率是衡量程式碼質量的唯一標準。這也是同樣的道理。
這樣,程式碼最重要的讀者就不再是編譯器、直譯器或者電腦了,而是人。寫出的程式碼能
讓人快速理解、輕鬆維護、容易擴充套件的程式設計師才是專業的程式設計師。
當然,為了達到這些目的,僅有編寫程式的禮節是不夠的,還需要很多相關的知識。這些知識既不屬於程式設計技巧,也不屬於演算法設計,並且和單元測試或者測試驅動開發這些話題也相對獨立。這些知識往往只能在公司無人問津的程式設計規範中才有所提及。這是我所見的僅把程式碼可讀性作為主題的一本書,而且這本書寫得很有趣!
既然是“藝術”,難免會有觀點上的多樣性。譯者本身作為程式設計師觀點更加“極端”一些。然而兩位作者見多識廣,輕易不會給出極端的建議,如“函式必須要小於10行”或者“註釋不可以用於解釋程式碼在做什麼而只能解釋為什麼這樣做”等語句很少出現在本書中。相反,作者給出目標以及判斷的標準。
翻譯書是件費時費力的事情,好在本書恰好涉及我感興趣的話題。但翻譯本書有一點點自相矛盾的地方,因為書中相當的篇幅是在講如何寫出易讀的英語。當然這裡的“英語”大多數的時候只是指“自然語言”,對於中文同樣適用。但鑑於大多數程式語言都是基於英語的(至少到目前為止),而且要求很多程式設計師用英語來註釋,在這種情況下努力學好英語也是必要的。
感謝機械工業出版社的各位編輯幫助我接觸和完成這本書的翻譯。這本譯作基本上可以說是在高鐵和飛機上完成的(我此時正在新加坡飛往香港的飛機上)。因此家庭的支援是非常重要的。尤其是我的妻子鄭秀雯(是的,新加坡的海關人員也對她的名字感興趣),她是全書的審校者。還有我“上有的老人”和“下有的小孩”,他們給予我幫助和關懷以及不斷前進的動力。
尹哲
前言
我們曾經在非常成功的軟體公司中和出色的工程師一起工作,然而我們所遇到的程式碼仍有很大的改進空間。實際上,我們曾見到一些很難看的程式碼,你可能也見過。
但是當我們看到寫得很漂亮的程式碼時,會很受啟發。好程式碼會很明確告訴你它在做什麼。使用它會很有趣,並且會鼓勵你把自己的程式碼寫得更好。
本書旨在幫助你把程式碼寫得更好。當我們說“程式碼”時,指的就是你在編輯器裡面要寫的一行一行的程式碼。我們不會討論專案的整體架構,或者所選擇的設計模式。當然那些很重要,但我們的經驗是程式設計師的日常工作的大部分時間都花在一些“基本”的事情上,像是給變數命名、寫迴圈以及在函式級別解決問題。並且這其中很大的一部分是閱讀和編輯已有的程式碼。我們希望本書對你每天的程式設計工作有很多幫助,並且希望你把本書推薦給你團隊中的每個人。
本書內容安排
這是一本關於如何編寫具有高可讀性程式碼的書。本書的關鍵思想是程式碼應該寫得容易理解。確切地說,使別人用最短的時間理解你的程式碼。
本書解釋了這種思想,並且用不同語言的大量例子來講解,包括C++、Python、JavaScript和Java。我們避免使用某種高階的語言特性,所以即使你不是對所有的語言都瞭解,也能很容易看懂。(以我們的經驗,反正可讀性的大部分概念都是和語言不相關的。)
每一章都會深入程式設計的某個方面來討論如何使程式碼更容易理解。本書分成四部分:
表面層次上的改進
命名、註釋以及審美——可以用於程式碼庫每一行的小提示。
簡化迴圈和邏輯
在程式中定義迴圈、邏輯和變數,從而使得程式碼更容易理解。
重新組織你的程式碼
在更高層次上組織大的程式碼塊以及在功能層次上解決問題的方法。
精選話題
把“易於理解”的思想應用於測試以及大資料結構程式碼的例子。
如何閱讀本書
我們希望本書讀起來愉快而又輕鬆。我們希望大部分讀者在一兩週之內讀完全書。
章節是按照“難度”來排序的:基本的話題在前面,更高階的話題在後面。然而,每章都是獨立的。因此如果你想跳著讀也可以。
程式碼示例的使用
本書旨在幫助你完成你的工作。一般來說,可以在程式和文件中使用本書的程式碼。如果你複製了程式碼的關鍵部分,那麼你就需要聯絡我們獲得許可。例如,利用本書的幾段程式碼編寫程式是不需要許可的。售賣或出版O’Reilly書中示例的D-ROM需要我們的許可。
引用本書回答問題以及引用示例程式碼不需要我們的許可。將本書的大量示例程式碼用於你的產品文件中需要許可。
如果你在參考文獻中提到我們,我們會非常感激,但並不強求。參考文獻通常包括標題、作者、出版社和ISBN。例如:“《The Art of Readable Code》by Dustin Boswell, and Trevor Foucher.©2012 Dustin Boswell, and Trevor Foucher,978-0-596-80229-5。”
如果你認為對程式碼示例的使用已經超出以上的許可範圍,我們很歡迎你通過permissions@oreilly.com聯絡我們。
聯絡我們
有關本書的任何建議和疑問,可以通過下列方式與我們取得聯絡:
美國:
O’Reilly Media, Inc.
1005 Gravenstein Highway North
Sebastopol, CA 95472
中國:
北京市西城區西直門南大街2號成銘大廈C座807室(100035)
奧萊利技術諮詢(北京)有限公司
我們會在本書的網頁中列出勘誤表、示例和其他資訊。可以通過http://oreilly.com/product/9780596802301.do訪問該頁面。
要評論或詢問本書的技術問題,請傳送郵件到:
bookquestions@oreilly.com
有關我們的書籍、會議、資源中心以及O’Reilly網路,可以訪問我們的網站:
http://www.oreilly.com
http://www.oreilly.com.cn
在Facebook上聯絡我們:http://facebook.com/oreilly
在Twitter上聯絡我們:http://twitter.com/oreillymedia
在You Tube上聯絡我們:http://youtube.com/oreillymedia
致謝
我們要感謝那些花時間審閱全書書稿的同事,包括Alan Davidson、Josh Ehrlich、Rob Konigsberg、Archie Russell、Gabe W.,以及Asaph Zemach。如果書裡有任何錯誤都是他們的過失(開玩笑)。
我們感激那些對書中不同部分的草稿給了具體反饋的很多審閱者,包括Michael Hunger、George Heinenman以及Chuck Hudson。
我們還從下面這些人那裡得到了大量的想法和反饋:John Blackburn、Tim Dasilva、Dennis Geels、Steve Gerding、Chris Harris、Josh Hyman、Joel Ingram、Erik Mavrinac、Greg Miller、Anatole Paine和Nick White。
感謝O'Reilly團隊無限的耐心和支援,他們是Mary Treseler(編輯)、Teresa Elsey(產品編輯)、Nancy Kotary(文字編輯)、Rob Romano(插圖畫家)、Jessica Hosman(工具)以及Abby Fox(工具)。還有我們的漫畫家Dave Allred,他把我們瘋狂的卡通想法展現了出來。
最後,我們想感謝Melissa和Suzanne,他們一直鼓勵我們,並給我們建立條件來滔滔不絕地談論程式設計話題。
第1章程式碼應當易於理解
在過去的五年裡,我們收集了上百個“壞程式碼”的例子(其中很大一部分是我們自己寫的),並且分析是什麼原因使它們變壞,使用什麼樣的原則和技術可以讓它們變好。我們發現所有的原則都源自同一個主題思想。
我們相信這是當你考慮要如何寫程式碼時可以使用的最重要的指導原則。貫穿本書,我們會展示如何把這條原則應用於你每天編碼工作的各個不同方面。但在開始之前,我們會詳細地介紹這條原則並證明它為什麼這麼重要。
是什麼讓程式碼變得“更好”
大多數程式設計師(包括兩位作者)依靠直覺和靈感來決定如何程式設計。我們都知道這樣的程式碼:
for (Node* node = list->head; node != NULL; node = node->next)
Print(node->data);
比下面的程式碼好:
Node* node = list->head;
if (node == NULL) return;
while (node->next != NULL) {
Print(node->data);
node = node->next;
}
if (node != NULL) Print(node->data);
(儘管兩個例子的行為完全相同。)
但很多時候這個選擇會更艱難。例如,這段程式碼:
return exponent >= 0 ? mantissa * (1 << exponent) : mantissa / (1 << -exponent);
它比下面這段要好些還是差些?
if (exponent >= 0) {
return mantissa * (1 << exponent);
} else {
return mantissa / (1 << -exponent);
}
第一個版本更緊湊,但第二個版本更直白。哪個標準更重要呢?一般情況下,在寫程式碼時你如何來選擇?
可讀性基本定理
在對很多這樣的例子進行研究後,我們總結出,有一種對可讀性的度量比其他任何的度量都要重要。因為它是如此重要,我們把它叫做“可讀性基本定理”。
關鍵思想:程式碼的寫法應當使別人理解它所需的時間最小化。
這是什麼意思?其實很直接,如果你叫一個普通的同事過來,測算一下他通讀你的程式碼並理解它所需的時間,這個“理解程式碼時間”就是你要最小化的理論度量。
並且當我們說“理解”時,我們對這個詞有個很高的標準。如果有人真的完全理解了你的程式碼,他就應該能改動它、找出缺陷並且明白它是如何與你程式碼的其他部分互動的。
現在,你可能會想:“誰會關心是不是有人能理解它?我是唯一使用這段程式碼的人!”就算你從事只有一個人的專案,這個目標也是值得的。那個“其他人”可能就是6個月後的你自己,那時你自己的程式碼看上去已經很陌生了。而且你永遠也不會知道——說不定別人會加入你的專案,或者你“丟棄的程式碼”會在其他專案裡重用。
總是越小越好嗎
一般來講,你解決問題所用的程式碼越少就越好(參見第13章)。很可能理解2000行程式碼寫成的類所需的時間比5000行的類要短。
但少的程式碼並不總是更好!很多時候,像下面這樣的一行表示式:
assert((!(bucket = FindBucket(key))) || !bucket->IsOccupied());
理解起來要比兩行程式碼花更多時間:
bucket = FindBucket(key);
if (bucket != NULL) assert(!bucket->IsOccupied());
類似地,一條註釋可以讓你更快地理解程式碼,儘管它給程式碼增加了長度:
// Fast version of "hash = (65599 * hash) + c"
hash = (hash << 6) + (hash << 16) - hash + c;
因此儘管減少程式碼行數是一個好目標,但把理解程式碼所需的時間最小化是一個更好的目標。
理解程式碼所需的時間是否與其他目標有衝突
你可能在想:“那麼其他約束呢?像是使程式碼更有效率,或者有好的架構,或者容易測試等?這些不會在有些時候與使程式碼容易理解這個目標衝突嗎?”
我們發現這些其他目標根本就不會互相影響。就算是在需要高度優化程式碼的領域,還是有辦法能讓程式碼同時可讀性更高。並且讓你的程式碼容易理解往往會把它引向好的架構且容易測試。
本書的餘下部分將討論如何把“易讀”這條原則應用在不同的場景中。但是請記住,當你猶豫不決時,可讀性基本定理總是先於本書中任何其他條例或原則。而且,有些程式設計師對於任何沒有完美地分解的程式碼都不自覺地想要修正它。這時很重要的是要停下來並且想一下:“這段程式碼容易理解嗎?”如果容易,可能轉而關注其他程式碼是沒有問題的。
最難的部分
是的,要經常地想一想其他人是不是會覺得你的程式碼容易理解,這需要額外的時間。這樣做就需要你開啟大腦中從前在編碼時可能沒有開啟的那部分功能。
但如果你接受了這個目標(像我們一樣),我們可以肯定你會成為一個更好的程式設計師,會產生更少的缺陷,從工作中獲得更多的自豪,並且編寫出你周圍人都愛用的程式碼。那麼讓我們開始吧!
第一部分表面層次的改進
我們的可讀性之旅從我們認為“表面層次”的改進開始:選擇好的名字、寫好的註釋以及把程式碼整潔地寫成更好的格式。這些改變很容易應用。你可以在“原位”做這些改變而不必重構程式碼或者改變程式的執行方式。你還可以增量地做這些修改卻不需要投入大量的時間。
這些話題很重要,因為會影響到你程式碼庫中的每行程式碼。儘管每個改變可能看上去都很小,聚集在一起造成程式碼庫巨大的改進。如果你的程式碼有很棒的名字、寫得很好的註釋,並且整潔地使用了空白符,你的程式碼會變得易讀得多。
當然,在表面層次之下還有很多關於可讀性的東西(我們會在本書的後面涵蓋這些內容)。但這一部分的材料幾乎不費吹灰之力就應用得如此廣泛,值得我們首先討論。
第2章把資訊裝到名字裡
無論是命名變數、函式還是類,都可以使用很多相同的原則。我們喜歡把名字當做一條小小的註釋。儘管空間不算很大,但選擇一個好名字可以讓它承載很多資訊。
我們在程式中見到的很多名字都很模糊,例如tmp。就算是看上去合理的詞,如size或者get,也都沒有裝入很多資訊。本章會告訴你如何把資訊裝入名字中。
本章分成6個專題:
l 選擇專業的詞。
l 避免泛泛的名字(或者說要知道什麼時候使用它)。
l 用具體的名字代替抽象的名字。
l 使用字首或字尾來給名字附帶更多資訊。
l 決定名字的長度。
l 利用名字的格式來表達含義。
選擇專業的詞
“把資訊裝入名字中”包括要選擇非常專業的詞,並且避免使用“空洞”的詞。
例如,“get”這個詞就非常不專業,例如在下面的例子中:
def GetPage(url): ...
“get”這個詞沒有表達出很多資訊。這個方法是從本地的快取中得到一個頁面,還是從資料庫中,或者從網際網路中?如果是從網際網路中,更專業的名字可以是FetchPage()或者DownloadPage()。
下面是一個BinaryTree類的例子:
class BinaryTree
{
int Size();
...
};
你期望Size()方法返回什麼呢?樹的高度,節點數,還是樹在記憶體中所佔的空間?
問題是Size()沒有承載很多資訊。更專業的詞可以是Height()、NumNodes()或者MemoryBytes()。
另外一個例子,假設你有某種Thread類:
class Thread
{
void Stop();
...
};
Stop()這個名字還可以,但根據它到底做什麼,可能會有更專業的名字。例如,你可以叫它Kill(),如果這是一個重量級操作,不能恢復。或者你可以叫它Pause(),如果有方法讓它Resume()。
找到更有表現力的詞
要勇於使用同義詞典或者問朋友更好的名字建議。英語是一門豐富的語言,有很多詞可以選擇。
下面是一些例子,這些單詞更有表現力,可能適合你的語境:
單詞 |
更多選擇 |
send |
deliver、dispatch、announce、distribute、route |
find |
search、extract、locate、recover |
start |
launch、create、begin、open |
make |
create、set up、build、generate、compose、add、new |
但別得意忘形。在PHP中,有一個函式可以explode()一個字串。這是個很有表現力的名字,描繪了一幅把東西拆成碎片的景象。但這與split()有什麼不同?(這是兩個不一樣的函式,但很難通過它們的名字來猜出不同點在哪裡。)
關鍵思想:清晰和精確比裝可愛好。
避免像tmp和retval這樣泛泛的名字
使用像tmp、retval和foo這樣的名字往往是“我想不出名字”的託辭。與其使用這樣空洞的名字,不如挑一個能描述這個實體的值或者目的的名字。
例如,下面的JavaScript函式使用了retval:
var euclidean_norm = function (v) {
var retval = 0.0;
for (var i = 0; i < v.length; i += 1)
retval += v[i] * v[i];
return Math.sqrt(retval);
};
當你想不出更好的名字來命名返回值時,很容易想到使用retval。但retval除了“我是一個返回值”外並沒有包含更多資訊(這裡的意義往往也是很明顯的)。
好的名字應當描述變數的目的或者它所承載的值。在本例中,這個變數正在累加v的平方。因此更貼切的名字可以是sum_squares。這樣就提前宣告瞭這個變數的目的,並且可能會幫忙找到缺陷。
例如,想象如果迴圈的內部被意外寫成:
retval += v[i];
如果名字換成sum_squares這個缺陷就會更明顯:
sum_squares += v[i]; //我們要累加的"square"在哪裡?缺陷!
然而,有些情況下泛泛的名字也承載著意義。讓我們來看看什麼時候使用它們有意義。
tmp
請想象一下交換兩個變數的經典情形:
if (right < left) {
tmp = right;
right = left;
left = tmp;
}
在這種情況下,tmp這個名字很好。這個變數唯一的目的就是臨時儲存,它的整個生命週期只在幾行程式碼之間。tmp這個名字向讀者傳遞特定資訊,也就是這個變數沒有其他職責,它不會被傳到其他函式中或者被重置以反覆使用。
但在下面的例子中對tmp的使用僅僅是因為懶惰:
String tmp = user.name();
tmp += " " + user.phone_number();
tmp += " " + user.email();
...
template.set("user_info", tmp);
儘管這裡的變數只有很短的生命週期,但對它來講最重要的並不是臨時儲存。用像user_info這樣的名字來代替可能會更具描述性。
在下面的情況中,tmp應當出現在名字中,但只是名字的一部分:
tmp_file = tempfile.NamedTemporaryFile()
...
SaveData(tmp_file, ...)
請注意我們把變數命名為tmp_file而非只是tmp,因為這是一個檔案物件。想象一下如果我們只是把它叫做tmp:
SaveData(tmp, ...)
只要看看這麼一行程式碼,就會發現不清楚tmp到底是檔案、檔名還是要寫入的資料。
建議:tmp這個名字只應用於短期存在且臨時性為其主要存在因素的變數。
迴圈迭代器
像i、j、iter和it等名字常用做索引和迴圈迭代器。儘管這些名字很空泛,但是大家都知道它們的意思是“我是一個迭代器”(實際上,如果你用這些名字來表示其他含義,那會很混亂。所以不要這麼做!)
但有時會有比i、j、k更貼切的迭代器命名。例如,下面的迴圈要找到哪個user屬於哪個club:
for (int i = 0; i < clubs.size(); i++)
for (int j = 0; j < clubs[i].members.size(); j++)
for (int k = 0; k < users.size(); k++)
if (clubs[i].members[k] == users[j])
cout << "user[" << j << "] is in club[" << i << "]" << endl;
在if條件語句中,members[]和users[]用了錯誤的索引。這樣的缺陷很難發現,因為這一行程式碼單獨來看似乎沒什麼問題:
if (clubs[i].members[k] == users[j])
在這種情況下,使用更精確的名字可能會有幫助。如果不把迴圈索引命名為(i、j、k),另一個選擇可以是(club_i、members_i、user_i)或者,更簡化一點(ci、mi、ui)。這種方式會幫助把程式碼中的缺陷變得更明顯:
if (clubs[ci].members[ui] == users[mi]) #缺陷!第一個字母不匹配。
如果用得正確,索引的第一個字母應該與資料的第一個字元匹配:
if (clubs[ci].members[mi] == users[ui]) #OK。首字母匹配。
對於空泛名字的裁定
如你所見,在某些情況下空泛的名字也有用處。
建議:如果你要使用像tmp、it或者retval這樣空泛的名字,那麼你要有個好的理由。
很多時候,僅僅因為懶惰而濫用它們。這可以理解,如果想不出更好的名字,那麼用個沒有意義的名字,像foo,然後繼續做別的事,這很容易。但如果你養成習慣多花幾秒鐘想出個好名字,你會發現你的“命名能力”很快提升。
用具體的名字代替抽象的名字
在給變數、函式或者其他元素命名時,要把它描述得更具體而不是更抽象。
例如,假設你有一個內部方法叫做ServerCanStart(),它檢測服務是否可以監聽某個給定的TCP/IP埠。然而ServerCanStart()有點抽象。CanListenOnPort()就更具體一些。
這個名字直接地描述了這個方法要做什麼事情。
下面的兩個例子更深入地描繪了這個概念。
例子:DISALLOW_EVIL_CONSTRUCTORS
這個例子來自Google的程式碼庫。在C++裡,如果你不為類定義拷貝建構函式或者賦值操作符,那就會有一個預設的。儘管這很方便,這些方法很容易導致記憶體洩漏以及其他災
難,因為它們在你可能想不到的“幕後”地方執行。
所以,Google有個便利的方法來禁止這些“邪惡”的建構函式,就是用這個巨集:
class ClassName {
private:
DISALLOW_EVIL_CONSTRUCTORS(ClassName);
public: ...
};
這個巨集定義成:
#define DISALLOW_EVIL_CONSTRUCTORS(ClassName) \
ClassName(const ClassName&); \
void operator=(const ClassName&);
通過把這個巨集放在類的私有部分中,這兩個方法成為私有的,所以不能用它們,即使意料之外的使用也是不可能的。
然而DISALLOW_EVIL_CONSTRUCTORS這個名字並不是很好。對於“邪惡”這個詞的使用包含了對於一個有爭議話題過於強烈的立場。更重要的是,這個巨集到底禁止了什麼這一點是不清楚的。它禁止了operator=()方法,但這個方法甚至根本就不是建構函式!
這個名字使用了幾年,但最終換成了一個不那麼囂張而且更具體的名字:
#define DISALLOW_COPY_AND_ASSIGN(ClassName) ...
例子:--run_locally (本地執行)
我們的一個程式有個可選的命令列標誌叫做--run_locally。這個標誌會使得這個程式輸出額外的除錯資訊,但是會執行得更慢。這個標誌一般用於在本地機器上測試,例如在膝上型電腦上。但是當這個程式執行在遠端伺服器上時,效能是很重要的,因此不會使用這個標誌。
你能看出來為什麼會有--run_locally這個名字,但是它有幾個問題:
l 團隊裡的新成員不知道它到底是做什麼的,可能在本地執行時使用它(想象一下),但不明白為什麼需要它。
l 偶爾,我們在遠端執行這個程式時也要輸出除錯資訊。向一個執行在遠端的程式傳遞--run_locally看上去很滑稽,而且很讓人迷惑。
l 有時我們可能要在本地執行效能測試,這時我們不想讓日誌把它拖慢,所以我們不會使用--run_locally。
這裡的問題是--run_locally是由它所使用的典型環境而得名。用像--extra_logging這樣的名字來代換可能會更直接明瞭。
但是如果--run_locally需要做比額外日誌更多的事情怎麼辦?例如,假設它需要建立和
使用一個特殊的本地資料庫。現在--run_locally看上去更吸引人了,因為它可以同時控制這兩種情況。
但這樣用的話就變成了因為一個名字含糊婉轉而需要選擇它,這可能不是一個好主意。
更好的辦法是再建立一個標誌叫--use_local_database。儘管你現在要用兩個標誌,但這兩個標誌非常明確,不會混淆兩個正交的含義,並且你可明確地選擇一個。
為名字附帶更多資訊
我們前面提到,一個變數名就像是一個小小的註釋。儘管空間不是很大,但不管你在名中擠進任何額外的資訊,每次有人看到這個變數名時都會同時看到這些資訊。
因此,如果關於一個變數有什麼重要事情的讀者必須知道,那麼是值得把額外的“詞”新增到名字中的。例如,假設你有一個變數包含一個十六進位制字串:
string id; // Example: "af84ef845cd8"
如果讓讀者記住這個ID的格式很重要的話,你可以把它改名為hex_id。
帶單位的值
如果你的變數是一個度量的話(如時間長度或者位元組數),那麼最好把名字帶上它的單位。
例如,這裡有些JavaScript程式碼用來度量一個網頁的載入時間:
var start = (new Date()).getTime(); // top of the page
...
var elapsed = (new Date()).getTime() - start; // bottom of the page
document.writeln("Load time was: " + elapsed + " seconds");
這段程式碼裡沒有明顯的錯誤,但它不能正常執行,因為getTime()會返回毫秒而非秒。
通過給變數結尾追加_ms,我們可以讓所有的地方更明確:
var start_ms = (new Date()).getTime(); // top of the page
...
var elapsed_ms = (new Date()).getTime() - start_ms; // bottom of the page
document.writeln("Load time was: " + elapsed_ms / 1000 + " seconds");
除了時間,還有很多在程式設計時會遇到的單位。下表列出一些沒有單位的函式引數以及帶單位的版本:
函式引數 |
帶單位的引數 |
Start(int delay) |
delay → delay_secs |
CreateCache(int size) |
size → size_mb |
ThrottleDownload(float limit) |
limit → max_kbps |
Rotate(float angle) |
angle → degrees_cw |
附帶其他重要屬性
這種給名字附帶額外資訊的技巧不僅限於單位。在對於這個變數存在危險或者意外的任何時候你都該採用它。
例如,很多安全漏洞來源於沒有意識到你的程式接收到的某些資料還沒有處於安全狀態。在這種情況下,你可能想要使用像untrustedUrl或者unsafeMessageBody這樣的名字。在呼叫了清查不安全輸入的函式後,得到的變數可以命名為trustedUrl或者safeMessageBody。
下表給出更多需要給名字附加上額外資訊的例子:
情形 |
變數名 更好的名字 |
一個“純文字”格式的密碼,需要加密後才能進一步使用 |
password plaintext_password |
一條使用者提供的註釋,需要轉義之後才能用於顯示 |
comment unescaped_comment |
已轉化為UTF-8格式的html位元組 |
html html_utf8 |
以“url方式編碼”的輸入資料 |
data data_urlenc |
但你不應該給程式中每個變數都加上像unescaped_或者_utf8這樣的屬性。如果有人誤解了這個變數就很容易產生缺陷,尤其是會產生像安全缺陷這樣可怕的結果,在這些地方這種技巧最有用武之地。基本上,如果這是一個需要理解的關鍵資訊,那就把它放在名字裡。
這是匈牙利表示法嗎?
匈牙利表示法是一個在微軟廣泛應用的命名系統,它把每個變數的“型別”資訊都編寫進名字的字首裡。下面有幾個例子:
名字 |
含義 |
pLast |
指向某資料結構最後一個元素的指標(p) |
pszBuffer |
指向一個以零結尾(z)的字串(s)的指標(p) |
cch |
一個字元(ch)計數(c) |
mpcopx |
在指向顏色的指標(pco)和指向x軸長度的指標(px)之間的一個對映(m) |
這實際上就是“給名字附帶上屬性”的例子。但它是一種更正式和嚴格的系統,關注於特有的一系列屬性。
我們在這一部分所提倡的是更廣泛的、更加非正式的系統:標識變數的任何關鍵屬性,如果需要的話以易讀的方式把它加到名字裡。你可以把這稱為“英語表示法”。
名字應該有多長
當選擇好名字時,有一個隱含的約束是名字不能太長。沒人喜歡在工作中遇到這樣的識別符號:
newNavigationControllerWrappingViewControllerForDataSourceOfClass
名字越長越難記,在螢幕上佔的地方也越大,可能會產生更多的換行。
另一方面,程式設計師也可能走另一個極端,只用單個單詞(或者單一字母)的名字。那麼如何來處理這種平衡呢?如何來決定是把一變數命名為d、days還是days_since_last_update呢?
這是要你自己要拿主意的,最好的答案和這個變數如何使用有關係,但下面還是提出了一些指導原則。
在小的作用域裡可以使用短的名字
當你去短期度假時,你帶的行李通常會比長假少。同樣,“作用域”小的識別符號(對於多少行其他程式碼可見)也不用帶上太多資訊。也就是說,因為所有的資訊(變數的型別、它的初值、如何析構等)都很容易看到,所以可以用很短的名字。
if (debug) {
map<string,int> m;
LookUpNamesNumbers(&m);
Print(m);
}
儘管m這個名字並沒有包含很多資訊,但這不是個問題。因為讀者已經有了需要理解這段程式碼的所有資訊。
然而,假設m是一個全域性變數中的類成員,如果你看到這個程式碼片段:
LookUpNamesNumbers(&m);
Print(m);
這段程式碼就沒有那麼好讀了,因為m的型別和目的都不明確。
因此如果一個識別符號有較大的作用域,那麼它的名字就要包含足夠的資訊以便含義更清楚。
輸入長名字——不再是個問題
有很多避免使用長名字的理由,但“不好輸入”這一條已經不再有效。我們所見到的所有的程式設計文字編輯器都有內建的“單詞補全”的功能。令人驚訝的是,大多數程式設計師並沒有注意到這個功能。如果你還沒在你的編輯器上試過這個功能,那麼請現在就放下本書然後試一下下面這些功能:
1. 鍵入名字的前面幾個字元。
2. 觸發單詞補全功能(見下表)。
3. 如果補全的單詞不正確,一直觸發這個功能直到正確的名字出現。
它非常準確。這個功能在任何語種的任何型別的檔案中都可以用。並且它對於任何單詞(token)都有效,甚至在你輸入註釋時也行。
編輯器 |
命令 |
Vi |
Ctrl+p |
Emacs |
Meta+/(先按ESC,然後按/) |
Eclipse |
Alt+/ |
IntelliJ |
IDEA |
TextMate |
ESC |
首字母縮略詞和縮寫
程式設計師有時會採用首字母縮略詞和縮寫來命令,以便保持較短的名字,例如,把一個類命名為BEManager而不是BackEndManager。這種名字會讓人費解,冒這種風險是否值得?
在我們的經驗中,使用專案所特有的縮寫詞非常糟糕。對於專案的新成員來講它們看上去太令人費解和陌生,當過了相當長的時間以後,即使是對於原作者來講,它們也會變得令人費解和陌生。
所以經驗原則是:團隊的新成員是否能理解這個名字的含義?如果能,那可能就沒有問題。
例如,對程式設計師來講,使用eval來代替evaluation,用doc來代替document,用str來代替string是相當普遍的。因此如果團隊的新成員看到FormatStr()可能會理解它是什麼意思,然而,理解BEManager可能有點困難。
丟掉沒用的詞
有時名字中的某些單詞可以拿掉而不會損失任何資訊。例如,ConvertToString()就不如ToString()這個更短的名字,而且沒有丟失任何有用的資訊。同樣,不用DoServeLoop(),ServeLoop()也一樣清楚。
利用名字的格式來傳遞含義
對於下劃線、連字元和大小寫的使用方式也可以把更多資訊裝到名字中。例如,下面是一些遵循Google開源專案格式規範的C++程式碼:
static const int kMaxOpenFiles = 100;
class LogReader {
public:
void OpenFile(string local_file);
private:
int offset_;
DISALLOW_COPY_AND_ASSIGN(LogReader);
};
對不同的實體使用不同的格式就像語法高亮顯示的形式一樣,能幫你更容易地閱讀程式碼。
該例子中的大部分格式都很常見,使用CamelCase來表示類名,使用lower_separated來表示變數名。但有些規範也可能會出乎你的意料。
例如,常量的格式是kConstantName而不是CONSTANT_NAME。這種形式的好處是容易和#define的巨集區分開,巨集的規範是MACRO_NAME。
類成員變數和普通變數一樣,但必須以一條下劃線結尾,如offset_。剛開始看,可能會覺得這個規範有點怪,但是能立刻區分出是成員變數還是其他變數,這一點還是很方便的。例如,如果你在瀏覽一個大的方法中的程式碼,看到這樣一行:
stats.clear();
你本來可能要想“stats屬於這個類嗎?這行程式碼是否會改變這個類的內部狀態?”如果用了member_這個規範,你就能迅速得到結論:“不,stats一定是個區域性變數。否則它就會命名為stats_。”
其他格式規範
根據專案上下文或語言的不同,還可以採用其他一些格式規範使得名字包含更多資訊。
例如,在《JavaScript:The Good Parts》一書中,作者建議“建構函式”(在新建時會呼叫的函式)應該首字母大寫而普通函式首字母小字:
var x = new DatePicker(); // DatePicker() is a "constructor" function
var y = pageHeight(); // pageHeight() is an ordinary function
下面是另一個JavaScript例子:當呼叫jQuery庫函式時(它的名字是單個字元$),一條非常有用的規範是,給jQuery返回的結果也加上$作為字首:
var $all_images = $("img"); // $all_images is a jQuery object
var height = 250; // height is not
在整段程式碼中,都會清楚地看到$all_images是個jQuery返回物件。
下面是最後一個例子,這次是HTML/CSS:當給一個HTML標記加id或者class屬性時,下劃線和連字元都是合法的值。一個可能的規範是用下劃線來分開ID中的單詞,用連字元來分開class中的單詞。
<div id="middle_column" class="main-content"> ...
是否要採用這些規範是由你和你的團隊決定的。但不論你用哪個系統,在你的專案中要保持一致。
總結
本章唯一的主題是:把資訊塞入名字中。這句話的含意是,讀者僅通過讀到名字就可以獲得大量資訊。
下面是討論過的幾個小提示:
l 使用專業的單詞——例如,不用Get,而用Fetch或者Download可能會更好,這由上下文決定。
l 避免空泛的名字,像tmp和retval,除非使用它們有特殊的理由。
l 使用具體的名字來更細緻地描述事物——ServerCanStart()這個名字就比CanListenOnPort更不清楚。
l 給變數名帶上重要的細節——例如,在值為毫秒的變數後面加上_ms,或者在還需要轉義的,未處理的變數前面加上raw_。
l 為作用域大的名字採用更長的名字——不要用讓人費解的一個或兩個字母的名字來命名在幾屏之間都可見的變數。對於只存在於幾行之間的變數用短一點的名字更好。
l 有目的地使用大小寫、下劃線等——例如,你可以在類成員和區域性變數後面加上"_"來區分它們。
第3章不會誤解的名字
在前一章中,我們講到了如何把資訊塞入名字中。本章會關注另一個話題:小心可能會有歧義的名字。
關鍵思想:要多問自己幾遍:“這個名字會被別人解讀成其他的含義嗎?”要仔細審視這個名字。
如果想更有創意一點,那麼可以主動地尋找“誤解點”。這一步可以幫助你發現那些二義性名字並更改。
例如,在本章中,當我們討論每一個可能會誤解的名字時,我們將在心裡默讀,然後挑選更好的名字。
例子:Filter()
假設你在寫一段運算元據庫結果的程式碼:
results = Database.all_objects.filter("year <= 2011")
結果現在包含哪些資訊?
l 年份小於或等於 2011的物件?
l 年份不小於或等於2011年的物件?
這裡的問題是“filter”是個二義性單詞。我們不清楚它的含義到底是“挑出”還是“減掉”。最好避免使用“filter”這個名字,因為它太容易誤解。
例子:Clip(text, length)
假設你有個函式用來剪下一個段落的內容:
# Cuts off the end of the text, and appends "..."
def Clip(text, length):
...
你可能會想象到Clip()的兩種行為方式:
l 從尾部刪除length的長度
l 截掉最大長度為length的一段
第二種方式(截掉)的可能性最大,但還是不能肯定。與其讓讀者亂猜程式碼,還不如把函式的名字改成Truncate(text, length)。
然而,引數名length也不太好。如果叫max_length的話可能會更清楚。
這樣也還沒有完。就算是max_length這個名字也還是會有多種解讀:
l 位元組數
l 字元數
l 字數
如你在前一章中所見,這屬於應當把單位附加在名字後面的那種情況。在本例中,我們是指“字元數”,所以不應該用max_length,而要用max_chars。
推薦用min和max來表示(包含)極限
假設你的購物車應用程式最多不能超過10件物品:
CART_TOO_BIG_LIMIT = 10
if shopping_cart.num_items() >= CART_TOO_BIG_LIMIT:
Error("Too many items in cart.")
這段程式碼有個經典的“大小差一”缺陷。我們可以簡單地通過把>=變成>來改正它:
if shopping_cart.num_items() > CART_TOO_BIG_LIMIT:
(或者通過把CART_TOO_BIG_LIMIT變成11)。但問題的根源在於 CART_TOO_BIG_LIMIT是個二義性名字,它的含義到底是“少於”還是“少於/且包括”。
建議:命名極限最清楚的方式是在要限制的東西前加上max_或者min_。
在本例中,名字應當是MAX_ITEMS_IN_CART,新程式碼現在變得簡單又清楚:
MAX_ITEMS_IN_CART = 10
if shopping_cart.num_items() > MAX_ITEMS_IN_CART:
Error("Too many items in cart.")
推薦用first和last來表示包含的範圍
下面是另一個例子,你沒法判斷它是“少於”還是“少於且包含”:
print integer_range(start=2, stop=4)
# Does this print [2,3] or [2,3,4] (or something else)?
儘管start是個合理的引數名,但stop可以有多種解讀。對於這樣包含的範圍(這種範圍包含開頭和結尾),一個好的選擇是first/last。例如:
set.PrintKeys(first="Bart", last="Maggie")
不像stop,last這個名字明顯是包含的。
除了first/last,min/max這兩個名字也適用於包含的範圍,如果它們在上下文中“聽上去合理”的話。
推薦用begin和end來表示包含/排除範圍
在實踐中,很多時候用包含/排除範圍更方便。例如,如果你想列印所有發生在10月16日的事件,那麼寫成這樣很簡單:
PrintEventsInRange("OCT 16 12:00am", "OCT 17 12:00am")
這樣寫就沒那麼簡單了:
PrintEventsInRange("OCT 16 12:00am", "OCT 16 11:59:59.9999pm")
因此對於這些引數來講,什麼樣的一對名字更好呢?對於命名包含/排除範圍典型的程式設計規範是使用begin/end。
但是end這個詞有點二義性。例如,在句子“我讀到這本書的end部分了”,這裡的end是包含的。遺憾的是,英語中沒有一個合適的詞來表示“剛好超過最後一個值”。
因為對begin/end的使用是如此常見(至少在C++標準庫中是這樣用的,還有大多數需要“分片”的陣列也是這樣用的),它已經是最好的選擇了。
給布林值命名
當為布林變數或者返回布林值的函式選擇名字時,要確保返回true和false的意義很明確。
下面是個危險的例子:
bool read_password = true;
這會有兩種截然不同的解釋:
l 我們需要讀取密碼。
l 已經讀取了密碼。
在本例中,最好避免用“read”這個詞,用need_password或者user_is_authenticated這樣的名字來代替。
通常來講,加上像is、has、can或should這樣的詞,可以把布林值變得更明確。
例如,SpaceLeft()函式聽上去像是會返回一個數字,如果它的本意是返回一個布林值,可能HasSapceLeft()個這名字更好一些。
最後,最好避免使用反義名字。例如,不要用:
bool disable_ssl = false;
而更簡單易讀(而且更緊湊)的表示方式是:
bool use_ssl = true;
與使用者的期望相匹配
有些名字之所以會讓人誤解是因為使用者對它們的含義有先入為主的印象,就算你的本意並非如此。在這種情況下,最好放棄這個名字而改用一個不會讓人誤解的名字。
例子:get*()
很多程式設計師都習慣了把以get開始的方法當做“輕量級訪問器”這樣的用法,它只是簡單地返回一個內部成員變數。如果違背這個習慣很可能會誤導使用者。
以下是一個用Java寫的例子,請不要這樣做:
public class StatisticsCollector {
public void addSample(double x) { ... }
public double getMean() {
// Iterate through all samples and return total / num_samples
}
...
}
在這個例子中,getMean()的實現是要遍歷所有經過的資料並同時計算中值。如果有大量的資料的話,這樣的一步可能會有很大的代價!但一個容易輕信的程式設計師可能會隨意地呼叫getMean(),還以為這是個沒什麼代價的呼叫。
相反,這個方法應當重新命名為像computeMean()這樣的名字,後者聽起來更像是有些代價的操作。(另一種做法是,用新的實現方法使它真的成為一個輕量級的操作。)
例子:list::size()
下面是一個來自C++標準庫中的例子。曾經有個很難發現的缺陷,使得我們的一臺伺服器慢得像蝸牛在爬,就是下面的程式碼造成的:
void ShrinkList(list<Node>& list, int max_size) {
while (list.size() > max_size) {
FreeNode(list.back());
list.pop_back();
}
}
這裡的“缺陷”是,作者不知道list.size()是一個O(n)操作——它要一個節點一個節點地歷數列表,而不是隻返回一個事先算好的個數,這就使得ShrinkList()成了一個O(n2)操作。
這段程式碼從技術上來講“正確”,事實上它也通過了所有的單元測試。但當把ShrinkList()應用於有100萬個元素的列表上時,要花超過一個小時來完成!
可能你在想:“這是呼叫者的錯,他應該更仔細地讀文件。”有道理,但在本例中,list.size()不是一個固定時間的操作,這一點是出人意料的。所有其他的C++容器類的size()方法都是時間固定的。
假使size()的名字是countSize()或者countElements(),很可能就會避免相同的錯誤。C++標準庫的作者可能是希望把它命名為size()以和所有其他的容器一致,就像vector和map。但是正因為他們的這個選擇使得程式設計師很容易誤把它當成一個快速的操作,就像其他的容器一樣。謝天謝地,現在最新的C++標準庫把size()改成了O(1)。
嚮導是誰
一段時間以前,有位作者正在安裝OpenBSD作業系統。在磁碟格式化這一步時,出現了一個複雜的選單,詢問磁碟引數。其中的一個選項是進入“嚮導模式”(Wizard mode)。他看到這個友好的選擇鬆了一口氣,並選擇了它。讓他失望的是,安裝程式給出了低層命名行提示符等待手動輸入磁碟格式化命令,而且也沒有明顯的方法可以退出。很明顯,這裡的“嚮導”指的是你自己。
例子:如何權衡多個備選名字
當你要選一個好名字時,可能會同時考慮多個備選方案。通常你要在頭腦中盤算一下每個名字的好處,然後才能得出最後的選擇。下面的例子示範了這個評判過程。
高流量網站常常用“試驗”來測試一個對網站的改變是否會對業務有幫助。下面的例子是一個配置檔案,用來控制某些試驗:
experiment_id: 100
description: "increase font size to 14pt"
traffic_fraction: 5%
...
每個試驗由15對屬性/值來定義。遺憾的是,當要定義另一個差不多的試驗時,你不得不拷貝和貼上其中的大部分。
experiment_id: 101
description: "increase font size to 13pt"
[other lines identical to experiment_id 100]
假設我們希望改善這種情況,方法是讓一個試驗重用另一個的屬性(這就是“原型繼承”模式)。其結果是你可能會寫出這樣的東西:
experiment_id: 101
the_other_experiment_id_I_want_to_reuse: 100
[change any properties as needed]
問題是:the_other_experiment_id_I_want_to_reuse到底應該如何命名?下面有4個名字供考慮:
1. template
2. reuse
3. copy
4. inherit
所有的這些名字對我們來講都有意義,因為是我們把這個新功能加入配置語言中的。但我們要想象一下對於看到這段程式碼卻又不知道這個功能的人來講,這個名字聽起來是什麼意思。因此我們要分析每一個名字,考慮各種讓人誤解的可能性。
1. 讓我們想象一下使用這個名字模板時的情形:
experiment_id: 101
template: 100
...
template有兩個問題。首先,我們不是很清楚它的意思是“我是一個模板”還是“我在用其他模板”。其次,“template”常常指代抽象事物,必須要先“填充”之後才會變“具體”。有人會以為一個模板化了的試驗不再是一個“真正的”試驗。總之,template對於這種情況來講太不明確。
2. 那麼reuse呢?
experiment_id: 101
reuse: 100
...
reuse這個單詞還可以,但有人會以為它的意思是“這個試驗最多可以重用100次”。把名字改成reuse_id會好一點。但有的讀者可能會以為reuse_id的意思是“我重用的id是100”。
3. 讓我們再考慮一下copy。
experiment_id: 101
copy: 100
...
copy這個詞不錯。但copy:100看上去像是在說“拷貝這個試驗100次”或者“這是什麼東西的第100個拷貝”。為了確保明確地表達這個名字是引用另一個試驗,我們可以把名字改成copy_experiement。這可能是到目前為止最好的名字了。
4. 但現在我們再來考慮一下inherit:
experiment_id: 101
inherit: 100
...
大多數程式設計師都熟悉“inherit”(繼承)這個詞,並且都理解在繼承之後會有進一步的修改。在類繼承中,你會從另一個類中得到所有的方法和成員,然後修改它們或者新增更多內容。甚至在現實生活中,我們說從親人那裡繼承財產,大家都理解你可能會賣掉它們或者再擁有更多屬於你自己的東西。
但是如果要明確它是繼承自另一個試驗,我們可以把名字改進成inherit_from,或者甚至是inherit_from_experiement_id。
綜上所述,copy_experiment和inherit_from_experiment_id是最好的名字,因為它們對
所發生的事情描述最清楚,並且最不可能誤解。
總結
不會誤解的名字是最好的名字——閱讀你程式碼的人應該理解你的本意,並且不會有其他的理解。遺憾的是,很多英語單詞在用來程式設計時是多義性的,例如filter、length和limit。
在你決定使用一個名字以前,要吹毛求疵一點,來想象一下你的名字會被誤解成什麼。最好的名字是不會誤解的。
當要定義一個值的上限或下限時,max_和min_是很好的字首。對於包含的範圍,first和last是好的選擇。對於包含/排除範圍,begin和end是最好的選擇,因為它們最常用。
當為布林值命名時,使用is和has這樣的詞來明確表示它是個布林值,避免使用反義的詞(例如disable_ssl)。
要小心使用者對特定詞的期望。例如,使用者會期望get()或者size()是輕量的方法。
第4章審美
很多想法來源於雜誌的版面設計——段落的長度、欄的寬度、文章的順序以及把什麼東西放在封面上等。一本好的雜誌既可以跳著看,也可以從頭讀到尾,怎麼看都很容易。
好的原始碼應當“看上去養眼”。本章會告訴大家如何使用好的留白、對齊及順序來讓你的程式碼變得更易讀。
確切地說,有三條原則:
l 使用一致的佈局,讓讀者很快就習慣這種風格。
l 讓相似的程式碼看上去相似。
l 把相關的程式碼行分組,形成程式碼塊。
在本章中,我們只關注可以改進程式碼的簡單“審美”方法。這些型別的改變很簡單並且常常能大幅地提高可讀性。有時大規模地重構程式碼(例如拆分出新的函式或者類)可能會更有幫助。我們的觀點是好的審美與好的設計是兩種獨立的思想。最好是同時在兩個方向上努力做到更好。
為什麼審美這麼重要
假設你不得不用這個類:
class StatsKeeper {
public:
// A class for keeping track of a series of doubles
void Add(double d); // and methods for quick statistics about them
private:
int count; /* how raany so far*/
public:
double Average();
private:
double minimum;
list<double> pastitems;
double maximum;
};
相對於下面這個更整潔的版本,你可能要花更多的時間來理解上面的程式碼:
// A class for keeping track of a series of doubles
// and methods for quick statistics about them.
class StatsKeeper {
public:
void Add(double d);
double Average();
private:
list<double> past_itews;
int count; // how many so far
double minimum;
double maximum;
};
很明顯,使用從審美角度講讓人愉悅的程式碼更容易。試想一下,你程式設計的大部分時間都花在看程式碼上!瀏覽程式碼的速度越快,人們就越容易使用它。
重新安排換行來保持一致和緊湊
假設你在寫Java程式碼來評估你的程式在不同的網路連線速度下的行為。你有一個 TcpConnectionSimulator,它的建構函式有4個引數:
1. 網路連線的速度(Kbps)
2. 平均延時(ms)
3. 延時的“抖動” (ms)
4. 丟包率(ms)
你的程式碼需要3個不同的TcpConnectionSimulator例項:
public class PerformanceTester {
public static final TcpConnectionSimulator wifi = new TcpConnectionSimulator(
500, /* Kbps */
80, /* millisecs latency */
200, /* jitter */
1 /* packet loss % */);
public static final TcpConnectionSimulator t3_fiber =
new TcpConnectionSimulator (
45000, /* Kbps */
10, /* millisecs latency */
0, /* jitter */
0 /* packet loss % */);
public static final TcpConnectionSimulator cell = new TcpConnectionSimulator (
100, /* Kbps */
400, /* millisecs latency */
250, /* jitter */
5 /* packet loss % */);
}
這段示例程式碼需要有很多額外的換行來滿足每行80個字元的限制(這是你們公司的編碼規範)。遺憾的是,這使得t3_fiber的定義看上去和它的鄰居不一樣。這段程式碼的“剪影”看上去很怪,它亳無理由地讓t3_fiber很突兀。這違反了“相似的程式碼應當看上去相似”這條原則。
為了讓程式碼看上去更一致,我們可以引入更多的換行(同時還可以讓註釋對齊)
public class PerformanceTester {
public static final TcpConnectionSimulator wifi =
new TcpConnectionSimulator(
500, /* Kbps */
80, /* millisecs latency */
200, /* jitter */
1 /* packet loss % */);
public static final TcpConnectionSimulator t3_fiber =
new TcpConnectionSimulator (
45000, /* Kbps */
10, /* millisecs latency */
0, /* jitter */
0 /* packet loss % */);
public static final TcpConnectionSimulator cell =
new TcpConnectionSimulator (
100, /* Kbps */
400, /* millisecs latency */
250, /* jitter */
5 /* packet loss % */);
}
這段程式碼有優雅一致的風格,並且很容易從頭看到尾快速瀏覽。但遺憾的是,它佔用了更多縱向的空間。並且它還把註釋重複了3遍。
下面是寫這個類的更緊湊方法:
public class PerformanceTester {
// TcpConnectionSimulator(throughput, latency, jitter, packet_loss)
// [Kbps] [ms] [ms] [percent]
public static final TcpConnectionSimulator wifi =
new TcpConnectionSimulator(500, 80, 200, 1);
public static final TcpConnectionSimulator t3_fiber =
new TcpConnectionSimulator (45000, 10, 0, 0);
public static final TcpConnectionSimulator cell =
new TcpConnectionSimulator (100, 400, 250, 5);
}
我們把註釋挪到了上面,然後把所有的引數都放在一行上。現在儘管註釋不再緊挨相鄰的每個數字,但“資料”現在排成更緊湊的一個表格。
用方法來整理不規則的東西
假設你有一個個人資料庫,它提供了下面這個函式:
// Turn a partial_name like "Doug Adams” into "Mr. Douglas Adams”.
// If not possible, 'error' is filled with an explanation.
string ExpandFullNane(DatabaseConnection dc, string partial_name, string* error);
並且這個函式由一系列的例子來測試:
DatabaseConnection database_connection;
string error;
assert(ExpandFullName(database_connection, "Doug Adams", &ierror)
== "Mr. Douglas Adams");
assert(error == "");
assert(ExpandFullName(database_connection, " Jake Brown ", &error)
== Mr. Jacob Brown III");
assert(error == "");
assert(ExpandFullNane(database_connection, "No Such Guy", &error) == "");
assert(error == "no match found");
assert(ExpandFullName(database_connection, "John", &error) == "");
assert(error == "more than one result");
這段程式碼沒什麼美感可言。有些行長得都換行了。這段程式碼的剪影很難看,也沒有什麼一致的風格。
但對於這種情況,重新佈置換行也僅能做到如此。更大的問題是這裡有很多重複的串,例如" assert(ExpandFullName(database_connection...",其中還有很多的"error"。要是真的想改進這段程式碼,需要一個輔助方法。就像這樣:
CheckFullName("Doug Adams", "Mr. Douglas Adams", "");
CheckFullNarae(" Jake Brown ", "Mr. Jake Brown III","");
CheckPullNane("No Such Guy", "", "no match found");
CheckFullNane("John","", "more than one result");
現在,很明顯這裡有4個測試,每個使用了不同的引數。儘管所有的“髒活”都放在 CheckFullName()中,但是這個函式也沒那麼差:
void CheckFullName(string partial_name,
string expected_full_name,
string expected_error) {
// database_connection is now a class member
string error;
string full_name = ExpandFullName(database_connection, partial_name, &error);
assert(error == expected_error);
assert(full_name == expected_full_name);
}
儘管我們的目的僅僅是讓程式碼更有美感,但這個改動同時有幾個附帶的效果:
l 它消除了原來程式碼中大最的重複,讓程式碼變得更緊湊。
l 每個測試用例重要的部分(名字和錯誤字串)現在都變得很直白。以前,這些字串是混雜在像database_connection和error這樣的標識之間的,這使得一眼看全這段程式碼變得很難。
l 現在新增新測試應當更簡單
這個故事想要傳達的寓意是使程式碼“看上去漂亮”通常會帶來不限於表面層次的改進,它可能會幫你把程式碼的結構做得更好。
在需要時使用列對齊
整齊的邊和列讓讀者可輕鬆地瀏覽文字。
有時你可以借用“列對齊”的方法來讓程式碼易讀。例如,在前一部分中,你可以用空白把CheckFullName的引數排成:
CheckFullName("Doug Adams" , "Mr. Douglas Adams" , "");
CheckFullNarae(" Jake Brown", "Mr. Jake Brown III", "");
CheckPullNane("No Such Guy" , "" , "no match found");
CheckFullNane("John" , "" , "more than one result");
在這段程式碼中,很容易區分出CheckFullName()的第二個和第三個引數。下面是一個簡單的例子,它有一大組變數定義:
# Extract POST parameters to local variables
details = request.POST.get('details')
location = request.POST.get('location')
phone = equest.POST.get('phone')
email = request.POST.get('email')
url = request.POST.get('url')
你可能注意到了,第三個定義有個拼寫錯誤(把request寫成了equest。當所有的內容都這麼整齊地排列起來時,這樣的錯誤就很明顯。
在wget資料庫中,可用的命令列選項(有一百多項)這樣列出::
commands[] = {
...
{ "timeout", NULL, cmd_spec_timeout },
{ "timestaraping",&opt.timestamping, cmd_boolean },
{ "tries", &opt.ntry, cmd_number_inf },
{ "useproxy", &opt.use_proxy, cmd_boolean },
{ "useragent", NULL, cmd_spec_useragent },
...
};
這種方式使行這個列表很容易快讀和從一列跳到另一列。
你應該用列對齊嗎
列的邊提供了“可見的欄杆”,閱讀起來很方便。這是個“讓相似的程式碼看起來相似”的好例子。
但有些程式設計師不喜歡它。一個原因是,建立和維護對齊的工作量很大。另一個原因是,在改動時它造成了更多的“不同”,對一行的改動可能會導致另外5行也要改動(大部分只是空白)。
我們的建議是要試試。在我們的經驗中,它並不像程式設計師擔心的那麼費工夫。如果真的很費工夫,你可以不這麼做。
選一個有意義的順序,始終一致地使用它
在很多情況下,程式碼的順序不會影響其正確性。例如,下面的5個變數定義可以寫成任 意的順序:
details = request.POST.get('details')
location = request.POST.get('location')
phone = request.POST.get('phone')
email = request.POST.get('email')
url = request.POST.get('url')
在這種情況下,不要隨機地排序,把它們按有意義的方式排列會有幫助。下面是一些想法:
l 讓變數的順序與對應的HTML表單中<input>欄位的順序相匹配。
l 從“最重要”到“最不重要”排序。
l 按字母順序排序。
無論使用什麼順序,你在程式碼中應當始終使用這一順序。如果後面改變了這個順序,那會讓人很困惑:
if details: rec.details = details
if phone: rec.phone = phone //Hey, where did 'location' go?
if email: rec.mail = email if url: rec.url = url
if location: rec.location = location # Why is 'location' down here now?
把宣告按塊組織起來
我們的大腦很自然地會按照分組和層次結構來思考,因此你可以通過這樣的組織方式來幫助讀者快速地理解你的程式碼。
例如,下面是一個前端伺服器的C++類,這裡有它所有方法的宣告:
class FrontendServer {
public:
FrontendServer();
void ViewProfile(HttpRequest* request);
void OpenDatabase(string location, string user);
void SaveProfile(HttpRequest* request);
string ExtractQueryParam(HttpRequest* request, string param);
void ReplyOK(HttpRequest* request, string html);
void FindFriends(HttpRequest* request);
void ReplyNotFound(HttpRequest* request, string error);
void CloseDatabase(string location);
~FrontendServer();
};
這不是很難看的程式碼,但可以肯定這樣的佈局不會對讀者更快地理解所有的方法有什麼幫助。不要把所有的方法都放到一個巨大的程式碼塊中,應當按邏輯把它們分成組,像以下這樣:
class FrontendServer {
public:
FrontendServer();
~FrontendServer();
// Handlers
void ViewProfile(HttpRequest* request);
void SaveProfile(HttpRequest* request);
void FindFriends(HttpRequest* request);
// Request/Reply Utilities
string ExtractQueryParam(HttpRequest* request, string param);
void ReplyOK(HttpRequest* request, string html);
void ReplyNotFound(HttpRequest* request, string error);
// Database Helpers
void OpenDatabase(string location, string user);
void CloseDatabase(string location);
};
這個版本容易理解多了。它還更易讀,儘管程式碼行數更多了。原因是你可以快速地找出 4個高層次段落,然後在需要時再閱讀每個段落的具體內容。
把程式碼分成“段落”
書面文字要分成段落是由於以下幾個原因:
l 它是一種把相似的想法放在一起並與其他想法分開的方法。
l 它提供了可見的“腳印”,如果沒有它,會很容易找不到你讀到哪裡了。
l 它便於段落之間的導航。
因為同樣的原因,程式碼也應當分成“段落”。例如,沒有人會喜歡讀下面這樣一大塊程式碼:
# Import the user's email contacts, and match them to users in our system.
# Then display a list of those users that he/she isn't already friends with.
def suggest_new_friends(user, email_password):
friends = user.friends()
friend_emails = set(f.email_for_f_in_friends)
contacts = import_contacts(user.email, email_password)
contact_emails = set(c.email_for_c_in_contacts)
non_friend_emails = contact_emails - friend_emails
suggested_friends = User.objects.select(email_in_non_friend_emails)
display['user'] = user
display['friends'] = friends
display['suggested_friends'] = suggested_friends
return render("suggested_friends.html", display)
可能看上去並不明顯,但這個函式會經過數個不同的步驟。因此,把這些行程式碼分成段落會特別有用:
def suggest_new_friends(user, email_password):
# Get the user's friends' email addresses.
friends = user.friends()
friend_emails = set(f.email_for_f_in_friends)
# Import all email addresses from this user's enall account.
contacts = import_contacts(user.email, email_password)
contact_emails = set(c.email_for_c_in_contacts)
# Find matching users that they aren't already friends with.
non_friend_emails = contact_emails - friend_emails
suggested_friends = User.objects.select(email_in_non_friend_eraails)
# Display these lists on the page.
display['user'] = user
display['friends'] - friends
display['suggested_friends'] = suggested_friends
return render("suggested_friends.html", display)
請注意,我們還給每個段落加了一條總結性的註釋,這也會幫助讀者瀏覽程式碼(參見第 5章)。
正如書面文字,有很多種方法可以分開程式碼,程式設計師可能會對長一點或短一點的段落有不同的偏好。
個人風格與一致性
有相當一部分審美選擇可以歸結為個人風格。例如,類定義的大括號該放在哪裡:
class Logger {
};
還是:
class Logger
{
};
選擇一種風格而非另一種,不會真的影響到程式碼的可讀性。但如果把兩種風格混在一起,就會對可讀性有影響了。
曾經在我們所從事過的很多專案中,我們感覺團隊所用的風格是“錯誤”的,但是我們還是遵守專案的習慣,因為我們知道一致性要重要得多。
關鍵思想:一致的風格比“正確”的風格更重。
總結
大家都願意讀有美感的程式碼。通過把程式碼用一致的、有意義的方式“格式化”,可以把程式碼變得更容易讀,並且可以讀得更快。
下面是討論過的一些具體技巧:
l 如果多個程式碼塊做相似的事情,嘗試讓它們有同樣的剪影。
l 把程式碼按“列”對齊可以讓程式碼更容易瀏覽。
l 如果在一段程式碼中提到A、B和C,那麼不要在另一段中說B、C和A。選擇一個有意義的順序,並始終用這樣的順序。
l 用空行來把大塊程式碼分成邏輯上的“段落”。
第5章該寫什麼樣的註釋
本章旨在幫助你明白應該寫什麼樣的註釋。你可能以為註釋的目的是“解釋程式碼做了什麼”,但這只是其中很小的一部分。
當你寫程式碼時,你的腦海裡會有很多有價值的資訊。當其他人讀你的程式碼時,這些資訊已經丟失了——他們所見到的只是眼前的程式碼。
本章會展示許多例子來說明什麼時候應該把你腦海中的資訊寫下來。我們略去了很多對註釋的世俗觀點,相對地,我們更關注註釋有趣的和“匱乏的”方面。
我們把本章組織成以下幾個部分:
l 瞭解什麼不需要註釋。
l 用程式碼記錄你的思想。
l 站在讀者的角度,去想象他們需要知道什麼。
什麼不需要註釋
閱讀註釋會佔用閱讀真實程式碼的時間,並且每條註釋都會佔用屏蓽上的空間。那麼,它最好是物有所值的。那麼如何來分辨什麼是好的註釋,什麼是沒有價值的註釋呢?
下面程式碼中所有的註釋都是沒有價值的:
// The class definition for Account
class Account {
public:
// Constructor
Account();
// Set the profit member to a new value
void SetProfit(double profit);
// Return the profit from this Account
double CetProfit();
};
這些註釋沒有價值是因為它們並沒有提供任何新的資訊,也不能幫助讀者更好地理解程式碼。
關鍵思想:不要為那些從程式碼本身就能快速推斷的事實寫註釋。
這裡“快速”是個重要的區別。考慮一下下面這段Python程式碼:
# remove everything after the second '*'
name = '*'.join(line.split('*')[:2])
從技術上來講,這裡的註釋也沒有表達出任何“新資訊”。如果你閱讀程式碼本身,你最終會明白它到底在做什麼。但對於大多數程式設計師來講,讀有註釋的程式碼比沒有註釋的程式碼理解起來要快速得多。
不要為了註釋而註釋
有些教授要求他們的學生在他們的程式碼作業中為每個函式都加上註釋。結果是,有些程式設計師會對沒有註釋的函式有負罪感,以至於他們把函式的名字和引數用句子的形式重寫了一遍:
// Find the Node in the given subtree, with the given name, using the given depth
Node* FindNodeInSubtree(Node* subtree, string name, int depth);
這種情況屬於“沒有價值的註釋”一類,函式的宣告與其註釋實際上是一樣的。對於這條註釋要麼刪除它,要麼改進它。如果你想要在這裡寫條註釋,它最好也能給出更多重要的細節:
// Find a Node with the given 'name' or return NULL.
// If depth <= 0, only 'subtree' is inspected.
// If depth == N, only 'subtree' and N levels below are inspected.
Node* FindNodeInSubtree(Node* subtree, string name, int depth);
不要給不好的名字加註釋——應該把名字改好
註釋不應用於粉飾不好的名字。例如,有一個叫做CleanReply()的函式,加上了看上去有用的註釋:
// Enforce limits on the Reply as stated in the Request,
// such as the nunber of items returned, or total byte size, etc.
void CleanReply(Request request, Reply reply);
這裡大部分的註釋只是在解釋“clean”是什麼意思。更好的做法是把“enforce limits”這個片語加到函式名裡:
// Make sure 'reply' meets the count/byte/etc. limits from the 'request'
void EnforceLimitsFromRequest(Request request, Reply reply);
這個函式現在更加“自我說明”了。一個好的名字比一個好的註釋更重要,因為在任何用到這個函式的地方都能看得到它。
下面是另一個例子,給名字不大好的函式加註釋:
// Releases the handle for this key. This doesn't modify the actual registry.
void DeleteRegistry(RegistryKey* key);
DeleteRegistry()這個名字聽起來像是一個很危險的函式(它會刪除登錄檔?!)註釋裡的“它不會改動真正的登錄檔”是想澄清困惑。
我們可以用一個更加自我說明的名字,就像:
void ReleaseRegistryHandle(RegistryKey* key);
通常來講,你不需要“柺杖式註釋”——試圖粉飾可讀性差的程式碼的註釋。寫程式碼的人常常把這條規則表述成:好程式碼>壞程式碼+好註釋。
記錄你的思想
現在你知道了什麼不需要註釋,下面討論什麼需要註釋(但往往沒有註釋)。
很多好的註釋僅通過“記錄你的想法”就能得到,也就是那些你在寫程式碼時有過的重要想法。
加入“導演評論”
電影中常有“導演評論”部分,電影製作者在其中給出自己的見解並且通過講故事來幫助你理解這部電影是如何製作的。同樣,你應該在程式碼中也加入註釋來記錄你對程式碼有價值的見解。
下面是一個例子:
// 出乎意料的是,對於這些資料用二叉樹比用雜湊錶快40%
// 雜湊運算的代價比左/右比較大得多
這段註釋教會讀者一些事情,並且防止他們為無謂的優化而浪費時間。
下面是另一個例子:
//作為整體可能會丟掉幾個詞。這沒有問題。要100%解決太難了
如果沒有這段註釋,讀者可能會以為這是個bug,然後浪費時間嘗試找到能讓它失敗的測試用例,或者嘗試改正這個bug。
註釋也可以用來解釋為什麼程式碼寫得不那麼整潔:
// 這個類正在變得越來越亂
// 也許我們應該建立一個‘ ResourceNode’子類來幫助整理
這段註釋承認程式碼很亂,但同時也鼓勵下一個人改正它(還給出了具體的建議)。如果沒有這段註釋,很多讀者可能會被這段亂程式碼嚇到而不敢碰它。
為程式碼中的瑕庇寫註釋
程式碼始終在演進,並且在這過程中肯定會有瑕疵。不要不好意思把這些瑕疵記錄下來。例如,當程式碼需要改進時:
// T0D0:採用更快演算法
或者當程式碼沒有完成時:
// TODO(dustin):處理除JPEG以外的影像格式
有幾種標記在程式設計師中很流行:
標記 |
通常的意義 |
TODO: |
我還沒有處理的事情 |
FIXME: |
已知的無法執行的程式碼 |
HACK: |
對一個問題不得不採用的比較粗糙的解決方案 |
XXX: |
危險!這裡有重要的問題 |
你的團隊可能對於是否可以使用及何時使用這些標記有具體的規範。例如,TODO:可能只用於重要的問題。如果是這樣,你可以用像todo::(小寫)或者maybe-later:這樣的方法表示次要的缺陷。
重要的是你應該可以隨時把程式碼將來應該如何改動的想法用註釋記錄下來。這種註釋給讀者帶來對程式碼質量和當前狀態的寶貴見解,甚至可能會給他們指出如何改進程式碼的方向。
給常量加註釋
當定義常量時,通常在常量背後都有一個關於它是什麼或者為什麼它是這個值的“故事”。例如,你可能會在程式碼中看到如下常量:
NUM_THREADS = 8
這一行看上去可能不需要註釋,但很可能選擇用這個值的程式設計師知道得比這個要多:
NUM_THREADS : 8 # as long as it's >= 2 * num_rocessors, that’s good enough.
現在,讀程式碼的人就有了調整這個值的指南了(比如,設定成1可能就太低了,設定成 50又太誇張了)。
或者有時常量的值本身並不重要。達到這種效果的註釋也會有用:
// Impose a reasonable limit - no human can read that much anyway.
const int MAX_RSS_SUBSCRIPTIONS = 1000;
還有這樣的情況,它是一個高度精細調整過的值,可能不應該大幅改動。
image_quality = 0.72; // users thought 0.72 gave the best size/quality tradeoff
在上述所有例子中,你可能不會想到要加註釋,但它們的確很有幫助。
有些常量不需要註釋,因為它們的名字本身已經很清楚(例如SECONDS_PERDAY)。但是在我們的經驗中,很多常量可以通過加註釋得以改進。這不過是匆匆記下你在決定這個常量值時的想法而已。
站在讀者的角度
我們在本書中所用的一個通用的技術是想象你的程式碼對於外人來講看起來是什麼樣子的,這個人並不像你那樣熟悉你的專案。這個技術對於發現什麼地方需要註釋尤為有用。
當別人讀你的程式碼時,有些部分更可能讓他們有這樣的想法:“什麼?為什麼會這樣?”你的工作就是要給這些部分加上註釋。
例如,看看下面Clear()的定義:
struct Recorder {
vector<float> data;
...
void Clear() {
vector<float>().swap(data); // Huh? Why not Just data.clear()?
}
} ;
大多數C++程式設計師看到這段程式碼時都會想:“為什麼他不直接用data.clear()而是與一個空的向量交換?”實際上只有這樣才能強制使向量真正地把記憶體歸還給記憶體分配器。這不是一個眾所周知的C++細節。起碼要加上這樣的註釋:
// Force vector to relinquish its memory (look up "STL swap trick")
vector<float>().swap(data);
公佈可能的陷阱
當為一個函式或者類寫文件時,可以問自己這樣的問題:“這段程式碼有什麼出人意料的地方?會不會被誤用?”基本上就是說你需要“未雨綢繆”,預料到人們使用你的程式碼時可能會遇到的問題。
例如,假設你寫了一個函式來向給定的使用者發郵件:
void SendEmail(string to, string subject, string body);
這個函式的實現包括連線到外部郵件服務,這可能會花整整一秒,或者更久。可能有人在寫Web應用時在不知情的情況下錯誤地在處理HTTP請求時呼叫這個函式。(這麼做可能會導致他們的Web應用在郵件服務當機時“掛起”。)
為了避免這種災難,你應當為這個“實現細節”加上註釋:
//呼叫外部服務來傳送郵件。(1分鐘之後超時。)
void SendEmail(string to, string subject, string body);
下面有另一個例子:假設你有一個函式FixBrokenHtml()用來嘗試重寫損壞的HTML,通過插入結束標記這樣的方法:
def FixBrokenHtml(html): ...
這個函式執行得很好,但要警惕當有深巢狀而且不匹配的標記時它的執行時間會暴增。對於很差的HTML輸入,該函式可能要執行幾分鐘。
與其讓使用者自己慢慢發現這一點,不如提前宣告:
//執行時間將達到O(number_tags * average_tag_depth),所以小心嚴重巢狀的輸入。
def FixBrokenHtml(html): ...
“全域性觀”註釋
對於團隊的新成員來講,最難的事情之一就是理解“全域性觀”——類之間如何互動,資料如何在整個系統中流動,以及入口點在哪裡。設計系統的人經常忘記給這些東西加註釋,“只緣身在此山中”。
思考下面的場景:有新人剛剛加入你的團隊,她坐在你旁邊,而你需要讓她熟悉程式碼庫。
在你帶領她瀏覽程式碼庫時,你可能會指著某些檔案或者類說這樣的話:
l “這段程式碼把我們的業務邏輯與資料庫粘在一起。任何應用層程式碼都不該直接使用它。”
l “這個類看上去很複雜,但它實際上只是個巧妙的快取。它對系統中的其他部分一無所知。”
在一分鐘的隨意對話之後,你的新團隊成員就知道得比她自己讀原始碼更多了。
這正是那種應該包含在高階別註釋中的資訊。
下面是一個檔案級別註釋的簡單例子:
//這個檔案包含一些輔助函式,為我們的檔案系統提供了更便利的介面
//它處理了檔案許可權及其他基本的細節。
不要對於寫龐大的正式文件這種想法不知所措。幾句精心選擇的話比什麼都沒有強。
總結性註釋
就算在一個函式的內部,給“全域性觀”寫註釋也是個不錯的主意。下面是一個例子,這段註釋巧妙地總結了其後的低層程式碼:
# Find all the items that customers purchased for themselves.
for customer_id in allcustomers:
for sale in all_sales[customer_id].sales:
if sale.recipient == customer_id:
...
沒有這段註釋,每行程式碼都有些謎團。(我知道這是在遍歷all_custome^s……但是為什麼要這麼做?)
在包含幾大塊的長函式中這種總結性的註釋尤其有用:
def CenerateUserReport():
# Acquire a lock for this user
...
# Read user's info from the database
...
# Write info to a file
...
# Release the lock for this user
這些註釋同時也是對於函式所做事情的總結,因此讀者可以在深入瞭解細節之前就能得到該函式的主旨。(如果這些大段很容易分開,你可以直接把它們寫成函式。正如我們前面提到的,好程式碼比有好註釋的差程式碼要強。)
註釋應該說明“做什麼”、“為什麼”還是“怎麼做”?
你可能聽說過這樣的建議:“註釋應該說明‘為什麼這樣做’而非‘做什麼’(或者‘怎麼做’)”。這雖然很容易記,但我們覺得這種說法太簡單化,並且對於不同的人有不同的含義。
我們的建議是你可以做任何能幫助讀者更容易理解程式碼的事。這可能也會包含對於“做什麼”、“怎麼做”或者“為什麼”的註釋(或者同時註釋這三個方面)。
最後的思考——克服“作者心理阻滯”
很多程式設計師不喜歡寫註釋,因為要寫出好的註釋感覺好像要花很多工夫。當作者有了這種“作者心理阻滯”,最好的辦法就是現在就開始寫。因此下次當你對寫註釋猶豫不決時,就直接把你心裡想的寫下來就好了,雖然這種註釋可能是不成熟的。
例如,假設你正在寫一個函式,然後心想:“哦,天啊,如果一旦這東西在列表中有重複的話會變得很難處理的。”那麼就直接把它寫下來:
//哦,天啊,如果一旦這東西在列表中有重複的話會變得很難處理的。
看到了,這難嗎?它作為註釋來講實際上沒那麼差——起碼比沒有強。可能措辭有點含糊。要改正這一點,可以把每個子句改得更專業一些:
l “哦,天啊”,實際上,你的意思是“小心:這個地方需要注意”。
l “這東西”,實際上,你的意思是“處理輸入的這段程式碼”。
l “會變得很難處理”,實際上,你的意思是“會變得難以實現”。
新的註釋可以是:
//小心:這段程式碼不會處理列表中的重複(因為這很難做到)
請注意我們把寫註釋這件事拆成了幾個簡單的步驟:
1. 不管你心裡想什麼,先把它寫下來。
2. 讀一下這段註釋,看看有沒有什麼地方可以改進。
3. 不斷改進。
當你經常寫註釋,你就會發現步驟1所產生的註釋變得越來越好,最後可能不再需要做任何修改了。並且通過早寫註釋和常寫註釋,你可以避免在最後要寫一大堆註釋這種令人不快的狀況。
總結
註釋的目的是幫助讀者瞭解作者在寫程式碼時已經知道的那些事情。本章介紹瞭如何發現所有的並不那麼明顯的資訊塊並且把它們寫下來。
什麼地方不需要註釋:
l 能從程式碼本身中迅速地推斷的事實。
l 用來粉飾爛程式碼(例如蹩腳的函式名)的“柺杖式註釋”——應該把程式碼改好。
你應該記錄下來的想法包括:
l 對於為什麼程式碼寫成這樣而不是那樣的內在理由(“指導性批註”)。
l 程式碼中的缺陷,使用像TODO:或者XXX:這樣的標記。
l 常量背後的故事,為什麼是這個值。
站在讀者的立場上思考:
l 預料到程式碼中哪些部分會讓讀者說:“啊?”並且給它們加上註釋。
l 為普通讀者意料之外的行為加上註釋。
l 在檔案/類的級別上使用“全域性觀”註釋來解釋所有的部分是如何一起工作的。
l 用註釋來總結程式碼塊,使讀者不致迷失在細節中。
第6章寫出言簡意賅的註釋
前一章是關於發現什麼地方要寫註釋的。本章則是關於如何寫出言簡意賅的註釋。
如果你要寫註釋,最好把它寫得精確——越明確和細緻越好。另外,由於註釋在螢幕上也要佔很多的地方,並且需要花更多的時間來讀,因此,註釋也需要很緊湊。
本章其餘部分將舉例說明如何做到這一點。
讓註釋保持緊湊
下面的例子是一個C++型別定義的註釋:
// The int is the CategoryType.
// The first float in the inner pair is the 'score',
// the second is the 'weight'.
typedef hash_map<int, pair<float, float> > ScoreMap;
可是為什麼解釋這個例子要用三行呢?用一行不就可以了嗎?
// CategoryType -> (score, weight)
typedef hashjnap<int^ pair<float, float> > ScoreMap;
的確有些註釋要佔用三行那麼多的空間,但這個不需要。
避免使用不明確的代詞
就像經典的美國相聲《誰在一壘》(Who's on First?)一樣,代詞可能會讓事情變得令人困惑。
讀者要花更多的工夫來“解讀”一個代詞。在有些情況下,“it”或者“this”到底指代什麼是不清楚的。看下面這個例子:
// Insert the data into the cache, but check if it's too big first.
在這段註釋中,“it”可能指資料也可能是指快取。可能在讀完剩下的程式碼後你會找到答案。但如果你必須這麼做,又要註釋幹什麼呢?
最安全的方式是,如果在有可能會造成困惑的地方把“填寫”代詞。在前一個例子中,假設“it”是指“data”,那麼:
// Insert the data into the cache, but check if the data is too big first.
這是最簡單的改進方法。你也可以重新組織這個句子來讓“it”變得很明確:
// If the data is small enough, insert it into the cache
潤色粗糙的句子
在很多情況下,讓註釋更精確的過程總是伴隨著讓註釋更緊湊。
下面是一個網頁爬蟲的例子:
# Depending on whether we've already crawled this URL before, give it a different priority.
這個句子看上去可能沒什麼問題,但如果和下面這個版本相比呢?
# Give higher priority to URLs we've never crawled before.
後一個句子更簡單、更小巧並且更直接。它同時還解釋了未曾爬到過的URL將得到較高的優先順序——前面那條註釋沒有包含這部分資訊。
精確地描述函式的行為
假設你剛寫了一個函式,它統計一個檔案中的行數:
// Return the number of lines in this file.
int CountLines(string filename) { ... }
上面的註釋並不是很精確,因為有很多定義“行”的方式。下面列出幾個特別的情況:
l ""(空檔案)——0或1行?
l "hello"——0或1行?
l "hello\n"——1或2行?
l "hello\n world”——1或2行?
l "hello\n\r world\r"——2、3或4行?
最簡單的實現方法是統計換行符(\n的個數(這就是Unix命令wc的工作原理)。下面的註釋對於這種實現方法更好一些:
// Count how nany newline bytes ('\n') are in thc file.
int CountLines(string filename) { ... }
這條註釋並沒有比第一個版本長很多,但包含更多資訊。它告訴讀者如果沒有換行符,這個函式會返回0。它還告訴讀者回車符(\r)會被忽略。
用輸入輸出例子來說明特別的情況
對於註釋來講,一個精心挑選的輸入/輸出例子比千言萬語還有效。
例如,下面是一個用來移除部分字串的通用函式:
// Remove the suffijc/prefix of 'chars' from the input 'src'.
String Strip(String src, String chars) {…}
這條註釋不是很精確,因為它不能回答下列問題:
l chars是整個要移除的子串,還是一組無序的字母?
l 如果在src的結尾有多個chars會怎樣?
然而一個精心挑選的例子就可以回答這些問題:
// ...
// Example: Strip("abba/a/ba", "ab") returns "/a/"
String Strip(String src, String chars) {…}
這個例子展示了Strip()的整個功能。請注意,如果一個更簡單的示例不能回答這些問題的話,它就不會那麼有用:
// Example: Strip("ab", "a") returns "b"
下面是另一個函式的例子,也能說明這個用法:
// Rearrange 'v' so that elements < pivot come before those >= pivot;
// Then return the largest 'i' for which v[i] < pivot (or -1 if none are < pivot)
int Partition(vector<int>* v, int pivot);
這段註釋實際上很精確,但是不直觀。可以用下面的例子來進一步解釋:
// ...
// Example: Partition([8 5 9 8 2], 8) might result in [5 2 | 8 9 8] and return 1
int Partition(vector<int>* v, int pivot);
對於我們所選擇的特別的輸入/輸出例子,有以下幾點值得提一下:
l pivot與向量中的元素相等,用來解釋邊界情況。
l 我們在向量中放入重複元素(8)來說明這是一種可以接受的輸入。
l 返回的向量沒有排序——如果是排好序的,讀者可能會誤解。
l 因為返回值是1,我們要確保1不是向量中的值——否則會讓人很困惑。
宣告程式碼的意圖
正如我們在前一章中提到的,很多時候註釋的作用就是要告訴讀者當你寫程式碼時你是怎麼想的。遺憾的是,很多註釋只描述程式碼字面上的意思,沒有包含多少新資訊。
下面的例子就是一條這樣的註釋:
void DisplayProducts(list<Product> products) {
products.sort(CompareProductByPrice);
// Iterate through the list in reverse order
for (list<Product>::reverse_iterator it = products.rbegin(); it != products.rend(); ++it)
DisplayPrice(it->price);
...
}
這裡的註釋只是描述了它下面的那行程式碼。相反,更好的註釋可以是這樣的:
// Display each price, fron highest to lowest
for (list<Product>::reverse_iterator it = products.rbegin(); ...)
這條註釋從更高的層次解釋了這段程式在做什麼。這更符合程式設計師寫這段程式碼時的想法。
有趣的是,這段程式中有一個bug!函式CompareProducyByPrice (例子中沒有給出)已經把高價的專案排在了前面。這段程式碼所做的事情與作者的意圖相反。
這是第二種註釋更好的原因。除了這個bug,第一條註釋從技術上講是正確的(迴圈進行的確是反向遍歷)。但是有了第二條註釋,讀者更可能會注意到作者的意圖(先顯示高價專案)與程式碼實際所做的有衝突。其效果是,這條註釋扮演了冗餘檢査的角色。
最終來講,最好的冗餘檢查是單元測試(參見第14章)。但是在你的程式中寫這種解釋意圖的註釋仍是值得的。
“具名函式引數”的註釋
假設你見到下面這樣的函式呼叫:
Connect(10, false);
因為這裡傳入的整數和布林型值,使得這個函式呼叫有點難以理解。
在像Python這樣的語言中,你可以按名字為引數賦值:
def Connect(timeout, use_encryption): ...
# Call the function using named parameters
Connect(tineout = 10, use_encryption = False)
在像C++和Java這樣的語言中,你不能這樣做。然而,你可以通過嵌入的註釋達到同樣的效果:
void Connect(int timeout, bool use_encryption) {…}
// Call the function with commented parameters
Connect(/* timeout_ms = */ 10, /* use_encryption = */ false);
請注意我們給第一個引數起名為timeout_ms而不是timeout。從理想角度來講,如果函式的實際引數是timeout_ms就好了,但如果因為某些原因我們無法做到這種改變,這也是“改進”這個名字的一個便捷的方法。
對於布林引數來講,在值的前面加上/* name = */尤其重要。把註釋寫在值的後面讓人困惑:
//不要這樣做
Connect( ... , false /* use_encryption */);
//也不要這樣做
Connect( ..., false /* = use_encryption */);
在上面這些例子中,我們不清楚false的含義是“使用加密”還是“不使用加密”。
大多數函式不需要這樣的註釋,但這種方法可以方便(而且緊湊)地解釋看上去難以理解的引數。
採用資訊含量高的詞
一旦你寫了多年程式以後,你會發現有些普遍的問題和解決方案會重複出現。通常會有專門的詞或短語來描述這種模式/定式。使用這些詞會讓你的註釋更加緊湊。
例如,假設你原來的註釋是這樣的:
// This class contains a number of members that store the same information as in the
// database, but are stored here for speed. When this class is read from later, those
// members are checked first to see if they exist, and if so are returned; otherwi.se the
// database is read from and that data stored in these field for next time.
那麼你可以簡單地說:
// This class acts as a caching layer to the database.
另一個註釋的例子:
// Remove excess whitespace from the street address, and do lots of other cleanup
// like turn "Avenue" into "Ave." This way, if there are two different street addresses
// that are typed in slightly differently, they will have the same cleaned-up version and
// we can detect that these are equal.
可以寫成:
// Canonicalize the street address (remove extra spaces, "Avenue" -> "Ave.", etc.)
很多詞和短語都具有多種含義,例如“heuristic”、“bruteforce”、“naive solution”等。如果你感覺到一段註釋太長了,那麼可以看看是不是可以用一個典型的程式設計場景來描述它。
總結
本章是關於如何把更多的資訊裝入更小的空間裡。下面是一些具體的提示:
l 當像“it”和“this”這樣的代詞可能指代多個事物時,避免使用它們。
l 儘量精確地描述函式的行為。
l 在註釋中用精心挑選的輸入/輸出例子進行說明。
l 宣告程式碼的高層次意圖,而非明顯的細節。
l 用嵌入的註釋(如Function(/*arg =*/...))來解釋難以理解的函式引數。
l 用含義豐富的詞來使註釋簡潔。
第二部分簡化迴圈和邏輯
第一部分介紹了表面層次的改進,那是一些改進程式碼可讀性的簡單方法,一次一行,在沒有很大的風險或者花很大代價的情況下就可以應用。
第二部分將進一步深入討論程式的“迴圈和邏輯”:控制流、邏輯表示式以及讓你的程式碼正常執行的那些變數。和第一部分的要求一樣,我們的目標是讓程式碼中的這些部分容易理解。
我們通過試著最小化程式碼中的“思維包袱”來達到目的。每當你看到一個複雜的邏輯、一個巨大的表示式或者一大堆變數,這些都會增加你頭腦中的思維包袱。它需要讓你考慮得更復雜並且記住更多事情。這恰恰與“容易理解”相反。當程式碼中有很多思維包袱時,很可能在不知不覺中就會產生bug,程式碼會變得難以改變,並且使用它也沒那麼有趣了。
第7章把控制流變得易讀
如果程式碼中沒有條件判斷、迴圈或者任何其他的控制流語句,那麼它的可讀性會很好。而跳轉和分支等困難部分則會很快地讓程式碼變得混亂。本章就是關於如何把程式碼中的控制流變得易讀的。
關鍵思想:把條件、迴圈以及其他對控制流的改變做得越“自然”越好。運用一種方式使讀者不用停下來重讀你的程式碼。
條件語句中引數的順序
下面的兩段程式碼哪個更易讀?
if (length >= 10)
還是
if (10 <= length)
對大多數程式設計師來講,第一段更易讀。那麼,下面的兩段呢?
while (bytes_received < bytes_expected)
還是
while (bytes_expected > bytes_received)
仍然是第一段更易讀。可為什麼會這樣?通用的規則是什麼?你怎麼才能決定是寫成 a<b好一些,還是寫成b>a好一些?
下面的這條指導原則很有幫助:
比較的左側 |
比較的右側 |
“被問詢的”表示式,它的值更傾向於不斷變化 |
用來做比較的表示式,它的值更傾向於常量 |
這條指導原則和英語的用法一致。我們會很自然地說:“如果你的年收入至少是10萬美元”或者“如果你不小於18歲。”而“如果18歲小於或等於你的年齡”這樣的說法卻很少見。
這也解釋了為什麼while (bytes_received < bytes_expected)有更好的可讀性。bytes_received是我們在檢查的值,並且在迴圈的執行中它在增長。當用來做比較時,bytes_expected則是更“穩定”的那個值。
“尤達表示法”:還有用嗎?
在有些語言中(包括C和C++,但不包括Java),可以把賦值操作放在if條件中:
if (obj = NULL) ...
這極有可能是個bug,程式設計師本來的意圖是:
if (obj == NULL) ...
為了避免這樣的bug,很多程式設計師把引數的順序調換一下:
if (NULL == obj) ...
這樣,如果把==誤寫為=,那麼表示式if (NULL = obj)連編譯也通不過。
遺憾的是,這種順序的改變使得程式碼讀起來很不自然(就像電影《星球大戰》裡的尤達大師的語氣:“除非對此有話可說之於我”)。慶幸的是,現代編譯器對if (obj = NULL)這樣的程式碼會給出警告,因此“尤達表示法”是已經過時的事情了。
if/else語句塊的順序
在寫if/else語句時,你通常可以自由地變換語句塊的順序。例如,你既可以寫成:
if (a == b) {
// Case One ...
} else {
// Case Two ...
}
也可以寫成:
if (a != b) {
// Case Two ...
} else {
// Case One ...
}
之前你可能沒想過太多,但在有些情況下有理由相信其中一種順序比另一種好:
l 首先處理正邏輯而不是負邏輯的情況。例如,用if(debug)而不是if(!debug)。
l 先處理掉簡單的情況。這種方式可能還會使得if和else在螢幕之內都可見,這很好。
l 先處理有趣的或者是可疑的情況。
有時這些傾向性之間會有衝突,那麼你就要自己判斷了。但在很多情況下這都會有明確的選擇。
例如,假設你有一個Web伺服器,它會根據URL是否包含查詢引數expand_all來建構一個response:
if (!url.HasQueryParameter("expand_all")) {
response.Render(items);
...
} else {
for (int i = 0; i < items.size(); i++) {
items[i].Expand();
}
...
}
當讀者剛看到第一行程式碼時,他的腦海中馬上開始思考expand_all的情況。這就像當有人說“不要去想一頭粉紅色的大象”時,你會不由自主地去想。“不要”這個詞已經被更不尋常的“粉紅色的大象”給淹沒了。
這裡,expand_all就是我們的“粉紅色的大象”。讓我們先來處理這種情況,因為它更有趣(並且也是正邏輯):
if (url.HasQueryParameter("expand_all")) {
for (int i = 0; i < items.size(); i++) {
items[i].Expand();
}
...
} else {
response.Render(items);
...
}
另外,下面所示是負邏輯更簡單並且更有趣或更危險的一種情況,那麼會先處理它:
if not file:
# Log the error...
else:
# ...
同樣,根據具體情況的不同,這也是需要你自己來判斷的。
作為小結,我們的建議很簡單,就是要注意這些因素並且小心那些會使你的if/else順序很彆扭的情況。
?:條件表示式(又名“三目運算子”)
在類C的語言中,可以把一個條件表示式寫成cond ? a : b這樣的形式,其實就是一種對 if (cond) { a } else {b }的緊湊寫法。
它對於可讀性的影響是富有爭議的。擁護者認為這種方式可以只寫一行而不用寫成多行。反對者則說這可能會造成閱讀的混亂而且很難用偵錯程式來除錯。
下面是一個三目運算子易讀而又緊湊的應用:
time_str += (hour >= 12) ? "pm" : "am";
要避免三目運算子,你可能要這樣寫:
if (hour >= 12) {
time str += "pm";
} else {
time_str += "am";
}
這有點冗長了。在這種情況下使用條件表示式似乎是合理的。然而,這種表示式可能很快就會變得很難讀:
return exponent >= 0 ? mantissa * (1 << exponent) : mantissa / (1 << -exponent);
在這裡,三目運算子已經不只是從兩個簡單的值中做出選擇。寫出這種程式碼的動機往往是“把所有的程式碼都擠進一行裡”。
關鍵思想:相對於追求最小化程式碼行數,一個更好的度量方法是最小化人們理解它所需的時間。
if (exponent >= 0) {
return mantissa * (1 << exponent);
} else {
return mantissa / (1 << -exponent);
}
建議:預設情況下都用if/else。三目運算子?:只有在最簡單的情況下使用。
避免do/while迴圈
很多推崇的程式語言,包括Perl,都有do {expression} while (condition)迴圈。其中的表示式至少會執行一次。下面舉個例子:
//在列表中從node開始査找給出的name節點
//不用考慮超出max_length的節點。
public boolean ListHasNode(Node node, String name, int max_length) {
do {
if (node.name().equals(name))
return true; node = node.next();
} while (node != null && --max_length > 0);
return false;
}
do/while的奇怪之處是一個程式碼塊是否會執行是由其後的一個條件決定的。通常來講,邏輯條件應該出現在它們所“保護”的程式碼之前,這也是if、while和for語句的工作方式。因為你通常會從前向後來讀程式碼,這就使得do/while迴圈有點不自然了。很多讀者最後會讀這段程式碼兩遍。
while迴圈相對更易讀,因為你會先讀到所有迭代的條件,然後再讀到其中的程式碼塊。但僅僅是為了去掉do/while迴圈而重複一段程式碼是有點愚蠢的做法:
//機械地模仿do/while迴圈——不要這樣做!
body
while (condition) {
body (again)
}
幸運的是,我們發現實踐當中大多數的do/while迴圈都可以寫成這樣開頭的while迴圈:
public boolean ListHasNode(Node node, String name, int max_length) {
while (node != null && max_length-- > 0) {
if (node.name().equals(name)) return true;
node = node.next();
}
return false;
}
這個版本還有一個好處是對於max_length是0或者node是null的情況它仍然可以工作。
另一個要避免do/while迴圈的原因是其中的continue語句會很讓人迷惑。例如,下面這段程式碼會做什麼?
do {
continue;
} while (false);
它會永遠迴圈下去還是隻執行一次?大多數程式設計師都不得不停下來想一想。(它只會迴圈一次。)
最後,C++的開創者Bjarne Stroustrup講得好(在《C++程式設計語言》一書中):
我的經驗是,do語句是錯誤和困惑的來源……我傾向於把條件放在“前面我能看到的地方”。其結果是,我傾向於避免使用do語句。
從函式中提前返回
有些程式設計師認為函式中永遠不應該出現多條return語句。這是胡說八道。從函式中提前返回沒有問題,而且常常很受歡迎。例如:
public boolean Contains(String str, String substr) {
if (str == null || substr == null) return false;
if (substr.equals("")) return true;
...
}
如果不用“保護語句”來實現這種函式將會很不自然。
想要單一出口點的一個動機是保證呼叫函式結尾的清理程式碼。但現代的程式語言為這種保證提供了更精細的方式:
語言 |
清理程式碼的結構化術語 |
C++ |
解構函式 |
Java、Python |
try finally |
Python |
with |
C# |
using |
在單純由C語言組成的程式碼中,當函式退出時沒有任何機制來觸發特定的程式碼。因此,如果一個大函式有很多清理程式碼,提前返回可能很難做得沒有問題。在這種情況下,其他的選擇包括重構函式,甚至慎重地使用goto cleanup;。
臭名昭著的goto
除了C語言之外,其他語言一般不大需要goto,因為有太多更好的方式能完成同樣的工作。同時goto也因為草草了事使程式碼難以理解而聲名狼藉。
但是你還是會在各種C專案中見到對goto的使用,最值得注意的就是Linux核心。在你認定所有對goto的使用都是一種褻讀之前,仔細研究為什麼某些對goto的使用比其他更好將會大有幫助。
對goto最簡單、最單純的使用就是在函式結尾有單個exit:
if (p == NULL) goto exit;
...
exit:
fclose(filei);
fclose(file2);
...
return;
如果只允許出現這一種goto的形式,goto不會成為什麼大問題。
當有多個goto的目標時可能就會有問題了,尤其當這些路徑交叉時。需要特別指出的是,向前goto可能會產生真正的義大利麵條式程式碼,並且它們肯定可以被結構化的迴圈替代。大多數時候都應該避免使用goto。
最小化巢狀
巢狀很深的程式碼很難以理解。每個巢狀層次都在讀者的“思維棧”上又增加了一個條件。當讀者見到一個右大括號時,可能很難“出棧”來回憶起它背後的條件是什麼。
下面是一個相對簡單的例子——當你回頭複查你在讀的是哪一個條件語句塊時,你是否能注意到你自己:
if (user_result == SUCCESS) {
if (permission_result != SUCCESS) {
reply.WriteErrors("error reading permissions");
reply.Done();
return;
}
reply.WriteErrors("");
} else {
reply.WriteErrors(user_result);
}
reply.Done();
當你看到第一個右大括號時,你不得不去想:“哦,permission_result != SUCCESS剛剛結束,那麼現在是在permission_result == SUCCESS之中了,並且還是在user_result == SUCCESS語句塊中。”
總之,你不得不始終記得user_result和permission_result的值。並且當每個if{}塊結束後你都不得不切換你腦海中的值。
上例中的程式碼尤其不好,因為它不斷地切換SUCCESS和non-SUCCESS的條件。
巢狀是如何累積而成的
在我們修正前面的示例程式碼之前,先來看看是什麼導致它成了現在的樣子。一開始,程式碼是很簡單的:
if (user_result == SUCCESS) {
reply.WriteErrors("");
} else {
reply.MriteErrors(user_result);
}
reply.Done();
這段程式碼很容易理解——它找出該寫什麼錯誤資訊,然後回覆並結束。
但是後來那個程式設計師增加了第二個操作:
if (user_result == SUCCESS) {
if (permission_result != SUCCESS) {
reply.WriteErrors("error reading permissions");
reply.Done();
return;
}
...
這個改動有合理的地方——該程式設計師要插入一段新程式碼,並且她找到了最容易插入的地方。對於她來講,新程式碼很整潔,而且很明確。這個改動的差異也很清晰——這看上去像是個簡單的改動。
但是以後當其他人遇到這段程式碼時,所有的上下文早已不在了。這就是你在本節一開始讀到這段程式碼時的情況,你不得不一下子全盤接受它。
關鍵思想:當你對程式碼做改動時,從全新的角度審視它,把它作為一個整體來看待。
通過提早返回來減少巢狀
好的,那麼讓我們來改進這段程式碼。像這種巢狀可以通過馬上處理“失敗情況”並從函式早返回來減少:
if (user_result != SUCCESS) {
reply.WriteErrors(user_result);
reply.Done();
return;
}
if (perfflission_result != SUCCESS) {
reply.WriteErrors(permission_result);
reply.Done();
return;
}
reply.WriteErrors ("");
reply.Done();
上面這段程式碼只有一層巢狀,而不是兩層。但更重要的是,讀者不再需要從思維堆疊裡“出棧”了——每個if塊都以一個return結束。
減少迴圈內的巢狀
提早返回這個技術並不總是合適的。例如,下面程式碼在迴圈中有巢狀:
for (int i = 0; i < results.size(); i++) {
if (results[i] != NULL) {
non_null_count++;
if (resuIts[i]->name != "") {
cout << "Considering candidate..." << endl;
...
}
}
}
在迴圈中,與提早返回類似的技術是continue:
for (int i = 0; i < results.size(); i++) {
if (results[i] == NULL) continue;
non_null_count++;
if (results[i]->name == "") continue;
cout << "Considering candidate..." << endl;
...
}
與if(...) return;在函式中所扮演的保護語句一樣,這些if(...) continue;語句是迴圈中的保護語句。
一般來講,continue語句讓人很困惑,因為它讓讀者不能連續地閱讀,就像迴圈中有goto語句一樣。但是在這種情況中,迴圈中的每個迭代是相互獨立的(這是一種“for each”迴圈),因此讀者可以很容易地領悟到這裡continue的意思就是“跳過該項”。
你能理解執行的流程嗎
本章介紹低層次控制流:如何把迴圈、條件和其他跳轉寫得簡單易讀。但是你也應該從高層次來考慮程式的“流動”。理想的情況是,整個程式的執行路徑都很容易理解——從main開始,然後在腦海中一步步執行程式碼,一個函式呼叫另一個函式,直到程式結束。
然而在實踐中,程式語言和庫的結構讓程式碼在“幕後”執行,或者讓流程難以理解。下面是一些例子:
程式設計結構 |
高層次程式流程是如何變得不清晰的 |
執行緒 |
不淸楚什麼時間執行什麼程式碼 |
訊號量/中斷處理程式 |
有些程式碼隨時都有可能執行 |
異常 |
可能會從多個函式呼叫中向上冒泡一樣地執行 |
函式指標和匿名函式 |
很難知道到底會執行什麼程式碼,因為在編譯時還沒有決定 |
虛方法 |
object.virtualMethod()可能會呼叫一個未知子類的程式碼 |
這些結構中有些很有用,它們甚至可以讓你的程式碼更具可讀性,並且冗餘更少。但是作為程式設計師,有時候我們得意忘形了,於是用得太多了,卻沒有發現以後它會多麼令人難以理解。並且,這些結構使得更難以跟蹤bug。
關鍵是不要讓程式碼中使用這些結構的比例太高。如果你濫用這些功能,它可能會讓跟蹤程式碼像三牌賭博遊戲(像卡通畫中一樣)。
總結
有幾種方法可以讓程式碼的控制流更易讀。
在寫一個比較時(while (bytes_expected > bytes_received)),把改變的值寫在左邊並且把更穩定的值寫在右邊更好一些(while (bytes_received < bytes_expected))。
你也可以重新排列if/else語句中的語句塊。通常來講,先處理正確的/簡單的/有趣的情況。有時這些準則會衝突,但是當不衝突時,這是要遵循的經驗法則。
某些程式設計結構,像三目運算子、do/while迴圈,以及goto經常會導致程式碼的可讀性變差。最好不要使用它們,因為總是有更整潔的代替方式。
巢狀的程式碼塊需要更加集中精力去理解。每層新的巢狀都需要讀者把更多的上下文“壓入棧”。應該把它們改寫成更加“線性”的程式碼來避免深巢狀。
通常來講提早返回可以減少巢狀並讓程式碼整潔。“保護語句”(在函式頂部處理簡單的情況時)尤其有用。
第8章拆分超長的表示式
巨型烏賊是一種神奇而又聰明的動物,但它近乎完美的身體設計有一個致命的弱點:在它的食管附近圍繞著圓環形的大腦。所以如果它一次吞太多的食物,它的大腦會受到傷害。
這和程式碼有什麼關係?嗯,大段大段的程式碼也可能會造成類似的效果。最近有研究表明,我們大多數人同時只能考慮3~4件“事情”。簡單地說,程式碼中的表示式越長,它就越難以理解。
關鍵思想:把你的超長表示式拆分成更容易理解的小塊。
在本章中,我們會看看各種可以操作和拆分程式碼以使它們更容易理解的方法。
用做解釋的變數
拆分表示式最簡單的方法就是引入一個額外的變數,讓它來表示一個小一點的子表示式。這個額外的變數有時叫做“解釋變數”,因為它可以幫助解釋子表示式的含義。
下面是一個例子:
if line.split(': ')[0].strip() == "root":
...
下面是和上面同樣的程式碼,但是現在有了一個解釋變數。
username = line.split(':')[o].strip()
if userna^e == "root":
...
總結變數
即使一個表示式不需要解釋(因為你可以看出它的含義),把它裝入一個新變數中仍然有用。我們把它叫做總結變數,它的目的只是用一個短很多的名字來代替一大塊程式碼,這個名字會更容易管理和思考。
例如,看看下面程式碼中的表示式。
if (request.user.id == document.owner_id) {
// user can edit this document...
}
...
if (request.user.id U document.OMner_id) {
// document is read-only...
}
這裡的表示式equest.user.id == document.owner_id看上去可能並不長,但它包含5個變數,所以需要多花點時間來想一想如何處理它。
這段程式碼中的主要概念是:“該使用者擁有此文件嗎?”這個概念可以通過增加一個總結變數來表達得更清楚。
final boolean user_owns_document = (request.user.id == document.owner_id);
if (user_owns_document) {
// user can edit this document...
}
...
if (!user_owns_document) {
// document is read-only...
}
上面的程式碼看上去改動並不大,但語句if (user_owns_document)更容易理解一些。並且,在一開始就定義了user_owns_document,用於提前告訴讀者“這是在整個函式中都會引用的一個概念”。
使用德摩根定理
如果你學過“電路”或者“邏輯”課,你應該還記得德摩根定理。對於一個布林表示式,有兩種等價的寫法:
1. not (a or b or c) <=> (not a) and (not b) and (not c)
2. not (a and b and c) <=> (not a) or (not b) or (not c)
如果你記不住這兩條定理,一個簡單的小結是“分別取反,轉換與/或”(反向操作是“提出取反因子”)。
有時,你可以使用這些法則來讓布林表示式更具可讀性。例如,如果你的程式碼是這樣的:
if (!(file_exists && !is_rotected)) Error("Sorry, could not read file.");
那麼可以把它改寫成:
if (!file_exists || is_protected) Error("Sorry, could not read file.");
濫用短路邏輯
在很多程式語言中,布林操作會做短路計算。例如,語句if(a || b)在a為真時不會計算b。使用這種行為很方便,但有時可能會被濫用以實現複雜邏輯。
下面例子中的語句當初是由某一位作者寫的:
assert((!(bucket = FindBucket(key))) || !bucket->IsOccupied());
用英語來講,這段程式碼是在說:“得到key的bucket。如果這個bucket不是空,那麼確定它是不是已經被佔用。”
儘管它只有一行程式碼,但是它的確要讓大多數程式設計師停下來想一想才行。現在和下面的程式碼比一比:
bucket = FindBucket(key);
if (bucket != NULL) assert(!bucket->IsOccupied());
它做的事情完全一樣,儘管它有兩行程式碼,但它要容易理解得多。
那麼無論如何為什麼要把程式碼寫在一個巨大的表示式裡呢?在當時,它看上去很智慧。把邏輯解析成一小段簡明的碼段。這可以理解——這就像在猜一個小小的謎,我們都想讓工作有樂趣。問題是這種程式碼對於任何讀它的人來講都是個思維上的減速帶。
關鍵思想:要小心“智慧”的小程式碼段——它們往往在以後會讓別人讀起來感到困惑。
這是否意味著你應該避免利用這種短路行為?不是的。在很多情況下可以用它達到整潔的目的,例如:
if (object && object->method()) ...
還有一個比較新的習慣用法值得一提:在像Python、JavaScript以及Ruby這樣的語言中,“or”操作符會返回其中一個引數(它不會轉換成布林值),所以這樣的程式碼:
x = a || b || c 可以用來從a、b或c中找出第一個為“真”的值。
例子:與複雜的邏輯戰鬥
假設你在實現下面這個的Range類:
struct Range {
int begin;
int end;
// For example, [0,5) overlaps with [3,8)
bool OverlapsWith(Range other);
};
下圖給出一些範圍的例子:
請注意終點是非包含的。因此A、B和C互相之間不會有重複,但是D與所有其他重複。
下面是對OverlapsWith()實現的一個嘗試——它檢査是否自身範圍的任意一個端點在other的範圍之內:
bool Range::OvexlapsWith(Range other) {
// Check if 'begin' or 'end' falls inside 'other'.
return (begin >= other.begin && begin <= other.end) ||
(end >= other.begin && end <= other.end);
}
儘管只有兩行程式碼,但是裡面包含很多東西。下圖給出其中所有的邏輯。
這裡面有太多的情況和條件要去考慮,這很容易滋生bug。
說到這兒,這裡還真有一個bug。前面的程式碼會認為Range[0, 2)與Range[2, 4)重複,而實際上它們並不重複。
這裡的問題是在比較begin/end值時要小心地使用<=或<。下面是對這個問題的修正:
return (begin >= other.begin && begin < other.end) ||
(end > other.begin && end <= other.end);
現在已經改正了,是嗎?實際上,還有另一個bug。這段程式碼忽略了begin/end完全包含other的情況。
下面是處理這種情況的修改:
return (begin >= other.begin && begin < other.end) ||
(end > other.begin && end <= other.end) ||
(begin <= other.begin && end >= other.end);
現在程式碼變得太複雜了。你不可能指望別人看了這段程式碼就對它的正確性有信心。那麼我們該怎麼辦?怎麼拆分這個大的表示式呢?
找到更優雅的方式
這就是那種你該停下來從整體上考慮不同方式的時機之一。開始還很簡單的問題(檢査兩個範圍是否重疊)變得非常令人費解。這通常預示著肯定有一種更簡單的方法。
但是找到更優雅的方式需要創造力。那麼怎麼做呢?一種技術是看看能否從“反方向”解決問題。根據你所處的不同情形,這可能意味著反向遍歷陣列,或者往回填充資料結構而非向前。
在這裡,OverlapsWith()的反方向是“不重疊”。判斷兩個範圍是否不重疊原來更簡單,因為只有兩種可能:
1. 另一個範圍在這個範圍開始前結束。
2. 另一個範圍在這個範圍結束後開始。
我們可以很容易地把它變成程式碼:
bool Range::OverlapsWith(Range other) {
if (other.end <= begin) return false; // They end before we begin
if (other.begin >= end) return false; // They begin after we end
return true; // Only possibility left: they overlap
}
這裡的每一行程式碼都要簡單得多——每行只有一個比較。這就使得讀者留有足夠的心力來關注<=是否正確。
拆分巨大的語句
本章是關於拆分獨立的表示式的,但同樣的技術也可以用來拆分大的語句。例如,下面的JavaScript程式碼需要一次讀很多東西:
var update_highlight = function (message_num) {
if ($("#vote_value" + message_num).html() === "Up") {
$("#thumbs_up" + message_num) . addClass( "highlighted");
$("#thumbs_down" + message_num).removeClass("highlighted");
} else if ($("#vote_value" + message_num).html() === "Down") {
$("#thumbs_up" + message_num).removeClass("highlighted");
$("#thumbs_down" + message_num).addClass("highlighted");
} else {
$("#thumbs_up" + message_num).removeClass("highighted");
$("#thumbs_downn + message_num).removeClass("highlighted");
}
};
程式碼中的每個表示式並不是很長,但當把它們放在一起時,它們就形成了一條巨大的語句,迎面撲來。
幸運的是,其中很多表示式是一樣的,這意味著可以把它們提取出來作為函式開頭的總結變數(這同時也是一個DRY——Don't Repeat Yourself的例子):
var update_highlight = function (message__nujn) {
var thumbs_up = $("#thumbs_up" + message_num);
var thuwbs_domn = $("#thumbs_down" + message_num);
var vote_value = $("#vote_vaIue" + message_num). html();
var hi = "highlighted";
if (vote_value === "Up") {
thunbs_up.addClass(hi);
thumbs_down.removeClass(hi);
} else if (vote_value === "Down") {
thumbs_up.removeClass(hi);
thumbs_down.addClass(hi);
} else {
thumbs_up.removeClass(hi);
thumbs_down.removeClass(hi);
}
};
建立var hi = "highlighted";嚴格來講不是必需的,但鑑於這裡有6次重複,有很多好處驅使我們這樣做:
l 它幫助避免錄入錯誤。(實際上,你是否注意到在第一個例子中,該字串在第5 種情況中被誤寫成"highhighted"?)
l 它進一步縮短了行的寬度,使程式碼更容易快速閱讀。
l 如果類的名字需要改變,只需要改一個地方即可。
另一個簡化表示式的創意方法
下面是另一個例子,同樣在每個表示式中都包括了很多東西,這次是用C++寫的:
void AddStats(const Stats& add_from, Stats* add_to) {
add_to->set_total_memory(add_from.total_memory() + add_to->total_memory());
add_to->set_free_memory(add_frora.free_memory() + add_to->free_memory());
add_to->set_swap_memory(add_from.swap_memory() + add_to->swap_memoryQ);
add_to->set_status_string(add_from.status_string() + add_to->status_string());
add_to->set_num_processes(add_frora.num_processes() + add_to->num_processes());
...
}
再一次,你的眼睛要面對又長又相似的程式碼,但不是完全一樣。在仔細檢查了10秒後,你想必會發現每一行都在做同樣的事,只是每次新增的欄位不同:
add_to->set_XXX(add_from.XXX() + add_to->XXX());
在C++中,可以定義一個巨集來實現它:
void AddStats(const Stats& add_from, Stats* add_to) {
#define ADD_FIELD(field) add_to->set_##field(add_from.field() + add_to->field())
ADD_FIELD(total_memory);
ADD_FIELD(free_memory);
ADD_FIELD(swap_memory);
ADD_FIELD(status_string);
ADD_FIELD(num_processes);
...
#undef ADD_FIELD
}
現在我們化繁為簡,你可以看一眼程式碼就馬上理解大致的意思。很明顯,每一行都在做同樣的事情。
請注意,我們不鼓吹經常使用巨集——事實上,我們通常避免使用巨集,因為它們會讓程式碼變得令人困惑並且引入細微的bug。但有時,就像在本例中,它們很簡單而且對可讀性有明顯的好處。
總結
很難思考巨大的表示式。本章給出了幾種拆分表示式的方法,以便讀者可以一段一段地消化。
一個簡單的技術是引入“解釋變數”來代表較長的子表示式。這種方式有三個好處:
l 它把巨大的表示式拆成小段。
l 它通過用簡單的名字描述子表示式來讓程式碼文件化。
l 它幫助讀者識別程式碼中的主要概念。
另一個技術是用德摩根定理來操作邏輯表示式——這個技術有時可以把布林表示式用更整潔的方式重寫(例如if(!(a && !b))變成if(!a || b))。
本章給出了一個,把一個複雜的邏輯條件拆分成小的語句的例子,就像“if(a < b) ...”。實際上,在本章所有改進過的示例程式碼中,所有的if語句內都沒有超過兩個值。這是理想情況。可能不是總能做到這樣——有時需要把問題“反向”或者考慮目標的對立面。
最後,儘管本章是關於拆分獨立的表示式的,同樣,這些技術也常應用於大的程式碼塊。所以,你可以在任何見到複雜邏輯的地方大膽地去拆分它們。
第9章變數與可讀性
在本章裡,你會看到對於變數的草率運用如何讓程式更難理解。確切地說,我們會討論三個問題:
1. 變數越多,就越難全部跟蹤它們的動向。
2. 變數的作用域越大,就需要跟蹤它的動向越久。
3. 變數改變得越頻繁,就越難以跟蹤它的當前值。
下面三節討論如何處理這些問題。
減少變數
在第8章中,我們講了如何引入“解釋”或者“總結”變數來使程式碼更可讀。這些變數很有幫助是因為它們把巨大的表示式拆分開,並且可以作為某種形式的文件。
在本節中,我們感興趣的是減少不能改進可讀性的變數。當移除這種變貴後,新程式碼會更精練而且同樣容易理解。
在下一節中的幾個例子講述這些不必要的變數是如何出現的。
沒有價值的臨時變數
在下面的一小段Python碼中,考慮now這個變數:
now = datetime.datetime.now()
root_message.last_view_time = now
now是一個值得保留的變數嗎?不是,下面是原因:
l 它沒有拆分任何複雜的表示式。
l 它沒有做更多的澄清——表示式datetime.datetime.now()已經很淸楚了。
l 它只用過一次,因此它並沒有壓縮任何冗餘程式碼。
沒有了now,程式碼一樣容易理解。
root_message.last_view_time = datetime.datetime.now()
像now這樣的變數通常是在程式碼編輯過後的“剩餘物”。now這個變數可能從前在多個地方用到。或者可能那個程式設計師料想now會多次用到,但實際上再沒用到過它。
減少中間結果
下面的例子是一個JavaScript函式,用來從陣列中刪除一個值:
var remove_one = function (array, value_to_remove) {
var index_to_remove = null;
for (var i = 0; i < array.length; i++ ) {
if (array[i] === value to_remove) {
index_to_remove = i;
break;
}
}
if (index_to_remove !== null) {
array.splice(index_to_rewove, 1);
}
}
變數index_to_remove只是用來儲存臨時結果。有時這種變數可以通過得到後立即處理它而消除。
var remove_one = function (array, value_to_remove) {
for (var i = 0; i < array.length; i++ ) {
if (array[i] === value to_remove) {
array.splice(i, 1);
return;
}
}
}
通過讓程式碼提前返回,我們不再需要index_to_remove,並且大幅簡化了程式碼。
通常來講,“速戰速決”是一個好的策略。
減少控制流變數
有些時候你會在程式碼的迴圈中見到如下模式:
boolean done = false;
while (/* condition */ && !done) {
...
if (...) {
done = true;
continue;
}
}
甚至可以在迴圈裡多處把變數done設定為true。
這樣的程式碼通常是為了滿足某些心照不宣的規則,即你不該從迴圈中間跳出去。根本就沒有這樣的規則!
像done這樣的變數,稱為“控制流變數”。它們唯一的目的就是控制程式的執行——它們沒有包含任何程式的資料。在我們的經驗中,控制流變數通常可以通過更好地運用結構化程式設計而消除。
while (/* condition */) {
break;
}
}
這個例子改起來很簡單,但是如果有多個巢狀迴圈,一個簡單的break根本不夠怎辦呢?在這種更復雜的情況下,解決方案通常包括把程式碼挪到一個新函式中(要麼是迴圈中的程式碼,要麼是整個迴圈)
你希望你的同事隨時都覺得是在面試嗎?
來自微軟的Eric Brechner曾說過一個好的面試問題起碼要涉及三個變數(《程式碼之道》《Hard Code》,由機械工業出版社引進並出版,作者Eric Brechner)。可能是因為同時處理三個變數會強迫你努力思考!這對於面試來講還說得過去,因為你要嘗試找到候選人的極限。但是你希望你的同事在讀你的程式碼時感覺就像你在面試他們嗎?
縮小變數的作用域
我們都聽過“避免全域性變數”這條建議。這是一條好的建議,因為很難跟蹤這些全域性變最在哪裡以及如何使用它們。並且通過“名稱空間汙染”(名字太多容易與區域性變數衝突),程式碼可能會意外地改變全域性變數的值,雖然本來的目的是使用區域性變數,或者反過來也有同樣的效果。
實際上,讓所有的變數都“縮小作用域”是一個好主意,並非只是針對全域性變數。
關鍵思想:讓你的變數對儘量少的程式碼行可見.
很多程式語言提供了多重作用域/訪問級別,包括模組、類、函式以及語句塊作用域。通常越嚴格的訪問控制越好,因為這意味著該變數對更少的程式碼行“可見”。
為什麼要這麼做?因為這樣有效地減少了讀者同時需要考慮的變數個數。如果你能把所有的變數作用域都減半,那麼這就意味著同時需要思考的變數個數平均來講是原來的一半。
例如,假設你有一個很大的類,其中有一個成員變數只由兩個方法用到,使用方式如下:
class LargeClass {
string str_;
void Methodl() {
str_ = …;
Method2();
}
void Method2() {
// Uses str_
}
// Lots of other methods that don't use str_ ...
};
從某種意義上來講,類的成員變數就像是在該類的內部世界中的“小型全域性變數”。尤其對大的類來講,很難跟蹤所有的成員變數以及哪個方法修改了哪個變數。這樣的小型全域性變數越少越好。
在本例中,最好把str_“降格”為區域性變數:
class LargeClass {
void Methodl() {
string str = …;
Method2(str);
}
void Method2(string str) {
// Uses str
}
// Now other method can't see str.
};
另一個對類成員訪問進行約束的方法是“儘量使方法變成靜態的”。靜態方法是讓讀者知道“這幾行程式碼與那些變數無關”的好辦法。
或者還有一種方式是“把大的類拆分成小一些的類”。這種方法只有在這些小一些的類事實上相互獨立時才能發揮作用。如果你只是建立兩個類來互相訪問對方的成員,那你什麼目的也沒達到。
把大檔案拆分成小檔案,或者把大函式拆分成小函式也是同樣的道理。這麼做的一個重要的動機就是資料(即變數)分離。
但是不同的語言有不同的管理作用域的規則。我們接下來給出一些與變數作用域相關的更有趣規則。
c++中if語句的作用域
假設你有以下C++程式碼:
PaymentInfo* info = database.ReadPaymentInfo();
if (info) {
cout << "User paid: " << info->amount() << endl;
}
// Many more lines of code below …
變數info在此函式的餘下部分仍在作用域內,因此,讀這段程式碼的人要始終記得它,猜測它是否或者怎樣再次用到。
但是在本例中,info只有在if語句中才用到。在C++語言中,我們實際上可以把info定義在條件表示式中:
if (PaymentInfo* info = database.ReadPaymentInfo()) {
cout << "User paid: " << info->amount() << endl;
}
現在讀者可以在info超出作用域後放心地忘掉它了。
在JavaScript中建立“私有”變數
假設你有一個長期存在的變數,只有一個函式會用到它:
submitted = false; // Note: global variable
var submit_form = function (form_name) {
if (submitted) {
return; // don't double-submit the form
}
...
submitted = true;
};
像submitted這種全域性變數會讓讀程式碼的人非常不安。看上去好像只有submit_form()使用submitted,但你就是沒辦法確定。實際上,另一個JavaScript檔案可能也在用一個叫submitted的全域性變數,卻不是為了同一個目的!
你可以把submitted放在一個“閉包”中來避免這個問題:
var submit_form = (function () {
var submitted = false; // Note: can only be accessed by the function below
return function (form_name) {
if (submitted) {
return; // don't double-submit the form
}
...
submitted = true;
}());
請注意在最後一行上的圓括號——它會使外層的這個匿名函式立即執行,返回內層的函式。
如果你以前沒見過這種技巧,可能一開始它看上去有些怪。它的效果是營造一個“私有”作用域,只有內層函式才能訪問。現在讀者不必再去猜“submitted還在什麼地方用到了?”或者擔心與其他同名的全域性變數衝突。(這方面的更多技巧,參見《JavaScript: The Good Parts》,原作者Douglas Crockford [O’Reilly,2008])。
JavaScript全域性作用域
在JavaScript中,如果你在變數定義中省略var關鍵字,這個變數會放在全域性作用域中,所有的JavaScript檔案和<script>塊都可以訪問它。下面是一個例子:
<script>
var f = function () {
// DANGER: 'i' is not declared with 'var'!
for (i = 0; i < 10; i += 1) ...
}
</script>
這段程式碼不慎把i放在了全域性作用域中,那麼以後的程式碼塊也能看到它::
<script>
alert(i); // Alerts '10'. 'i' is a global variableI
</script>
很多程式設計師沒有注意到這個作用域規則,這個令人吃驚的行為可以產生奇怪的bug。這種bug的一個共同形式是,當兩個函式都建立了有相同名字的局布變數時,忘記了使用var。這些函式會在背地裡“交談”,然後可憐的程式設計師可能會認為他的計算機瘋了或者RAM壞了。
對於JavaScript通用的“最佳實踐”是“總是用var關鍵宇來定義變數”。這個方法把變數的作用域約束在定義它的(最內層)函式之中。
在Python和JavaScrit中沒有巢狀的作用域
像C++和Java這樣的語言有“語句塊作用域”,定義在if、for、try或者類似結構中的變數被限制在這個語句塊的巢狀作用域裡。
if (...) {
int x = 1;
}
x++; // Compile-error! 'x' is undefined.
但是在Python和JavaScript中,在語句塊中定義的變數會“溢位”到整個函式。例如,請注意在下面這段完全正確的Python程式碼中對example_value的使用:
# No use of example_value up to this point.
if request:
for value in request.values:
if value > 0:
example_value = value
break
for logger in debug.loggers:
logger.log("Example:", example_value)
這條作用域規則讓很多程式設計師感到意外,並且寫成這樣的程式碼也很難讀。在其他語言中,可能更容易找到example_value最初是在哪裡定義的——你只要沿著你所在的函式“左手邊”一路找下去就可以了。
前面的例子同時也有錯誤:如果在程式碼的第一部分中沒有設定example_value,那麼第二部分會產生異常:“NameError: 'example_value' is not defined”。我們可以改正它並讓程式碼更可讀,把example_value的定義移到它與使用點的“最近共同前輩”(就巢狀而言)處就可以了:
exanple_value = None
if request:
for value in request.values:
if value > 0:
example_value = value
break
for logger in debug.loggers:
logger.log("Example:", example_value)
然而,在這個例子中其實exanple_value完全可以不要。exanple_value只儲存一箇中間結果,如第9章所述,這種變數可以通過“儘早完成任務”來消除。在這個例子中,這意味著在找到exanple_value時馬上給它寫日誌。
下面是修改過的新程式碼:
def LogExample(value):
for logger in debug.loggers:
logger.log("Example:", value)
if request:
for value in request.values:
if value > 0:
LogExample(value) # deal with 'value' immediately
break
把定義向下移
原來的C語言要求把所有的變數定義放在函式或語句塊的頂端。這個要求很令人遺憾,因為對於有很多變數的函式,它強迫讀者馬上思考所有這些變數,即使是要到很久之後才會用到它們。(C99和C++去掉了這個要求。)在下面的例子中,所有的變數都無辜地定義在函式的頂部:
def ViewFilteredReplies(original_id):
filtered_replies =[]
root_message = Messages.objects.get(originaX_id)
all_replies = Messages.objects.select(root_id=original_id)
root_message.view_count += 1
root_message.last_view_time = datetime.datetime.now()
root_message.save()
for reply in all_replies:
if reply.spaiii_votes <= MAX_SPAM_VOTES:
filtered_replies.append(reply)
return filtered_replies
這段示例程式碼的問題是它強迫讀者同時考慮3個變數,並且在它們間不斷切換。
因為讀者在讀到後面之前不需要知道所有變數,所以可以簡單地把每個定義移到對它的使用之前:
def ViewFilteredReplies(originaljLd):
root_message = Messages.objects.get(original_id)
root__message. view_count += 1
root_message.last_viewtime = datetime. datetime. now()
rootjnessage.save()
all_replies = Messages.objects.select(root_id = orislnal_id)
filtered_replies =[]
for reply in all_replies:
if reply.spam_votes <= MAX_SPAM_VOTES:
filtered_replies.append(reply)
return filtered_replies
你可能會想到底all_replies是不是個必要的變數,或者這麼做是不是可以消除它:
for reply in Messages.objects.select(root_id = original_id):
...
在本例中,all_replies是一個相當好的解釋,所以我們決定留下它。
只寫一次的變數更好
到目前為止,本章討論了很多變數參與“整個遊戲”是怎樣導致難以理解的程式的。不斷變化的變數更難讓人理解。跟蹤這種變數的值更有難度。
要解決這種問題,我們有一個聽起來怪怪的建議:只寫一次的變數更好。
“永久固定”的變數更容易思考。當前,像這種常量:
static const int NUM_THREADS = 10;
不需要讀者思考很多。基於同樣的原因,鼓勵在C++中使用const(在Java中使用final)。
實際上,在很多語言中(包括Python和Java),一些內建型別(如string)是不可變的。如James Gosling(Java的創造者)所說:“(常量)往往不會引來麻煩。”
但是就算你不能讓變數只寫一次,讓變數在較少的地方改動仍有幫助。
那麼怎麼做呢?能把一個變數改成只寫一次嗎?很多時間這需要修改程式碼的結構,就像你將在下面的例子中所見到的那樣。
最後的例子
作為本章最後一個例子,我們要給出一個能演示目前為止我們所討論過的多條原則的例子。
假設你有一個網頁,上面有幾個文字輸入欄位,佈置如下:
<input type="text" id="input1" value="Dustin">
<input type="text" id="input2" value*"Trevor">
<input type="text" id="input3" value="">
<input type="text" id="input4*1 value="Melissa">
如你所見,id從input1開始增加。
你的工作是寫一個叫setFirstEmptyInput()的函式,它接受一個字串並把它放在頁面上第一個空的<input>欄位中(在給出的示例中是“input3”)。這個函式應當返回已更新的那個DOM元素(如果沒有剩下任何空欄位則返回null)。下面是完成這項工作的程式碼,它沒有遵守本章中的原則:
var setFirstEmptyInput = function (new_value) {
var found = false;
var i = 1;
var elem = docunent.getElementById("input' + i);
while (elem !== null) {
if (elem.value === "") {
found = true;
break;
}
i++;
elem = document.getElementById('input' + i);
}
if (found) elem.value = new_value;
return elem;
這段程式碼可以完成工作,但看上去並不完美。什麼地方不對?如何改進?
有很多途徑可以用來思考對這段程式碼的改進,但我們會從它所使用的變數這個角度開始考慮:
l var found
l var i
l var elem
所有三個變數都存在於整個函式中,並且寫入了多次。讓我們來嘗試改進它們中的每一個。
如我們在本章前面討論過的,中間變數(如found)通常可以通過提前返回來消除。下面是這一點改進:
var setFirstEmptyInput = function (new_value) {
var i = 1;
var elem = docunent.getElementById("input' + i);
while (elem !== null) {
if (elem.value === "") {
elem.value = new_value;
return elem;
}
i++;
elem = document.getElementById('input' + i);
}
return null;
};
接下來,看一下elem。它在整個程式碼中以迴圈的方式多次用到,這讓我們很難跟蹤它的值。這段程式碼讓我們覺得elem就是在迭代的值,實際上只是在累加1。所以把while迴圈重寫成對i的for迴圈。
var setFirstEmptyInput = function (new_value) {
for (var i = 1; true; i++) {
var elem = docunent.getElementById("input' + i);
if(elem == null)
return null; // Search Faild. No empty input found.
if (elem.value === "") {
elem.value = new_value;
return elem;
}
}
};
特別地,請注意elem是如何成為一個只寫一次的變數的,它的生命週期只在迴圈內。用true來作為for迴圈的條件並不多見,但作為交換,我們可以在同一行裡看到i的定義與修改。(傳統的while(true)也是個合理的選擇。)
總結
本章是關於程式中的變數是如何快速累積而變得難以跟蹤的。你可以通過減少變數的數量和讓它們盡景“輕量級”來讓程式碼更有可讀性。具體有:
l 減少變數,即那些妨礙的變數。我們給出了幾個例子來演示如何通過立刻處理結果來消除“中間結果”變數。
l 減小每個變數的作用域,越小越好。把變數移到一個有最少程式碼可以看到它的地方。眼不見,心不煩。
l 只寫一次的變數更好。那些只設定一次值的變數(或者const、final、常量)使得程式碼更容易理解。
第三部分重新組織程式碼
第二部分討論瞭如何改變程式的“迴圈與邏輯”來讓程式碼更有可讀性。我們描述了幾種技巧,這些技巧都需要對程式碼結構做出微小的改動。
該部分會討論可以在函式級別對程式碼做的更大的改動。具體來講,我們會講到三種組織程式碼的方法:
l 抽取出那些與程式主要目的“不相關的子問題”。
l 重新組織程式碼使它一次只做一件事情。
l 先用自然語言描述程式碼,然後用這個描述來幫助你找到更整潔的解決方案。
最後,我們會討論你可以把程式碼完全移除或者一開始就避免寫它的那些情況——唯一可稱為改進程式碼可讀性的最佳方法。
第10章抽取不相關的子問題
所謂工程學就是關於把大問題拆分成小問題再把這些問題的解決方案放回一起。把這條原則應用於程式碼會使程式碼更健壯並且更容易讀。
本章的建議是“積極地發現並抽取出不相關的子邏輯”。我們是指:
l 看看某個函式或程式碼塊,問問你自己:這段程式碼高層次的目標是什麼?
l 對於每一行程式碼,問一下:它是直接為了目標而工作嗎?這段程式碼高層次的目標是什麼呢?
l 如果足夠的行數在解決不相關的子問題,抽取程式碼到獨立的函式中。
你每天可能都會把程式碼抽取到單獨的函式中。但在本章中,我們決定關注抽取的一個特別情形:不相關的子問題,在這種情形下抽取出的函式無憂無慮,並不關心為什麼會呼叫它。
你將會看到,這是個簡單的技巧卻可以從根本上改進你的程式碼。然而由於某些原因,很多程式設計師沒有充分使用這一技巧。這裡的訣竅就是主動地尋找那些不相關的子問題。
在本章中,我們會看到幾個不同的例子,它們針對你將遇到的不同情形來講明這些技巧。
介紹性的例子:findClosestLocation()
下面JavaScript程式碼的高層次目標是“找到距離給定點最近的位置”(請勿糾結於斜體部分所用到的高階幾何知識):
// Return which element of 'array' is closest to the given latitude/longitude.
// Models the Earth as a perfect sphere.
var findClosestLocation = function (lat, lng, anay) {
var closest;
var closest_dist = Nunber.MAX_VALUE;
for (var i = 0; i < array.length; i += 1) {
// Convert both points to radians.
var lat_rad = radians(lat);
var lng_rad = radians(lng);
var lat2_rad = radians (array[i].latitude);
var lng2_rad = radians(array[i].longitude);
// Use the "Spherical Law of Cosines" formula.
var dist = Math.acos(Math.sin(lat_rad) * Hath.sin(lat2_rad) +
Math.cos(lat_rad) * Math.cos(lat2_rad) *
Math.cos(lng2_rad - lng_rad));
if (dist < closest_dist) {
closest = array[i];
closest_dist = dist;
}
}
return closest;
};
迴圈中的大部分程式碼都旨在解決一個不相關的子問題:“計算兩個經緯座標點之間的球面距離”。因為這些程式碼太多了,把它們抽取到一個獨立的spherical_distance()函式是合理的:
var spherical_distance = function (lat1, lng1, lat2, lng2) {
var lat1_rad = radians(lat1);
var lng1_rad = radians(lng1);
var lat2_rad = radians(lat2);
var lng2_rad = radians(lng2);
// Use the "Spherical Law of Cosines" formula.
return Math.acos(Math.sin(lat1_rad) * Math.sin(lat2_rad) +
Math.cos(lat1_rad) * Math.cos(lat2_rad) *
Math.cos(lng2_rad - lngl_rad));
};
現在,剰下的程式碼變成了
var findClosestLocation = function (lat, lng, anay) {
var closest;
var closest_dist = Nunber.MAX_VALUE;
for (var i = 0; i < array.length; i += 1) {
var dist = spherical_distance(lat, lng, array[i].latitude, array[i].longitude);
if (dist < closest_dist) {
closest = array[i];
closest_dist = dist;
}
}
return closest;
};
這段程式碼的可讀性好得多,因為讀者可以關注於高層次目標,而不必因為複雜的幾何公式分心。
作為額外的獎勵,spherical_distance()很容易單獨做測試。並且spherical_distance()是那種在以後可以重用的函式。這就是為什麼它是一個“不相關”的子問題——它完全是自包含的,並不知道其他程式是如何使用它的。
純工具程式碼
有一組核心任務大多數程式都會做,例如操作字串、使用雜湊表以及讀/寫檔案。通常,這些“基本工具”是由程式語言中內建的庫來實現的。例如,如果你想讀取檔案的整個內容,在PHP中你可以呼叫file_get_contents("filename"),或者在Python中你可以用open("filename").read()。
但有時你要自己來填充這中間的空白。例如,在C++中,沒有簡單的方法來讀取整個檔案。取而代之的是你不可避免地要寫這樣的程式碼:
ifstream file(file_name);
// Calculate the file's size, and allocate a buffer of that size.
file.seekg(0, ios::end);
const int file_size = file.tellg();
char* file_buf = new char [filesize];
// Read the entire file into the buffer.
file.seekg(0, ios::beg);
file.read(file_buf, file_size);
file.close();
...
這是一個不相關子問題的經典例子,應該把它抽取到一個新的函式中,比如 ReadFileToString()。現在,你程式碼庫的其他部分可以當做C++語言中確實有ReadFileToString()這個函式。
通常來講,如果你在想:“我希望我們的庫裡有XYZ()函式”,那麼就寫一個!(如果它還不存在的話)經過一段時間,你會建立起一組不錯的工具程式碼,後者可以應用於多個專案。
其他多用途程式碼
當除錯JavaScript程式碼時,程式設計師經常使用alert()來彈出訊息框,把一些資訊顯示給他們看,這是Web版本的“printf()除錯”。例如,下面函式呼叫會用Ajax資料提交給伺服器,然後顯示從伺服器返回的字典。
ajax_post({
url: 'http://example.com/submit',
data: data,
onsuccess: function (response_data) {
var str = "{\n";
for (var key in response_data) {
str += " " + key + " = " + response data[key] + "\n";
}
alert(str + "}");
// Continue handling 'response_data'
}
});
這段程式碼的高層次目標是“對伺服器做Ajax呼叫,然後處理響應結果”。但是有很多程式碼都在處理不相關的子問題,也就是美化字典的輸出。把這段程式碼抽取到一個像format_pretty(obj)這樣的函式中很簡單:
var format_pretty = function (obj) {
var str = "{\n";
for (vax key in obj) {
str += " " + key + " = " + obj[key] + "\n";
}
return str + "}";
};
意料之外的好處
出於很多理由,抽取出format_pretty()是個好主意。它使得用程式碼更簡單,並且format_pretty()是一個很方便的函式。
但是還有一個不那麼明顯的重要理由:當format_prett()中的程式碼自成一體後改進它變得更容易。當你在使用一個獨立的小函式時,感覺新增功能、改進可讀性、處理邊界情況等都更容易。
下面是format_pretty(obj)無法處理的一些情況。
l 它期望obj是一個物件。如果它是個普通字串(或者undefined),那麼當前的程式碼會丟擲異常。
l 它期望obj的每個值都是簡單型別。否則如果它包含巢狀物件的話,當前程式碼會把它們顯示成[object Object],這並不是很漂亮。
在我們把format_pretty()拆分成自己的函式之前,感覺要做這些改進可能會需要大量的工作。(實際上,迭代地輸出巢狀物件在沒有獨立的函式時是很難的。)
但是現在增加這些功能就很簡單了。改進後的程式碼如下:
var format_pretty = function (obj, indent) {
// Handle null, undefined, strings, and non-objects
if(obj === null) return "null";
if(obj === undefined) return "undefined";
if(typeof obj == "string") return '"' + obj + '"';
if(typeof obj !== "object") return String(obj);
if(indent === undefined) indent = "";
// Handle (non-null) objects.
var str = "{\n";
for (vax key in obj) {
str += indent + " " + key + " = ";
str += format_pretty(obj[key], indent + " ") + "\n";
}
return str + indent + "}";
};
上面的程式碼把前面提到的不足都改正了,產生的輸出如下:
{
key1 = 1
key2 = true
key3 = undefined
key4 = null
key5 = {
key5a = {
key5a1 = "hello world"
}
}
}
建立大量通用程式碼
ReadFileToString()和format_pretty()這兩個函式是不相關子問題的好例子。它們是如此基本而廣泛適用,所以很可能會在多個專案中重用。程式碼庫常常有個專門的目錄來存放這種程式碼(例如util),這樣它們就很方便重用。
通用程式碼很好,因為“它完全地從專案的其他部分中解耦出來”。像這樣的程式碼容易開發,容易測試,並且容易理解。想象一下如果你所有的程式碼都如此會怎樣!
想一想你使用的眾多強大的庫和系統,如SQL資料庫、JavaScript庫和HTML模板系統。你不用操心它們的內部——那些程式碼與你的專案完全分離。其結果是,你專案的程式碼庫仍然較小。
從你的專案中拆分出越多的獨立庫越多越好,因為你程式碼的其他部分會更小而且更容易思考。
這是自頂向下或者自底向上式程式設計嗎?
自頂向下程式設計是一種風格,先設計高層次模組和函式,然後根據支援它們的需要來實現低層次函式。
自底向上程式設計嘗試首先預料和解決所有的子問題,然後用這些程式碼段來建立更高層次的元件。
本章並不鼓吹一種方法比另一種好。大多數程式設計都包括了兩者的組合。重要的是最終的結果:移除並單獨解決子問題。
專案專有的功能
在理想情況下,你所抽取出的子問題對專案一無所知。但是就算它們不是這樣,也沒有問題。分離子問題仍然可能創造奇蹟。
下面是一個商業評論網站的例子。這段Python程式碼建立一個新的Business物件並設定它的name、url和date_created。
business = Business()
business.name = request.POST["name"]
url_path_name = business.name.lower()
url_path_name = re.sub(r"['\.]", "”, url_path_name)
url_path_name = re.sub(r"[^a-zO-9]+", "-", url_path_name)
url_path_name = url_path_name.strip("-")
business.url = "/biz/" + url_path_name
business.date_created = datetime.datetine.utcnow()
business.save_to_database()
url應該是一個“乾淨”版本的name。例如,如果name是“A.C. Joe’s Tire & Smog, Inc.”,url就是“/biz/ac-joes-tire-smog-inc”。
這段程式碼中的不相關子問題是“把名字轉換成一個有效的URL”。這段程式碼很容易抽取。同時,我們還可以預先編譯正規表示式(並給它們以可讀的名字):
CHARS_TO_REMOVE = re.compile(r"['\.]+")
CHARS_TO_DASH = re.cofnpile(r"[^a-z0-9]+")
def make_url_friendly(text):
text = text.lower()
text = CHARS_TO_REMOVE.sub(", text)
text = CHARSJTO_DASH.sub('-', text)
return text.strip("-")
現在,原來的程式碼可以有更“常規”的外觀了:
business = Business()
business.name = request.POST["name"]
business.url = "/biz/" + make_url_friendly(business.name)
business.date_created = datetime.datetine.utcnow()
business.save_to_database()
讀這段程式碼所花的工夫要小得多,因為你不會被正規表示式和深層的字串操作分散精力。
你應該把make_url_friendly的程式碼放在哪裡呢?那看上去是相當通用的一個函式,因此把它放到一個單獨的util目錄中是合理的。另一方面,這些正規表示式是按照美國商業名稱的思路設計的,所以可能這段程式碼應該留在原來檔案中。這實際上並不重要,以後你可以很容易地把這個定義移到另一個地方。更重要的是make_url_friendly完全被抽取出來。
簡化已有介面
人人都愛提供整潔介面的庫——那種引數少,不需要很多設定並且通常只需要花一點工夫就可以使用的庫。它讓你的程式碼看起來優雅:簡單而又強大。
但如果你所用的介面並不整潔,你還是可以建立自己整潔的“包裝”函式。
例如,處理JavaScript瀏覽器中的cookie比理想情況糟糕很多。從概念上講,cookie是一組名/值對。但是瀏覽器提供的介面只提供了一個document.cookie字串,語法如下:
name1=value1; name2=value2; …
要找到你想要的cookie,你不得不自己解析這個巨大的字串。下面的例子程式碼用來讀取名為“max_results”的cookie的值。
var max_results;
var cookies = document.cookie.split(';');
for (var i = 0; i < cookies.length; i++) {
var c = cookies[i];
c = c.replace(/^[ ]+/, ''); // remove leading spaces
if (c.indexOf("max_results=") === 0)
max_results = Number(c.substring(12, c.length));
}
這段程式碼可真難看。很明顯,它等著我們建立一個get_cookie()函式,這樣我們就只需要寫:
var maxresults = Number(get_cookie("max_results"));
建立或者改變一個cookie的值更奇怪。你得把document.cookie設定為一個必需嚴格滿足下面語法的值:
document.cookie = "max_results=50; expires=Wed, 1 ]an 2020 20:53:47 UTC; path=/";
這條語句看上去像是它會重寫所有其他的已有cookie,但是(魔術般地)它沒有!
設定cookie更理想的介面應該像這樣:
set_cookie(name, value, days_to_expire);
擦除cookie也不符合直覺:你得把cookie設定成在過去的時間過期才行。更理想的介面應該是很簡單的:
delete_cookie(name);
這裡我們學到的是“你永遠都不要安於使用不理想的介面”。你總是可以建立你自己的包裝函式來隱藏介面的粗陋細節,讓它不再成為你的阻礙。
按需重塑介面
程式中很多程式碼在那裡只是為了支援其他程式碼——例如,為函式設定輸入或者對輸出做後期處理。這些“粘附”程式碼常常和程式的實際邏輯沒有任何關係。這種傳統的程式碼是抽取到獨立函式的最好機會。
例如,假設你有一個Python字典,包含敏感的使用者資訊,如{"username":"...", "password": "..."},你需要把這些資訊放入一個url中。因為它很敏感,所以你決定要對字典先加密,這會用到一個Cipher類。
但是Cipher期望的輸入是位元組串,不是字典。而且Cipher返回一個位元組串,但我們需要的是對於URL安全的東西。Cipher還需要幾個額外的引數,使用起來還有些麻煩。
開始以為很簡單的任務變成了一大堆粘附程式碼:
user_info = { "usernane": "...", "password": "..." }
user_str = json.dumps(user_info)
cipher = Cipher("aes_l28_cbc", key = PRIIVATE_KEY, init_vector = INIT_VECTOR, op = ENC00E)
encrypted_bytes = cipher.update(user_str)
encrypted_bytes += cipher.final() # flush out the current 128 bit block
url = "http://example.com/?user_info" + base64.urlsafe_b64encode(encrypted_bytes)
...
儘管我們要解決的問題是把使用者的資訊編碼成URL,這段程式碼的主體只是在“把Python 物件編碼成URL友好的字串”。把子問題抽取出來並不難:
def url_safe_encrypt(obj):
obj_str = json.dumps(obj)
cipher = Cipher("aes_128_cbc", key=PRmTE_KEY, init_vector = INIT_VECTOR, op = ENCODE)
encrypted_bytes = cipher.update(obj_str)
encrypted_bytes += cipher.final() # flush out the current 128 bit block
return base64.urlsafe_b64encode(encrypted_bytes)
然後得到的程式中執行“真正”邏輯的程式碼很簡單:
user_info = { "usernarae": "...", "password": "..." }
url = "http://exawple.com/?user_info=" + url_safe_encrypt(userinfo)
過猶不及
像我們在本章的開頭所說的那樣,我們的目標是“積極地發現和抽取不相關的子問題”。我們說“積極地”是因為大多數程式設計師不夠積極。但也可能會過於積極,導致過猶不及。
例如,前一節中的程式碼可能會進一步拆分,如下:
user_info = { "usernarae": "...", "password": "..." }
url = "http://example.com/?user_info=" + url_safe_encrypt_obj(user_info)
def url_safe_encrypt_obj(obj):
obj_str = json.dumps(obj)
return url_safe_encrypt_str(obj_str)
def url_safe_encrypt_str(data):
encrypted_bytes = encrypt(data)
return base64.urlsafe_b64encode(encrypted_bytes)
def encrypt(data):
cipher = make_cipher()
encrypted_bytes = cipher.update(data)
encrypted_bytes += cipher.final() # flush out any remaining bytes
return encrypted_bytes
def make_cipher():
return Cipher("aes_128_cbc", key = PRIVATE_KEY, init_vector = INIT_VECTOR, op = ENCODE)
引入這麼多小函式實際上對可讀性是不利的,因為讀者要關注更多東西,並且按照執行的路徑需要跳來跳去。
為程式碼增加一個函式存在一個小的(卻有形的)可讀性代價。在前面的情況裡,付出這種代價卻什麼也沒有得到。如果你專案的其他部分也需要這些小函式,那麼增加它們是有道理的。但是目前為止,還沒有這個需要。
總結
對本章一個簡單的總結就是“把一般程式碼和專案專有的程式碼分開”。其結果是,大部分程式碼都是一般程式碼。通過建立一大組庫和輔助函式來解決一般問題,剩下的只是讓你的程式與眾不同的核心部分。
這個技巧有幫助的原因是它使程式設計師關注小而定義良好的問題,這些問題已經同專案的其他部分脫離。其結果是,對於這些子問題的解決方案傾向於更加完整和正確。你也可以在以後重用它們。
閱讀參考
Martin Fowler, 《Refactoring: Improving the Design of Existing code》描述了重構的“抽取方法”,而且列舉了很多其他重構程式碼的方法。
Kent Beck, 《SmalltaLk Best Practice Patterns》描述了“組合方法模式”,其中列出了幾條把程式碼拆分成得多小函式的原則。尤其是其中的一條原則“把一個方法中的所有操作保持在一個抽象層次上”。
這些思想和我們的建議“抽取不相關的子問題”相似。本章所討論的是抽取方法中的一個簡單而又特定的情況。
第11章一次只做一件事
同時在做幾件事的程式碼很難理解。一個程式碼塊可能初始化物件,清除資料,解析輸入,然後應用業務邏輯,所有這些都同時進行。如果所有這些程式碼都糾纏在一起,對於每個“任務”都很難靠其自身來幫你理解它從哪裡開始,到哪裡結束。
關鍵思想:應該把程式碼組織得一次只做一件事情。
換個說法,本章是關於如何給程式碼“整理碎片”的。下圖演示了這個過程:左邊所示為一段程式碼所做的各種任務,右邊所示是同一段程式碼在組織成一次只做一件事情後的樣子。
你也許聽說過這個建議:“一個函式只應當做一件事”。我們的建議和這差不多,但不是關於函式邊界的。當然,把一個大函式拆分成多個小一些的函式是好的。但是就算你不這樣做,你仍然可以在函式內部組織程式碼,使得它感覺像是有分開的邏輯段。
下面是用於使程式碼“一次只做一件事”所用到的流程:
1. 列出程式碼所做的所有“任務”。這裡的“任務”沒有很嚴格的定義——它可以小得如“確保這個物件有效”,或者含糊得如“遍歷樹中所有結點”。
2. 儘量把這件任務拆分到不同的函式中,或者至少是程式碼中不同的段落中。
在本章中,我們會給出幾個例子說明如何來做。
任務可以很小
假設有一個部落格上的投票外掛,使用者可以給一條評論投“上”或“下”票。每條評論的總分為所有投票的和:“上”票對應分數為+1,“下”票-1。下面是使用者投票可能的三種狀態,以及它如何影響總分:
當使用者按了一個按鈕(建立或改變她的投票),會呼叫以下JavaScript程式碼:
vote_changed(old_vote, new_vote); // each vote is "Up", "Down", or ""
下面這個函式計算總分,並且對old_vote和new_vote的各種組合都有效:
var vote_changed = function (old_vote, new_vote) {
var score = get_score();
if (new_vote 1== old_vote) {
if (new_vote === 'Up') {
score += (old_vote === 'Down' ? 2 : 1);
} else if (new_vote === 'Down') {
score -= (old_vote === 'Up' ? 2 : 1);
} else if (new_vote ==="") {
score += (old_vote === 'Up' ? -1 : 1);
}
}
set_score(score);
儘管這段程式碼很短,但它做了很多事情。其中有很多錯綜複雜的細節,很難看一眼就知道是否裡面有“偏一位”錯誤、錄入錯誤或者其他bug。
這段程式碼好像是隻做了一件事情(更新分數),但實際上是同時做了兩件事:
1. 把old_vote和new_vote解析成數字值。
我們可以分開解決每個任務來使程式碼變簡單。下面的程式碼解決第一個任務,把投票解析成數字值:
var vote_value = function (vote) {
if (vote === 'Up') {
return +1;
}
if (vote === 'Down') {
return -1;
}
return 0;
}
現在其餘的程式碼可以解決第二個問題,更新分數:
var vote_changed = function (old_vote, new_vote) {
var score = get_score();
score -= vote_value(old_vote); // remove the old vote
score += vote_value(new_vote); // add the new vote
set_score (score);
};
如你所見,要讓自己確信程式碼可以工作,這個版本需要花費的心力小得多。“容易理解”在很大程度上就是這個意思。
從物件中抽取值
我們曾有過一段JavaScript程式碼,用來把使用者的位置格式化成“城市,國家”這樣友好的字串,比如Santa Monica,USA (聖摩尼卡,美國)或者Pairs,France (巴黎,法國)。我們收到的是一個location_info字典,其中有很多結構化的資訊。我們所要做的就是從所有的欄位中找到“City”和“Country”然後把它們接在一起。
下圖給出了輸入/輸入的示例:
location_info
LocalityName |
"Santa Monica" |
SubAdminstrativeAreaName |
"Los Angeles" |
AdminstrativeAreaName |
"Caiifofnia" |
CountryName |
"USA" |
輸出:"Santa Monica,USA"
到目前為止這看上去很簡單,但是微妙之處在於“4個值中的每個或所有都可能缺失”。下面是解決方案:
l 當選擇“City”時,“LocalityName”(城市/鄉鎮),如果有的話。然後是“SubAdministrativeAreaName”(大城市/國家),然後是“AdministrativeAreaName”(州/地區)。
l 如果三個都沒有的話,那麼賦予“City”一個預設值“Middle-of-Nowhere”。
l 如果“CountryName”不存在,就會用“PlanetEarth”這個預設值。
LocalityName |
(undefined) |
SubAdminstrativeAreaName |
(undefined) |
AdminstrativeAreaName |
(undefined) |
CountryName |
"Canada" |
"Middle-of-Nowhere, Canada" |
LocalityName |
(undefined) |
SubAdminstrativeAreaName |
"washington, DC" |
AdminstrativeAreaName |
(undefined) |
CountryName |
"USA" |
"Mashington,DC, USA" |
我們寫了下面的程式碼來實現這個任務:
var place = location_info["LocalityName"]; // e.g. "Santa Monica"
if (!placc) {
place = location_info["SubAdministrativeAreaName"]; // e.g. "Los Angeles"
}
if (!place) {
place = location_info["AdministrativeAreaName"]; // e.g. "California"
}
if (!place) {
place = "Middle-of-Nowhere";
}
if (location__info["CountryName"]) {
place += ", " + location_info["CountryName"]; // e.g. "USA"
} else {
place += ", Planet Earth";
}
return place;
當然,這個有點亂,但是它能完成工作。
但是幾天之後,我們需要改進功能:對於美國之內的位置,我們想要顯示州名而不是國家名(如果可能的話)。所以不再是“Santa Monica, USA”,而是變成了“Santa Monica, California”。
把這個功能新增進前面的程式碼中會讓它變得更難看。
應用“一次只做一件事情”原則
與其強行讓這段程式碼滿足我們的需要,不如我們停了下來並且意識到它現在已經同時在完成多個任務了:
1. 從字典location_info中提取值。
2. 按喜好順序找到“City”,如果找不到就給預設值“Middle-of-Nowhere”。
3. 找到“Country”,如果找不到的話就用“PlanetEarth”。
4. 更新place。
所以我們反而重寫了原來的程式碼來獨立地解決每個任務。
一個任務(從location_info中提取值)自己很容易解決:
var town = location_info["LocalityName"]; // e.g. "Santa Monica"
var city = location_info["SubAdministrativeAreaName"]; // e.g. "los Angeles"
var state = location_info["AdministrativeAreaName"]; // e.g. "CA"
var country = location_info["CountryName"]; // e.g. "USA"
做到這兒,我們已經用完了location_info,不用再記得那些又長又違反直覺的鍵值了。反而我們得到了4個簡單的變數。
下一步,我們要找出返回值中的“第二部分”是什麼:
// Start with the default, and keep overwriting with the most specific value.
var second_half = "Planet Earth";
if (country) {
second half = country;
}
if (state && country === "USA") {
second half = state;
}
類似地,我們可以找出“第一部分”:
var first_half = "Middle-of-Nowhere";
if (state && country !== "USA") {
first half = state;
}
if (city) {
first_half = city;
}
if (town) {
first half = town;
}
最後,我們把資訊結合在一起:
return first_half + ", ” + second_half;
本章開頭展示的“碎片整理”實際上體現了原來的方案和這個新版本。下面是同一幅圖,新增了更多細節:
如你所見,在第二個方案中把4個任務整理到獨立的區域中了。
另一種做法
在重構程式碼時,經常有很多種做法,這個例子也不例外。一旦你把一些任務分離開,程式碼變得更容易讓人思考,你可能會想到重構程式碼的更好方法。
例如,早先的一連串if語句需要小心地去讀才能知道每種情況是否都對。在那段程式碼中其實有兩個子任務同時在進行:
1. 遍歷一系列變數,找出可用變數中最滿意的那一個。
2. 依據國家是否為“USA”而採用不同的列表。
回顧從前的程式碼,你可以看到“if USA”的邏輯交織在其他的邏輯中。我們可以分別處理USA和非USA的情況:
var first_half, second_half;
if (country === "USA") {
first_half = town || city || "Middle-of-Nowhere";
second_half = state || "USA";
} else {
first_half = town || city || state || "Middle-of-Nowhere";
second_half = country || "Planet Earth";
return first+_half + ", " + second_half;
如果你不瞭解JavaScript的話,a || b || c的寫法會逐個計算直到找到第一個“真”值 (在本例中,是指一個定義的非空字串)。這段程式碼的好處是觀察喜好列表很容易,也容易更新。大多數if語句都被掃地出門,業務邏輯所佔的程式碼更少了。
更大型的例子
我們做過一個網頁爬蟲系統,會在下載每個網頁後呼叫一個叫UpdateCounts()的函式來增加不同的統計資料:
void UpdateCounts(HttpDownload hd) {
counts["Exit State"][hd.exit_state()]++; // e.g. "SUCCESS" or "FAILURE"
counts["Http Response"][hd.http_response()]++;// e.g. "404 NOT F0UND"
counts["Content-Type" ][hd.content_type()]++; //e.g. "text/html"
哦,那是我們希望程式碼成為的樣子!
實際上,HttpDownload物件沒有上面所示的任何方法。相反,HttpDownload是一個非常大並且非常複雜的類,有很多巢狀類,並且我們得自己把它們挖出來。更糟糕的是,有時有些值誰不知道是什麼,這種情況下我們只能用“unknown”作為預設值。
由於這些原因,實際的程式碼非常亂:
// WARNING: DO NOT STARE DIRECTLY AT THIS C00E FOR EXTENDED PERIODS OF TIME.
void UpdateCounts(HttpDownload hd) {
// Figure out the Exit State, if available.
if (!hd.has_event_log()|| lhd.event_log().has_exit_state()) {
counts["Exit State"]["unknown"]++;
} else {
string state str = ExitStateTypeName(hd.event log().exit state());
counts["Exit State"][state_str]++;
}
// If there are no HTTF headers at all, use "unknown" for the remaining elements.
if (!hd.has_http_headers()) {
counts["Http Response"]["unknown"]++;
counts["Content-Type"]["unknown"]++;
return;
}
HttpHeaders headers = hd.http_headers();
// Log the HTTP response, if known, otherwise log "unknown"
if (!headers.has_response_code()) {
counts["ttttp Response"]["unknown"]++;
} else {
string code = StringPrintf("%d", headers.response_code());
counts["Http Resp0nse"][code]++;
}
// Log the Content-Type if known, otherwise log "unknown"
if (Iheaders.has_content_type()) {
counts["Content-Type"]["unknown"]++;
} else {
string content_type = ContentTypeMime(headers.content_type());
counts["Content-Typc"][content_type]++;
}
}
如你所見,程式碼很多,邏輯也很多,甚至還有幾行重複的程式碼。讀這種程式碼一點也不有趣。特別是,這段程式碼在不同的任務間來回切換。下面是程式碼裡通篇交織著的幾個任務:
1. 使用"unknown"作為每個鍵的預設值。
2. 檢測HttpDownload的成員是否缺失。
3. 抽取出值並將其轉換成字串。
4. 更新counts[]。
我們可以通過把其中一些任務分割到程式碼中單獨的區域來改進這段程式碼:
void UpdateCounts(HttpDownload hd) {
// Task: define default values for each of the values we want to extract
string exit_state = "unknown";
string http_response = "unknown";
string content_type = "unknown";
// Task: try to extract each value fron HttpDownload, one by one
if (hd.has_event_log() && hd.event_log().has_exit_state()) {
exit_state = ExitStateTypeName(hd.event_log().exit_state());
}
if (hd.has_http_headers() && hd.http_headers().has_response_code()) {
http_response = StringPrintf("%d", hd.http_headers().response_code());
}
if (hd.has_http_headers() && hd.http_headers().has_content_type()) {
content_type = ContentTypeMime(hd.http headers().content_type());
}
// Task: update counts[]
counts["Exit State"][exit_state]++;
counts["Http Response"][http_response]++;
counts["Content-Type"][content_type]++;
}
如你所見,這段程式碼有三個分開的區域,各自目標如下:
1. 為我們感興趣的三個鍵定義預設值。
2. 對於每個鍵,如果有的話就抽取出值,然後把它們轉換成字串。
3. 對於每個鍵/值更新counts[]。
這些區域好的地方是它們互相之前是獨立的——當你在讀一個區域時,你不必去想其他 的區域。
請注意儘管我們列出4個任務,但我們只能拆分出3個。這完全沒問題:你一開始列出的任務只是個開端。即使只拆分出它們中的一些就能對可讀性有很大幫助,就像這個例子中一樣。
進一步的改進
對於當初的大段程式碼來講這個新版本算是有了改進。請注意我們甚至不用建立新函式來 完成這個清理工作。像前面提到的,“一次只做一件事情”這個想法有助於不考慮函式的邊界。
然而,也可以用另一種方法改進這段程式碼,通過引入3個輔助函式:
void UpdateCounts(HttpDownload hd) {
counts["Exit State1"][ExitState(hd)]++;
counts["Http Response"][HttpResponse(hd)]++;
counts["Content-Type"][ContentTyp((hd)]++;
}
這些函式會抽取出對應的值,或者返回"unknown"。例如:
string ExitState(HttpDownload hd) {
if (hd.has_event_log() && hd.event_log().has_exit_state()) {
return ExitStateTypeName(hd.event_log().exit_state());
} else {
return "unknown";
}
}
請注意在這個做法中甚至沒有定義任何變數!像第9章所提到的那樣,儲存中間結果的 變數往往可以完全移除。
在這種方法裡,我們簡單地把問題從不同的角度“切開”。兩種方法都很有可讀性,因為它們讓讀者一次只需要思考一件事情。
總結
本章給出了一個組織程式碼的簡單技巧:一次只做一件事情。
如果你有很難讀的程式碼,嘗試把它所做的所有任務列出來。其中一些任務可以很容易地 變成單獨的函式(或類)。其他的可以簡單地成為一個函式中的邏輯“段落”。具體如何拆分這些任務沒有它們已經分開這個事實那樣重要。難的是要準確地描述你的程式所做的所有這些小事情。
第12章把想法變成程式碼
如果你不能把一件事解釋紿你祖母聽的話說明你還沒有真正理解它。
阿爾伯特·愛因斯坦
當把一件複雜的事向別人解釋時,那些小細節很容易就會讓他們迷惑。把一個想法用 “自然語言”解釋是個很有價值的能力,因為這樣其他知識沒有你這麼淵博的人才可以理解它。這需要把一個想法精煉成最重要的概念。這樣做不僅幫助他人理解,而且也幫助你自己把這個想法想得更清晰。
在你把程式碼“展示”給讀者時也應使用同樣的技巧。我們接受程式碼是你解釋程式所做事 情的主要手段這一關點。所以程式碼應當用“自然語言”編寫。
在本章中,我們會用一個簡單的過程來使你編寫更清晰的程式碼:
1. 像對著一個同事一樣用自然語言描述程式碼要做什麼。
2. 注意描述中所用的關鍵詞和短語。
3. 寫出與描述所匹配的程式碼。
清楚地描述邏輯
下面是來自一個網頁的一段PHP程式碼。這段程式碼在一段安全程式碼的頂部。它檢査是否授權使用者看到這個頁面,如果沒有,馬上返回一個頁面來告訴使用者他沒有授權:
$is_admin = is_admin_request();
if ($document) {
if (!$is_admin && ($document['username'] != $_SESSICW['username'])) {
return not_authorized();
}
} else {
if (!$is_admin) {
return not_authorized();
}
}
// continue rendering the page ...
這段程式碼中有相當多的邏輯。像你在本書第二部分所讀到的,這種大的邏輯樹不容易理解。這些程式碼中的邏輯可以簡化,但是怎麼做呢?讓我們從用自然語言描述這個邏輯開始:
授權你有兩種方式:
1. 你是管理員
2. 你擁有當前文件(如果有當前文件的話)
否則,無法授權你。
下面是受這段描述啟發寫出的不同方案:
if (is_admin_request()) {
// authorized
} elseif ($document && ($documentt['usernante'] == $_SESSION['username'])) {
// authorized
} else {
return not authorized();
}
// continue rendering the page …
這個版本有點不尋常,因為它有兩個空語句體。但是程式碼要少一些,並且邏輯也簡單,因為沒有反義(前一個方案中有三個“not” )。起碼它更容易理解。
瞭解函式庫是有幫助的
我們曾有一個網站,其中有一個“提示框”,用來顯示對使用者有幫助的建議,比如:
提示:登入後可以看到過去做過的査找,[顯示另一條提示!]
這種提示有幾十條,全都藏在HTML中:
<div id="tip-l" class="tip">Tip: Log in to see your past queries.</div>
<div id="tip-2" class="tip">Tip: Click on a picture to see it close up.</div>
...
當讀者訪問這個頁面時,會隨機地讓其中的一個div塊變得可見,其他的還保持隱藏狀態。
如果單擊“Show me another tip! ”連結,它會迴圈到下一個提示。下面是實現這一功能的一些程式碼,使用了JavaScript庫jQuery:
var show_next_tip = function () {
var num_tips = S('.tip').size();
var shown_tip = S('.tip:visible');
var shown_tip_num = Number(shown_tip.attr('id').slice(4));
if (shown_tip_num === num_tips) {
$('#tip-l').show();
} else {
$('#tip-' + (shown_tip_num + l)).show();
}
shown_tip.hide();
};
這段程式碼還可以。但可以把它做得更好。讓我們從描述開始,用自然語言來說這段程式碼要做的事情是:
找到當前可見的提示並隱蔵它。
然後找到它的下一個提示並顯示。
如果沒有更多提示,迴圈回第一個提示。
根據這個描述,下面是另一個方案:
var show_next_tip = function () {
var cur_tip = S('.tip:visible').hide();//find the currently visible tip and hide it
var next_tip = cur_tip.next('.tip'); // find the next tip after it
if (next_tip.size() === 0) { // if we're run out of tips,
next_tip = S('.tip:first'); // cycle back to the first tip
}
next__tip.show(); // show the new tip
}
這個方案的程式碼行數更少,並且不用直接操作整型。它與人們對此程式碼的理解一致。
在這個例子中,jQuery有一個.next()給我們用,這一點很有幫助。編寫精練程式碼的一部分工作是瞭解你的庫提供了什麼。
把這個方法應用於更大的問題
前一個例子把過程應用於小塊程式碼。在下一個例子中,我們會把它應用於更大的函式。你會看到,這個方法可以幫助你識別哪個片段可以分離,從而讓你可以拆分程式碼。
假設我們有一個記錄股票採購的系統。每筆交易都有4塊資料:
l time(一個精確的購買日期和時間)
l ticker_symbol(公司簡稱,如:GOOG)
l price(價格,如:$600)
l number_of_shares(股票數量,如:100)
由於一些奇怪的原因,這些資料分佈在三個分開的資料庫表中,如下圖所示。在每個資料庫中,time是唯一的主鍵。
現在,我們要寫一個程式來把三個表聯合在一起(就像在SQL中的Join操作所做的那樣)。這個步驟應該是簡單的,因為這些行都是按time來排序的,但是有些行缺失了。你希望找到這3個time匹配的所有行,忽略任何不匹配的行,就像前面圖中所示的那樣。
下面是一段Python程式碼,用來找到所有的匹配行:
def PrintStockTransactions():
stock_iter = db_read("SELECT time, ticker_symbol FROM ...")
price_iter = ...
num shares iter = …
# Iterate through all the rows of the 3 tables in parallel.
while stock_iter and price_iter and num_shares_iter:
stock_time = stock_itex.tirae
price_time = price_iter.time
num_shares_time = nua_shares_iter.time
# If all 3 rows don't have the same time, skip over the oldest row
# Note: the "<=" below can't just be "<” in case there are 2 tied-oldest.
if stock_time != price_time or stock_tiroe != nun_shares_time:
if stocktime <= price_time and stock_time <= num_shares_time:
stock_iter.NextRow()
elif price_time <= stock_time and price_time <= num_shares_time:
price_iter.NextRow()
elif num_shares_time <= stock_time and nun_shares_time <= pricc_time:
num_shares_iter.NextRow()
else:
assert False # impossible
continue
assert stock_time == price_time == num_shares_time
# Print the aligned rows.
print "@", stock_time,
print stock_iter.ticker_symbol,
print price_iter.price,
print num_shares_iter.number_of_shares
stock_iter.NextRow()
price_iter.NextRow()
num_shares_iter.NextRow()
這個例子中的程式碼能執行,但是在迴圈中為了跳過不匹配的行做了很多事情。你的腦海中也許閃過了一些警告:“這麼做不會丟失一些行嗎?它的迭代器會不會越過資料流的結尾”? 那麼如何來把它變得更可讀呢?
用自然語言描述解決方案
再一次,讓我們退一步來用自然語言描述我們要做的事情:
我們並行地讀取三個行迭代器。
只要這些行不匹配,向前找直到它們匹配。
然後輸出匹配的行,再繼續向前。
一直做,直到沒有匹配的行。
回頭看看原來的程式碼,最亂的部分就是處理“向前找直到它們匹配”的語句塊。為了讓程式碼表現得更清楚,我們可以把所有這些亂糟糟的邏輯抽取到,名叫AdvanceToMatchingTime()的新函式中。
下面是程式碼的新版本,它使用了新的函式:
def PrintStockTransactions():
stock_iter = …
price_iter = …
num_shares_iter = …
while True:
time = AdvanceToMatchingTime(stock_itez, price_iter, nun_shares_itex)
if time is None:
return
# Print the aligned rows.
print "@”,time,
print stock_iter.ticker_sywbol,
print price_iter.price,
print num_shares_iter.number_of_shares
stock_iter.NextRow()
price_iter.NextRow()
num_shares_iter.NextRow()
如你所見,這段程式碼容易理解得多,因為我們隱藏了所有行對齊的混亂細節。
遞迴地使用這種方法
很容易想象你將如何編寫AdvanceToMatchingTIme()——最壞的情況就是它看上去和第一個版本中難看的程式碼塊很像:
def AdvanceToMatchingTime(stock_iter, price_iter, num_shares_iter):
# Iterate through all the rows of the 3 tables in parallel.
while stock_iter and price_iter and num_shares_iter:
stock_time = stock_itex.tirae
price_time = price_iter.time
num_shares_time = nua_shares_iter.time
# If all 3 rows don't have the same time, skip over the oldest row
if stock_time != price_time or stock_tiroe != nun_shares_time:
if stocktime <= price_time and stock_time <= num_shares_time:
stock_iter.NextRow()
elif price_time <= stock_time and price_time <= num_shares_time:
price_iter.NextRow()
elif num_shares_time <= stock_time and nun_shares_time <= pricc_time:
num_shares_iter.NextRow()
else:
assert False # impossible
continue
assert stock_time == price_time == num_shares_time
return stock_time
但是讓我們把我們的方法同樣應用於AdvanceToMatchingTime()來進改這段程式碼。下面是對於這個函式所要做的事情的描述:
看一下每個當前行:如果它們匹配,那麼就完成了。
否則,向前移動任何“落後”的行。
一直這樣做直到所有行匹配(或者其中一個迭代器結束)
這個描述清晰得多,並且比以前的程式碼更優雅。一件值得注意的事情是描述從未提及stock_iter或者其他解決問題的細節。這意味著我們可以同時把變數重新命名得更簡單,更通用。下面是這樣做後得到的程式碼:
def AdvanceToMatchingTime(row_iterl, row_iter2, row_iter3):
while row_iterl and row_iter2 and row_iter3:
tl = row_iterl.time
t2 = row_iter2.time
t3 = row_iter3.time
if tl == t2 == t3:
return tl
tmax = max(tl, t2, t3)
# If any row is "behind", advanced it.
# Eventually, this while loop will align them all.
if t1 < tmax: row_iter1.NextRow()
if t2 < tmax: row_iter2.NextRow()
if t3 < tmax: row_iter3.NextRow()
return None # no alignment could be found
如你所見,這段程式碼比以前清楚得多。該演算法變得更簡單,並現在那種微妙的比較更少了。我們用了像t1這樣的短名字,卻不用再考慮那些具體涉及的資料庫欄位。
總結
本章討論了一個簡單的技巧,用自然語言描述程式然後用這個描述來幫助你寫出更自然的程式碼。這個技巧出人意料地簡單,但很強大。看到你在描述中所用的詞和短語還可以幫助你發現哪些子問題可以拆分出來。
但是這個“用自然語言說事情”的過程不僅可以用於寫程式碼。例如,某個大學計算機實 驗室的規定聲稱當有學生需要別人幫它除錯程式時,他首先要對房間角落的一隻專用的泰迪熊解釋他遇到的問題。令人驚訝的是,僅僅通過大聲把問題描述出來,往往就能幫這個學生找到解決的辦法。這個技巧叫做“橡皮鴨技術”。
另一個看待這個問題的角度是:如果你不能把問題說明白或者用詞語來做設計,估計是缺少了什麼東西或者什麼東西缺少定義。把一個問題(或想法)變成語言真的可以讓它更具體。
第13章少寫程式碼
知道什麼時候不寫程式碼可能對於一個程式設計師來講是他所要學習的最重要的技巧。你所寫的每一行程式碼都是要測試和維護的。通過重用庫或者減少功能,你可以節省時間並且讓你的程式碼庫保持精簡節約。
關鍵思想:最好讀的程式碼就是沒有程式碼。
別費神實現那個功能——你不會需要它
當你開始一個專案,自然會很興奮並且想著你希望實現的所有很酷的功能。但是程式設計師傾向於高估有多少功能真的對於他們的專案來講是必不可少的。很多功能結果沒有完成,或者沒有用到,也可能只是讓程式更復雜。
程式設計師還傾向於低估實現一個功能所要花的工夫。我們樂觀地估計了實現一個粗糙原型所要花的時間,但是忘記了在將來程式碼庫的維護、檔案以及後增的“重量”所帶來的額外時間。
質疑和拆分你的需求
不是所有的程式都需要執行得快,100%準確,並且能處理所有的輸入。如果你真的仔細 檢査你的需求,有時你可以把它削減成一個簡單的問題,只需要較少的程式碼。讓我們來看一些例子。
例子:商店定位器
假設你要給某個生意寫個“商店定位器”。你以為你的需求是:
對於任何給定使用者的經度/緯度,找到距離該經度/緯度最近的商店。
為了100%正確地實現,你要處理:
l 當位置處於國際日期分界線兩側的情況。
l 接近北極或南極的位置。
l 按“每英里所跨經度”不同,處理地球表面的曲度。
處理所有這些情況需要相當多的程式碼。
然而,對於你的應用程式來講,只有在德州的30家店。在這麼小的範圍裡,上面列出的三個問題並不重要。結果是,你可以把需求縮減為:
對於德州附近的使用者,在德州找到(近似)最近的商店。
解決這個問題很簡單,因為你只要遍歷每個商店並計算它們與這個經緯度之間的歐幾里得距離就可以了。
例子:增加快取
我們曾有一個Java程式,它經常要從磁碟讀取物件。這個程式的速度受到了這些讀取操作的限制,因此我們希望能實現快取之類的功能。一個典型的讀取序列像是這樣:
讀取物件A
讀取物件A
讀取物件A
讀取物件B
讀取物件B
讀取物件C
讀取物件D
讀取物件D
如你所見,有很多對同一物件的重複訪問,因此快取絕對會有幫助。
當面對這樣的問題時,我們首先的直覺是使用那種丟掉最近沒有使用的條目的快取。在我們的庫中沒有這樣的快取,因此我們必須實現一個自己的。這不是問題,因為我們以前實現過這種資料結構(它包含一個雜湊表和一個單向連結串列——一共有大約100行程式碼)。
然而,我們注意到這種重複訪問總是處於一行的。因此不要實現LRU(最近最少使用) 快取,我們只要實現有一個條目的快取:
DiskObject lastUsed; // class member
DiskObject lookUp(String key) {
if (lastUsed == null 丨| !lastUsed.key().equals(key)) {
lastUsed = loadDiskObject(key);
}
return lastUsed;
}
這樣我們就用很少的程式碼得到了90%的好處,這段程式所佔的記憶體也很小。
怎麼說“減少需求”和“解決更簡單的問題”的好處都不為過。需求常常以微妙的方式互相影響。這意味著解決一半的問題可能只需要花四分之一的工夫。
保持小程式碼庫
在你第一次開始一個軟體專案,並且只有一兩個原始檔時,一切都很順利。編譯和執行程式碼轉眼就完成,很容易做改動,並且很容易記住每個函式或類定義在哪裡。
然後,隨著專案的增長,你的目錄中加進了越來越多的原始檔。很快你就需要多個目錄來組織它們了。很難再記得哪個函式呼叫了哪個函式,而且跟蹤bug也要做多一點的工作。
最後,你就有了很多原始碼分佈在很多不同的目錄中。專案很大,沒有一個人自己全部理解它。增加新功能變得很痛苦,而且使用這些程式碼很費力還令人不快。
我們所描述的是宇宙的自然法則——隨著任何座標系統的增長,把它粘合在一起所需的複雜度增長得更快。
最好的解決辦法就是“讓你的程式碼庫越小,越輕量級越好”,就算你的專案在增長。那麼你就要:
l 建立越多越好的“工具”程式碼來減少重複程式碼(見第10章)。
l 減少無用程式碼或沒有用的功能(見下圖)。
l 讓你的專案保持分開的子專案狀態。
l 總的來說,要小心程式碼的“重量”。讓它保持又輕又靈。
刪除沒用的程式碼
園丁經常修剪植物以讓它們活著並且生長。同樣地,修剪掉礙事和沒用的程式碼也是個好主意。
一旦程式碼寫好後,程式設計師往往不情願刪除它,因為它代表很多實際的工作量。刪掉它可能意味著承認在上面所花的時間就是浪費。不要這麼想!這是一個有創造性的領域——攝影家、作者和電影製版人也不會保留他們所有的工作。
刪除獨立的函式很簡單,但有時“無用程式碼”實際上交織在你的專案中,你並不知情。下面是一些例子:
l 你一開始把系統設計成能處理多語言檔名,現在程式碼中到處都充滿了轉換程式碼。然而,那段程式碼不能很好地工作,實現上你的程式也從來沒有用到過任何多語言檔名。
l 為什麼不刪除這個功能呢?
l 你希望你的程式在記憶體耗盡的情況下仍能工作,因此你有很多耍小聰明的邏輯來試著從記憶體耗盡的情況下恢復。這是個好主意,但在實踐中,當系統記憶體耗盡時,你的程式將變成不穩定的殭屍——所有的核心功能都不可用,再點一下滑鼠它就死了。
為什麼不通過一句簡單的提示“系統記憶體不足,抱歉”並刪除所有記憶體不足的程式碼,終止程式呢?
熟悉你周邊的庫
很多時候,程式設計師就是不知道現有的庫可以解決他們的問題。或者有時,它們忘了庫可以做什麼。知道你的庫能做什麼以便你可以使用它,這一點很重要。
這裡有一條比較中肯的建議:每隔一段時間,花15分鐘來閱讀標準庫中的所有函式/模組/型別的名字。這包括C++標準模板庫、Java API、Python內建的模組以及其他內容。
這樣做的目的不是記住整個庫。這只是為了瞭解有什麼可以用的,以便下次你寫新程式碼時會想:“等一下,這個聽起來和我在API中見到的東西有點像……”我們相信提前做這種準備很快就會得到回報,起碼因為你會更傾向於使用庫了。
例子:Python中的列表和集合
假設你有一個使用Python寫的列表(如[2,1,2]),你想要一個擁有不重複元素的列表(在上例中,就是[2,1])。你可以用字典來完成這個任務,它有一個鍵列表保證元素是唯一的:
def unique(elements):
temp = {}
for elenent in elements:
temp[element] = None # The value doesn't matter.
return temp.keys()
unique_elements = unique([2, 1, 2])
但是你可以用較少人知道的集合型別:
unique_elements = set([2, 1, 2]) # Remove duplicates
這個物件是可以列舉的,就像一個普通的list一樣。如果你很想要一個list物件,你可以用:
unique_elements = list(set([2, 1, 2])) # Remove duplicates
很明顯,這裡集合才是正確的工具。但如果你不知道set型別,你可能會寫出像前面如unique()一樣的程式碼。
為什麼重用庫有這麼大的好處
一個常被引用的統計結果是一個平均水平的軟體工程師每天寫出10行可以放到最終產品中的程式碼。當程式設計師們剛一聽到這個,他們根本不相信——“10行程式碼?我一分鐘就寫出來了!”
這裡的關鍵詞是“最終產品中的”。在一個成熟的庫中,每一行程式碼都代表相當大量的設計、除錯、重寫、文件、優化和測試。任何經受了這樣達爾文進化過程一樣的程式碼行就是很有價值的。這就是為什麼重用庫有這麼大的好處,不僅節省時間,還少寫了程式碼。
例子:使用Unix工具而非編寫程式碼
當一個Web伺服器經常性地返回HTTP響應程式碼4xx或者5xx,這是個有潛在問題的訊號(4xx是客戶端錯誤,5xx是伺服器端錯誤)。所以我們想要編寫個程式來解析一個Web伺服器的訪問日誌並找出哪些URL導致了大部分的錯誤。
訪問日誌一般看起來像是這個樣子:
1.2.3.4 example.com [24/Aug/2010:01:08:34] "GET /index.html HTTP/1.1" 200 ...
2.3.4.5 example.com [24/Aug/2010:01:14:27] "GET /help?topic=8 HTTP/1.1" 500 ...
3.4.5.6 example.com [24/Aug/2010:01:15:54] "GET /favicon.ico HTTP/1.1" 404 ...
...
基本上,它們都包含有以下格式的行:
browser-IP host [date] "GET /url-path HTTP/1.1" HTTP-response-code ...
寫個程式來找出帶有4xx或5xx響應程式碼的最常見網址可能只要20行用C++或者Java寫的程式碼。然而,在Unix中,可以輸入下面的命令:
cat access.log | awk '{ print $5 " " $7 }' | egrep "[45]..$"\
| sort | uniq -c | sort -nr
就會產生這樣的輸出:
95 /favicon.ico 404
13 /help?topic=8 500
11 /login 403
...
<count> <path> <http response code>
這條命令的好處在於我們不必寫任何“真正”的程式碼,或者向原始碼管理庫中籤入任何東西。
總結
冒險、興奮——絕地武士追求的並不是這些。
——尤達大師
本章是關於寫越少程式碼越好的。每行新的程式碼都需要測試、寫文件和維護。另外,程式碼庫中的程式碼越多,它就越“重”,而且在其上開發就越難。
你可以通過以下方法避免編寫新程式碼:
l 從專案中消除不必要的功能,不要過度設計。
l 重新考慮需求,解決版本最簡單的問題,只要能完成工作就行。
l 經常性地通讀標準庫的整個API,保持對它們的熟悉程度。
第四部分精選話題
本書前三部分覆蓋了使程式碼簡單易讀的各種技巧。在該部分中,我們會把這些技術應用在兩個精選出的話題中。
首先,我們會討論測試——如何同時寫出有效而又可讀的測試。
然後,我們會歷經一個設計和實現專門設計的資料結構的過程(一個“分鐘/小時計數器”),這將是一個效能與好的設計以及可讀性互動的例子。
第14章測試與可讀性
在本章中,我們會揭示一些寫出整潔並且有效測試的簡單技巧。
測試對不同的人意味著不同的事。在本章中,“測試”是指任何僅以檢查另一段(“真實”)程式碼的行為為目的的程式碼。我們會關注測試的可讀性方面,不會討論你是否應該在寫真實程式碼之前寫測試程式碼(“測試驅動的開發”)或者測試開發的其他哲學方面。
使測試易於閱讀和維護
測試程式碼的可讀性和非測試程式碼是同樣重要的。其他程式設計師會經常來把測試程式碼看做非正式的文件,它記錄了真實程式碼如何工作和應該如何使用。因此如果測試很容易閱讀,使用者對於真實程式碼的行為會有更好的理解。
關鍵思想:測試應當具有可讀性,以便其他程式設計師可以舒服地改變或者増加測試。
當測試程式碼多得讓人望而止步,會發生下面的事情:
l 程式設計師會不敢修改真實程式碼。“啊,我們不想糾結於那段程式碼,更新它的那些測試將會是個噩夢!”
l 當增加新程式碼時,程式設計師不會再堆加新的測試。一段時間後,測試的模組越來越少,你不再對它有信心。
相反,你希望鼓勵你程式碼的使用者(尤其是你自己!)習慣於測試程式碼。他們應該能在新改動破壞已有測試時做出分析,並且應該感覺增加新測試很容易。
這段測試什麼地方不對
在程式碼庫中,有一個函式,它對於一個打過分的搜尋結果列表進行排序和過濾。下面是函式的宣告:
// Sort 'docs' by score (highest first) and remove negative-scored documents.
void SortAndFilterDocs(vector<ScoredDocument>* docs);
該函式的測試最初如下所示:
void Test1() {
vector<ScoredDocument> docs;
docs.resize(5);
docs[0].url = "http://example.com";
docs[0].score = -5.0;
docs[1].url = ''http: //example. com";
docs[1].score = 1;
docs[2].url = "http://example.com";
docs[2].score = 4;
docs[3].url = "http://example.com";
docs[3].score = -99998.7;
docs[4].url = "http://example.cow";
docs[4].sc0re = 3.0;
SortAndFilterDocs(&docs);
assert(docs.size() == 3);
assert(docs[0].score == 4);
assert(docs[1j.score == 3.0);
assert(docs[2].score == 1);
}
這段測試程式碼起碼有8個不同的問題。在本章結束前,你將能夠找出並改正它們。
使這個測試更可讀
做為一條普遍的測試原則,你應當“對使用者隱去不重要的細節,以便更重要的細節會更突出”。
前一節中的測試程式碼明顯違反了這條原則。該測試的所有細節都擺在那裡,比如像建立一個vector<ScoreDocument>這樣不重要的細枝末節。例子中大部分程式碼都包含url、scroe和docs[],這些只是背後的C++物件如何建立的細節,不是關於所測試內容的高層次描述。
作為淸理這些程式碼的第一步,可以建立一個這樣的輔助函式:
void MakeScoredDoc(ScoredDocument* sd, double score, string url) {
sd->score = score;
sd->url = url;
}
使用這個函式,測試程式碼變得緊湊一點了:
void Test1() {
vector<ScoredDocument> docs;
docs.resize(5);
MakeScoredDoc(&docs[0], -5.0, "http://example.com”);
MakeScoredOoc(&docs[1], 1, "http://example.com");
MakeScoredDoc(&docs[2], 4, "http://example.com");
MakeScoredDoc(&docs[3], -99998.7, "http://example.com");
...
}
但是它還是不夠好——在我們面前還是有不重要的細節。例如,引數“http://exaraple. com”看著很不自然。它每次都是一樣的,而且具體URL是什麼根本沒關係——只要填進一個有效的ScoreDocument就可以了。
我們被迫要看的另一個不重要的細節是docs.resize(5)和&docs[0]、&docs [1]等。讓我們修改輔助函式來做更多事情,並給它命名為AddScoreDoc():
void AddScoredDoc(vector<ScoredDocument>& docs, double score) {
ScoredDocument sd;
sd.score = score;
sd.url = "http://example.com";
docs.push_back(sd);
}
使用這個函式,測試程式碼更緊湊了:
void Test1() {
vector<ScoredDocument> docs;
AddScoredDoc(docs, -5.0);
AddScoredDoc(docs, 1);
AddScoredDoc(docs, 4);
AddScoredDoc(docs, -99998.7);
...
}
這段程式碼已經好多了,但仍然不滿足“高度易讀和易寫”測試的要求。如果你希望增加一個測試,其中用到一組新的scroed docs,這會需要大量的拷貝和貼上。那麼,我們怎麼樣來進一步改進它呢?
建立最小的測試宣告
要改進這段測試程式碼,讓我們使用從第12章學來的技巧。讓我們用自然語言來描述我們的測試要做什麼:
我們有一個文件列表,它們的分數為[-5, 1, 4, -99998.7,3]。
在SortAndFilderDocs()之後,剩下的文件應當有的分數是[4,3, 1],而且順序也是這樣。
如你所見,在描述中沒有在任何地方提及vector<ScroedDocument>。這裡最重要的是分數陣列。理想的情況下,測試程式碼應該看起來這樣:
CheckScoresBeforeAfter("-5, 1, 4, -99998.7, 3", "4, 3, 1");
我們可以把這個測試的基本內容精練成一行程式碼!
然而這沒什麼大驚小怪的。大多數測試的基本內容都能精練成“對於這樣的輸入/情形,期望有這樣的行為/輸出”。並且很多時候這個目的可以用一行程式碼來表達。這除了讓程式碼緊湊而又易讀,讓測試的表述保持很短還會讓增加測試變得很簡單。
實現定製的“微語言”
注意到CheckScoreBeforeAfter()需要兩個字串引數來描述分數陣列。在較新版本的C++中,可以這樣傳入陣列常量:
CheckScoresBeforeAfter({-5, 1, 4, -99998.7, 3}, {4, 3, 1})
因為當時我們還不能這麼做,所以我們就把分數都放在字串中,用逗號分開。為了讓這個方法可行,CheckScoresBeforeAfter()就不得不解析這些字串引數。
一般來講,定義一種定製的微語言可能是一種佔用很少的空間來表達大量資訊的強大方法。其他例子包含printf()和正規表示式庫。
在本例中,編寫一些輔助函式來解析用逗號分隔的一系列數字應該不會非常難。下面是 CheckScoreBeforeAfter()的實現方式:
void CheckScoresBeforeAfter(string input, string expected_output) {
vector<ScoredDocument> docs = ScoredDocsFroraString(input);
SortAndFilterDocs(&docs);
string output = ScoredDocsToString(docs);
assert(output == expected_output);
}
為了更完善,下面是用來在string和vector<ScoredDocument>之間轉換的輔助函式:
vector<ScoredDocument> ScoredDocsFromString(string scores) {
vector<ScoredDocument> docs;
replace(scores.begin(), scores.end(), ',', ' ');
// Populate 'docs' from a string of space-separated scores.
istringstream stream(scores);
double score;
while (stream >> score) {
AddScoredDoc(docs, score);
}
return docs;
}
string ScoredDocsToString(vector<ScoredDocument> docs) {
ostringstream stream;
for (int i = 0; i < docs.size(); i++) {
if (i > 0) stream << ", ";
stream << docs[i].score;
}
return stream.str();
}
乍一看這裡有很多程式碼,但是它能帶給你難以想象的能力。因為你可以只呼叫 CheckScoresBeforeAfter()—次就寫出整個測試,你會更傾向於增加更多的測試(就像我們在本章後面要做的那樣)。
讓錯誤訊息具有可讀性
現在的程式碼已經很不錯了,但是當assert(output == expected_output)這一行失敗時會發生什麼呢?它會產生一行這樣的錯誤訊息:
Assertion failed: (output == expected_output), function CheckScoresBeforeAfter, file test.cc, line 37.
顯然,如果你看到了這個錯誤,你會想:“output和expected_output出錯時的值是什麼呢?”
更好版本的assert()
幸運的是,大部分語言和庫都有更高階版本的assert()給你用。所以不用這樣寫:
assert(output == expected_output);
你可以使用C++的Boost庫!
BOOST_REQUIRE_EQUAL(output, expected_output)
現在,如果測試失敗,你會得到更具體的訊息:
test.cc(37): fatal error in "CheckScoresBeforeAfter": critical check
output == expected_output failed ["1, 3, 4" !== "4, 3, 1"]
這更有幫助。
如果有的話,你應該使用這些更有幫助的斷言方法。每當你的測試失敗時,你就會受益。
其他語言中更好的ASSERT()選擇
在Python中,內建語句assert a == b會產生一條簡單的錯誤訊息:
File "file.py" line X, in <module>
assert a == b
AssertionError
不如用unittest模組中的assertEqual()方法:
import unittest
class MyTestCase(unittest.TestCase):
def testFunction(self):
a = 1
b = 2
self.assertEqual(a, b)
if __name__ == '__main__':
unittest.main()
它會給出這樣的錯誤訊息:
File "MyTestCase.py", line 7, in testFunction
self.assertEqual(a, b)
AssertionError: 1 != 2
無論你用什麼語言,都可能會有一個庫/框架(例如XUnit)來幫助你。瞭解那些庫對你有好處!
手工打造錯誤訊息
使用BOOST_REQUIRE_EQUAL(),可以得到更好的錯誤訊息:
output == expected_output failed ["1, 3, 4" !== "4, 3, 1"]
然而,這條訊息還能進一步改進。例如,如果能看到原本觸發這個錯誤的輸入一定會有幫助。理想的錯誤訊息可以像是這樣:
CheckScoresBeforeAfter() failed,
Input: "-5, 1, 4, -99998.7, 3"
Expected Output: "4, 3, 1"
Actual Output: "1, 3, 4"
如果這就是你想要的,那麼就寫出來吧!
void CheckScoresBeforeAfter(...) {
...
if (output != expected_output) {
cerr << "CheckScoresBeforeAfter() failed," << endl;
cerr << "Input: \"" << input << "\"" << endl;
cerr << "Expected Output: \"" << expected_output << "\"" << endl;
cerr << "Actual Output: \"" << output << "\"" << endl;
abort();
}
}
這個故事的寓意就是錯誤訊息應當越有幫助越好。有時,通過建立“定製的斷言”來輸出你自己的訊息是最好的方式。
選擇好的測試輸入
有一門為測試選擇好的輸入的藝術。我們現在看到的這些是很隨機的:
CheckScoresBeforeAfter("-5, 1, 4, -99998.7, 3", "4, 3, 1");
如何選擇好的輸入值呢?好的輸入應該能徹底地測試程式碼。但是它們也應該很簡單易讀。
關鍵思想:基本原則是,你應當選擇一組最簡單的輸入,它能完整地使用被測程式碼。
例如,假設我們剛剛寫了:
CheckScoresBeforeAfter("1, 2, 3", "3, 2, 1");
儘管這個測試很簡單,它沒有測試SortAndFilterDocs()中“過濾掉負的分數”這一行為。如果在程式碼的這部分中有bug,這個輸入不會觸發它。
另一個極端是,假設我們這樣寫測試:
CheckScoresBeforeAfter("123014, -1082342, 823423, 234205, -235235",
"823423, 234205, 123014");
這些值複雜得沒有必要。(並且甚至也沒能完整地測試程式碼。)
簡化輸入值
那麼我們能做些什麼來改進這些輸入值呢?
CheckScoresBeforeAfter("-5, 1, 4, -99998.7, 3", "4, 3, 1");
嗯,首先能可能會注意到的是非常“囂張”的值-99 998.7。這個值的含義只是“任何負數”,所以簡單的值就是-1。(如果-99998.7是想說明它是個“非常負的負數”,明確地用像-1e100這樣的值會更好。)
關鍵思想:又簡單又能完成工作的測試值更好。
測試中其他的值還不算太差,但既然我們都已經改了,那麼把它們也簡化成儘量簡單的整數。並且,只要有一個負數來測試負數會被移除就可以了。下面是測試的新版本:
CheckScoresBeforeAfter("1, 2, -1, 3", "3, 2, 1");
我們簡化了測試的值,卻並沒有降低它的效果。
大型“破壞性”測試
對於大的、不切實際的輸入進行測試當然是有價值的。例如,你可能會想包含一個這樣的測試:
CheckScoresBeforeAfter("100, 38, 19, -25, 4, 84, [lots of values]...",
"100, 99, 98, 97, 96, 95, 94, 93, ...");
這樣的大型輸入在發現bug方面很有作用,比如緩衝區溢位或者其他出乎意料的情況。
但是這樣的程式碼又大多看上去又嚇人,對程式碼的壓力測試來講並無很好的效果。相反,用程式設計的方法來生成大型輸入會更有效果,例如,生產100 000個值。
一個功能的多個測試
與其建立單個“完美”輸入來完整地執行你的程式碼,不如寫多個小測試,後者往往會更容易、更有效並且更有可讀性。
每個測試都應把程式碼推往某一個方向,嘗試找到某種bug。例如,下面有SortAdnFiterDocs()的4個測試:
CheckScoresBeforeAfter("2, 1, 3", "3, 2, 1"); // Basic sorting
CheckScoresBeforeAfter{"0, -0.1, -10", "0"); // All values < 0 removed
CheckScoresBeforeAfter("1, -2, 1, -2", "1, 1"); // Duplicates not a problem
CheckScoresBeforeAfter("", ""); // Empty input OK
如果要非常地徹底,還可以寫更多的測試。有分開的測試用例還可以使下一個負責程式碼相關工作的人更輕鬆。如果有人不小心引人了一個bug,測試的失敗會指向那個具體的失敗測試用例。
為測試函式命名
測試程式碼一般以函式的形式組織起來——你所測試的每個方法和/或情形對應一個測試函式。例如,測試SortAndFilterDocs()的測試程式碼是在函式Test1()中:
void Test1() {
...
}
為測試函式選擇一個好名字可能看上去很無聊而且也無關緊要,但是不要因此而訴諸沒有意義的名字,像是Test1()、Test2()這樣。
反而,你應當用這個名字來描述這個測試的細節。如果讀測試程式碼的人可以很快搞明白這些的話,這一點尤其便利:
l 被測試的類(如果有的話)
l 被測試的函式
l 被測試的情形或bug
一種構造好的測試函式名的簡單方式是把這些資訊拼接在一起,可能再加上一個“Test_”字首。
例如,不要用Test1()這個名字,可以用了Test_SortAndFilterDocs()這樣的格式:
void Test_SortAndFilterDocs() {
...
}
依照測試的精細程度不同,你可能會考慮為測試的每種情形寫一個單獨的測試函式。可以使用Test_<FunctionName>_<Situation>()這樣的格式:
void Test_SortAndFilterDocs_BasicSorting() {
...
}
void Test_SortAndFilterDocs_NegativeValues() {
...
}
這裡不要怕名字太長或者太繁瑣。在你的整個程式碼庫中不會呼叫這個函式,因此那些要避免使用長函式名的理由在這裡並不適用。測試函式的名字的作用就像是註釋。並且,如果測試失敗了,大部分測試框架會輸出其中斷言失敗的那個函式的名字,因此一個具有描述性的名字尤其有幫助。
請注意如果你在使用一個測試框架,可能它已經有方法命名的規則和規範了。例如,在Python的unittest模組中它需要測試方法的名字以test開頭。
當為測試程式碼的輔助函式命名時,標明這個函式是否自身有任何斷言或者只是一個普通的“對測試一無所知”的輔助函式。例如,在本章中,所有呼叫了assert()的輔助數都命名成Check...()。但是函式AddScoredDoc()就只是像普通輔助函式一樣命名。
那個測試有什麼地方不對
在本章的開頭,我們聲稱在這個測試中至少有8個地方不對:
void Test1() {
vector<ScoredDocument> docs;
docs.resize(5);
docs[0].url = "http://example.com";
docs[0].score = -5.0;
docs[1].url = ''http: //example. com";
docs[1].score = 1;
docs[2].url = "http://example.com";
docs[2].score = 4;
docs[3].url = "http://example.com";
docs[3].score = -99998.7;
docs[4].url = "http://example.cow";
docs[4].sc0re = 3.0;
SortAndFilterDocs(&docs);
assert(docs.size() == 3);
assert(docs[0].score == 4);
assert(docs[1j.score == 3.0);
assert(docs[2].score == 1);
}
現在我們已經學到了一些編寫更好測試的技巧,讓我們來找出它們:
1. 這個測試很長,並且充滿了不重要的細節,你可以用一句話來描述這個測試所做的事情,因此這條測試的語句不應該太長。
2. 增加新測試不會很容易。你會傾向於拷貝/貼上/修改,這樣做會讓程式碼更長而且充滿重複。
3. 測試失敗的訊息不是很有幫助。如果測試失敗的話,它只是說Assertion failed: docs.size() == 3,這並沒有為進一步除錯提供足夠的資訊。
4. 這個測試想要同時測試完所有東西。它想要既測試對負數的過濾又測試排序的功能。把它們拆分成多個測試會更可讀。
5. 這個測試的輸入不是很簡單。尤其是,樣本分數-99998.7很“囂張”,儘管它是什麼值並不重要但是它會引起你的注意。一個簡單的負數值就足夠了。
6. 測試的輸入沒有徹底地執行程式碼。例如,它沒有測試到當分數為0時的情況。(這種文件會過濾掉嗎?)
7. 它沒有測試其他極端的輸入,例如空的輸入向量、很長的向量,或者有重複分數的情況。
8. 測試的名字Test1()沒有意義——名字應當能描述被測試的函式或情形。
對測試較好的開發方式
有些程式碼比其他程式碼更容易測試。對於測試來講理想的程式碼要有明確定義的介面,沒有過多的狀態或者其他的“設定”,並且沒有很多需要審查的隱藏資料。
如果你寫程式碼的時候就知道以後你要為它為寫測試的話,會發生有趣的事情:你開始把程式碼設計得容易測試!幸運的是,這樣的程式設計方式一般來講也意味著會產生更好的程式碼。對測試友好的設計往往很自然地會產生有良好組織的程式碼,其中不同的部分做不同的事情。
測試驅動開發
測試驅動開發(TDD)是一種程式設計風格,你在寫真實程式碼之前就寫出測試。TDD的支持者相信這種流程對沒有測試的程式碼來講會做岀極大的質量改進,比寫出程式碼之後再寫測試要大得多。
這是一個爭論很激烈的話題,我們不想攪進來。至少,我們發現僅通過在寫程式碼時想著測試這件事就能幫助把程式碼寫得更好。
但不論你是否使用TDD,其結果總是你用程式碼來測試另一些程式碼。本章旨在幫助你把測試做得既易讀又易寫。
在所有的把一個程式拆分成類和方法的途徑中,解耦合最好的那一個往往就是最容易測試的那個。另一方面,假設你的程式內部聯絡很強,在類與類之間有很多方法的呼叫,並且所有的方法都有很多引數。不僅這個程式會有難以理解的程式碼,而且測試程式碼也會很難看,並且既難讀又難寫。
有很多“外部”元件(需要初始化的全域性變數、需要載入的庫或者配置檔案等)對寫測試來講也是很討厭的。
一般來講,如果你在設計程式碼時發現:“嗯,這對測試來講會是個噩夢”,這是個好理由讓你停下來重新考慮這個設計。表14-1列出一些典型的測試和設計問題:
表14-1:可測試性差的程式碼的特徵,以及它所帶來的設計問題
特徵 |
可測試性的問題 |
設計問題 |
使用全域性變數 |
對於每個測試都要重置所有的全域性狀態(否則,不同的測試之間會互相影響) |
很難理解哪些函式有什麼副作用。沒辦法獨立考慮每個函式,要考慮整個程式才能理解是不是所有的程式碼都能工作 |
對外部元件有大量依賴的程式碼 |
很難給它寫出任何測試,因為要先搭起太多的腳手架。寫測試會比效無趣,因此人們會避免寫測試 |
系統會更可能因某一依賴失敗而失敗。對於改動來講很難知道會產生什麼樣的影響。很難重構類。系統會有更多的失敗模式,並且要考慮更多恢復路徑 |
程式碼有不確定的行為 |
測試會很古怪,而且不可靠。經常失敗的測試最終會被忽略 |
這種程式更可能會有條件競爭或者其他難以重現的bug。這種程式很難推理。產品中的bug很難跟蹤和改正 |
另一方面,如果對於你的設計容易寫出測試,那是個好現象。表14-2列出一些有益的測試和設計的特徵。
表14-2:可測試性較好的程式碼的特徵,以及它所產生的優秀設計
特徵 |
對可測試性的好處 |
對設計的好處 |
類中只有很少或者沒有內部狀態 |
很容易寫出測試,因為要測試一個方法只要較少的設定,並且有較少的隱藏狀態需要檢查 |
有較少狀態的類更簡單,更容易理解 |
類/函式只做一件事 |
要測試它只需要較少的測試用例 |
較小/較簡單的元件更加模組化,並且一般來講系統有更少的耦合 |
每個類對別的類的依賴很少;低耦合 |
每個類可以獨立地測試(比多個類一起測試容易得多) |
系統可以並行開發。可以很容易修改或者刪除類,而不會影響系統的其他部分 |
函式的介面簡單,定義明確 |
有明確的行為可以測試。測試簡單介面所需的工作量較少 |
介面更容易讓程式設計師學習,並且重用的可能性更大 |
走得太遠
對於測試的關注也會過多。下面是一些例子:
l 犧牲真實程式碼的可讀性,只是為了使能測試。把真實程式碼設計得具有可測試性,這應該是個雙嬴的局面:真實的程式碼變得簡單而且低耦合,並且也更容易為它寫測試。但是如果你僅僅是為了測試它而不得不在真實程式碼中插入很多難看的塞子,那肯定有什麼地方不對了。
l 著迷於100%的測試覆蓋率。測試你程式碼的前面90%通常要比那後面的10%所花的工夫少。後面那10%包括使用者介面或者很難出現的錯誤情況,其中bug的代價並不高,花工夫來測試它們並不值得。事實上你永遠也不會達到100%的測試覆蓋率。如果不是因為漏掉的bug,也可能是因為漏掉的功能或者你沒想到說明書應該改一改。根據你的bug的成本不同,對於你花在測試程式碼上的開發時間有一個合理的範圍。如果你在建一個網站原型,可能寫任何測試都是不值得的。另一方面,如果你在為一架飛船或者一臺醫用裝置編寫控制器,測試可能是你的重點。
l 讓測試成為產品開發的阻礙。我們曾見過這樣的情形,測試,本應只是專案的一個方面,卻主導了整個專案。測試成了要敬畏的上帝,程式設計師只是走走這些儀式和過場,沒有意識到他們在工程上寶貴的時間花在別的地方可能會更好。
總結
在測試程式碼中,可讀性仍然很重要。如果測試的可讀性很好,其結果是它們也會變得很容易寫,因此大家會寫更多的測試。並且,如果你把事實程式碼設計得容易測試,程式碼的整個設計會變得更好。
以下是如何改進測試的幾個具體要點:
l 每個測試的最高一層應該越簡明越好。最好每個測試的輸入/輸出可以用一行程式碼來描述。
l 如果測試失敗了,它所發出的錯誤訊息應該能讓你容易跟蹤並修正這個bug。
l 使用最簡單的並且能夠完整運用程式碼的測試輸入。
l 給測試函式取一個有完整描述性的名字,以使每個測試所測到的東西很明確。不要用Test1(),而用像Test_<FunctionName>_<Situation>()這樣的名字。
最重要的是,要使它易於改動和增加新的測試。
第15章設計並改進“分鐘/小時計數器”
讓我們來看一件真實產品所用程式碼中的資料結構:一個“分鐘/小時計數器”。我們會帶你走過一個工程師可能會經歷的自然的思考過程,首先試著解決問題,然後改進它的效能和增加功能。最重要的是,我們也會試著讓程式碼保持易讀,就用本書中所有的原則。在這個過程中我們也會轉錯幾個地方,或者產生些其他的錯誤。看看你能不能理解並找出這些地方。
問題
我們需要跟蹤在過去的一分鐘和一個小時裡Web伺服器傳輸了多少位元組。下面的圖示說明了如何維護這些總和:
這個問題相當直接明瞭,但你將會看到,要有效地解決這個問題是個有趣的挑戰。讓我們從定義類介面開始。
定義類介面
下面是用C++寫的第一個類介面版本:
class MinuteHourCounter {
public:
// Add a count
void Count(int num_bytes);
// Return the count over this ninute
int MinuteCount();
// Return the count over this hour
int HourCount();
};
在實現這個類之前,讓我們看一遍這些名字和註釋,看看是否有什麼地方我們想改一改。
改進命名
MinuteHourCounter這個類名是很好的。它很專門、具體,並且容易讀出來。
有了類名,方法名MinuteCount()和HourCount()也是合理的。你可能會給它們起 GetMinuteCount()和GetHourCount()這樣的名字,但這並沒什麼幫助。如第3章所述,對很多人來講“get”暗示著“輕量級的訪問器”。你將會看到,其他的實現並不會是輕量級的,所以最好不要“get”這個詞。
然而方法名Count()是有問題的。我們問同事他們認為Count()會做什麼,其中一些人認為它的意思是“返回所有時間裡的總的計數”。這個名字有點違反直覺。問題是Count既是個名詞又是個動詞,既可以是“我想要得到你所見過的所有樣本的計數”的意思也可以是“我想要你對樣本進行計數”的意思。
下面幾個名字可供代替Count():
l Increment()
l Observe()
l Record()
l Add()
Increment()是會誤導人的,因為它意味著一個只會增加的值。(在該情況中,小時計數會隨時間波動。)
Observe()還可以,但是有點模糊。
Record()也有名詞/動詞的問題,所以不好。
Add()很有趣,因為它既可以是“以算術方法增加”的意思,也可以是“新增到一個資料列表”——在該情況中,兩種情況兼而有之,所以眾Add()正合適。那麼我們就要把這個方法重新命名為void Add(int num_bytes)。
但是引數名num_bytes太有針對性了。是的,我們主要的用例的確是對位元組計數,但是 MinuteHourCounter沒必要知道這一點。其他人可能用這個類來統計査詢或者資料庫事務的次數。我們可以用更通用的名字,如delta,但是delta這個詞常常用在值有可能為負的場合,這可不是我們希望的。count這個名字應該可以——它簡單、通用並且暗示“非負數”。同時,它使我們可以在更明確的背景下加入“count”這個詞。
改進註釋
下面是目前為止的類介面:
class MinuteHourCounter {
public:
// Add a count
void Add(int count);
// Return the count over this ninute
int MinuteCount();
// Return the count over this hour
int HourCount();
};
讓我們看一遍每個方法的註釋並且改進它們。看看第一個:
// Add a count
void Add(int count);
這條註釋現在完全是多餘的了——要麼刪除它,要麼改進它。下面是一個改進的版本:
// Add a new data point (count >= 0).
// For the next minute, MinuteCount() will be larger by +count.
// For the next hour, HourCount() will be larger by +count.
void Add(irrt count);
現在讓我們來看看MinuteCount的註釋:
// Return the count over this minute
int MinuteCount();
當我們問同事這段註釋是什麼意思時,得到了兩種互相矛盾的解讀:
1. 返回現在所在的時間(如12:12pm)所在的分鐘中的計數。
2. 返回過去60秒內的計數,和時鐘邊界無關。
第二種解釋才是它實際的工作方式。所以讓我們把這個混淆用更明確和具體的語言解釋淸楚。
// Return the accumulated count over the past 60 seconds.
int MinuteCount();
(同樣地,我們也可以改進HourCount()的註釋。)
下面是目前為止包含所有改動的類定義,還有一條類級別的註釋:
// Track the cumulative counts over the past minute and over the past hour.
// Useful, for exanplef to track recent bandwidth usage.
class MinuteHourCounter {
public:
// Add a new data point (count >= 0).
// For the next minute, MinuteCount() will be larger by +count.
// For the next hour, HourCount() will be larger by +count.
void Add(irrt count);
// Return the accumulated count over the past 60 seconds.
int MinuteCount();
// Return the accumulated count over the past 3600 seconds.
int HourCount();
};
(出於簡潔的考慮,我們在後面會省略掉程式碼中的這些註釋。)
得到外部視角的觀點
你可能已經注意到,我們已經有兩次通過同事來幫助我們解決問題了。詢問外部視角的觀點是測試你的程式碼是否“對使用者友好”的好辦法。要試著對他們的第一印象持開放的態度,因為其他人可能會有同樣的結論。並且這些“其他人”裡可能就包含6個月之後的你自己。
嘗試1: 一個幼稚的方案
讓我們來進入下一步,解決這個問題。我們會從一個很直接的方案開始:就是保持一個有時間戳的“事件”列表:
class MinuteHourCounter {
struct Event {
Event(int count, time_t time) : count(count), time(time) {}
int count;
time_t time;
};
list<Event> events;
public:
void Add(int count) {
events.push_back(Event(count, time()));
}
...
};
然後我們就可以根據需要計算最近事件的個數。
class MinuteHourCounter {
...
int MinuteCount() {
int count = 0;
const time_t now_secs = time();
for (list<Event>::reverse_iterator i = events.rbegin();
i != events.rend() && i->time > now_secs - 60; ++i) {
count += i->count;
}
return count;
}
int HourCount() {
int count = 0;
const time_t now_secs = time();
for (list<Event>::reverse_iterator i = cvents.rbegin();
i != events.rend() && i->time > now_secs - 3600; ++i) {
count += i->count;
}
return count;
}
};
這段程式碼易於理解嗎?
儘管這個方案是“正確”的,可是其中有很多可讀性的問題:
l for迴圈太大,一口吃不下。大多數讀者在讀這部分程式碼時會顯著地慢下來(至少他們應該慢下來,如果他們要確定這裡的確沒有bug的話)。
l MinuteCount()和HourCount()幾乎一模一樣。如果他們可以共享重複程式碼就可能讓這段程式碼少一些。這個細節非常重要,因為這些重複的程式碼相對更復雜。(讓有難度的程式碼約束在一起更好些。)
一個更易讀的版本
MinuteCount()和HourCount()中的程式碼只有一個常量不一樣(60和3600)。明顯的重構方法是引入一個輔助方法來處理這兩種情況:
class MinuteHourCounter {
list<Event> events;
int CountSincc(time_t cutoff) {
int count = 0;
for (list<Event>::reverse_iterator rit = events.rbegin(); rit != events.rend(); ++rit) {
if (rit->time <= cutoff) {
break;
}
count += rit->count;
}
return count;
}
public:
void Add(int count) {
events.push_back(Event(count, time()));
}
int MinuteCount() {
return CountSince(time() - 60);
}
int HourCount() {
return CountSince(time() - 3600);
}
};
在這段新程式碼中有幾件事情值得一提。
首先,請注意CountSince()的引數是一個絕對的cutoff,而不是一個相對的secs_ago(60 或3600)。兩種方式都可行,但是這樣做對CountSince來講更容易些。
其次,我們把迭代器從i改名為rit。i這個名字更常用在整型索引上。我們考慮過用it這個名字,這是迭代器的一個典型名字。但這一次我們用的是一個反向迭代器,並且這一點對於程式碼的正確性至關重要。通過在名字前面加一個字首r使它在如rit != events. rend()這樣的語句中看上去對稱。
最後,把條件rit->time <= cutoff從for迴圈中抽取出來,並把它作為一條單獨的if語句。為什麼這麼做?因為保持迴圈的“傳統”格式for(begin, end, advance)最容易讀。讀者會馬上明白它是“遍歷所有的元素”,並且不需要再做更多的思考。
效能問題
儘管我們改進了程式碼的外觀,但這個設計還有兩個嚴重的效能問題:
1. 它一直不停地在變大。
這個類儲存著所有它見過的事件——它對記憶體的使用是沒有限制的!最好 MinuteHourCounter能自動刪除超過一個小時以前的事件,因為不再需要它們了。
2. MinuteCount()和HourCount()太慢了。
CountSince()這個方法的時間為O(n),其中n是在其相關的時間視窗內資料點的個數。想象一下一個高效能伺服器每秒呼叫Add()幾百次。每次對HourCount()的呼叫都可能要對上百萬個資料點計數!最好MinuteHourCounter能記住minute_count和hour_count變數,並隨每次對Add()的呼叫而更新。
嘗試2:傳送帶設計方案
我們需要一個設計來解決前面提到的兩個問題:
1. 刪除不再需要的資料。
2. 更新事先算好的minute_count和hour_count變數總和。
我們打算這樣做:我們會像傳送帶一樣地使用list。當新資料在一端到達,我們會在總數上增加。當資料太陳舊,它會從另一端“掉落”,並且我們會從總數中減去它。
有幾種方法可以實現這個傳送帶設計。一種方法是維護兩個獨立的list,—個用於過去一分鐘的事件,一個用於過去一小時。當有新事件到達時,在兩個列表中都增加一個拷貝。
這種方法很簡單,但它效率並不高,因為它為每個事件建立了兩個拷貝。
另一種方法是維護兩個list,事件先會進入第一個列表(“最後一分鐘裡的事件”),然後這個列表會把資料傳送給第二個列表(“最後一小時[但不含最後一分鐘]裡的事件”)。
這種“兩階段”傳送帶設計看上去更有效,所以讓我們按這個方法實現。
實現兩階段傳送帶設計
讓我們從列出類中的成員開始:
class MinuteHourCounter {
list<Event> minute_events;
list<Event> hour_events; // only contains elements NOT in minute_events
int minute count;
int hour_count; // counts ALL events over past hour, including past minute
};
這個傳送帶設計的要點在於要能隨時間的推移“切換”事件,使事件從minute_events移到hour_events,並且minute_events和hour_events相應地更新。要做到這一點,我們會建立一個叫做ShiftOldEvents()的輔助方法。當我們有了這個方法以後,這個類的剩餘部分很容易實現:
void Add(int count) {
const time_t nowsecs == time();
ShiftOldEvents(now_secs);
// Feed into the minute list (not into the hour list--that will happen later)
minute_events.push_back(Event(count, now_secs));
minutecount += count;
hour_count += count;
}
int MinuteCount() {
ShiftOldEvents(time());
return minute_count;
}
HourCount() {
ShiftOldEvents(time());
return hour_count;
}
明顯,我們把所有的髒活兒都放到了ShiftOldEvents()裡:
// Find and delete old events, and decrease hour_count and minute_count accordingly.
void ShiftOldEvents(time_t now_secs) {
const int minuteago = now_secs - 60;
const int hour_ago = now_secs - 3600;
// Move events more than one minute old fron 'minute_events' into 'hour_events'
// (Events older than one hour will be removed in the second loop.)
while (!minute_events.empty() && minute_events.front().time <= minute_ago) {
hour_events.push_back(minute_events.front());
minute_count -= minute_events.front().count;
minute_events.pop_front();
}
// Remove events more than one hour old from 'hour_events'
while (!hour_events.empty() && hour_events.front().time <= hour_ago) {
hour_count -= hour_events.front().count;
hour events.pop_front();
}
}
這樣就完成了嗎?
我們已經解決了前面提到了對效能的兩點擔心,並且我們的方案是可行的。對很多應用來講,這個解決方案就足夠好了。但它還是有些缺點的。
首先,這個設計很不靈活。假設我們希望保留過去24小時的計數。這可能需要改動大量的程式碼。你可能已經注意到了,ShiftOldEvents()是一個很集中的函式,在分鐘與小時資料間做了微妙的互動。
其次,這個類佔用的記憶體很大。假設你有一個高流量的服務,每分鐘呼叫Add()函式100 次。因為我們保留了過去一小時內所有的資料,所以這段程式碼可能會需要用到大約5MB的記憶體。
一般來講,Add()被呼叫得越多,使用的記憶體就越多。在一個產品開發環境中,庫使用大量不可預測的記憶體不是一件好事。最好不論Add()被呼叫得多頻繁,MinuteHourCounter能用固定數量的記憶體。
嘗試3:時間桶設計方案
你應該已經注意到,前面的兩個實現都有一個小bug。我們用time_t來儲存時間戳,它儲存的是一個以秒為單位的整數。因為這個近似,所以MinuteCount()實際上返回的是介於 59~60秒鐘的結果,根據呼叫它的時間而不同。
例如,如果一個事件發生在time = 0.99秒,這個time會近似成t=0秒。如果你在time = 60.1秒呼叫MinuteCount(),它會返回t=1,2,3...60的事件的總和。因此會遺漏第一個事件,儘管它從技術上來講發生在不到一分鐘以前。
平均來講,MinuteCount()會返回相當於59.5秒的資料。並且HourCount()會返回相當於 3 599.5秒的資料(一個微不足道的誤差)。
可以通過使用亞秒粒度來修正這個誤差。但是有趣的是,大多數使用MinuteHourCounter的應用程式不需要這種程度的精度。我們會利用這一點來設計一個新的MinuteHourCounter,它要快得多並且佔用的空間更少。它是在精度與物有所值的效能之間的一個平衡。
這裡的關鍵思想是把一個小時間窗之內的事件裝到桶裡,然後用一個總和累加這些事件。例如,過去1分種裡的事件可以插入60個離散的桶裡,每個有1秒鐘寬。過去1小時裡的事件也可以插入60個離散的桶裡,每個1分鐘寬。
如圖一樣使用這些桶,方法MinuteCount()和HourCount()的精度會是1/60,這是合理的。(與前面的方案相似,最後一隻桶平均只有實際的一半。用這種設計,我們可以用保持61只桶而不是60只桶並且忽略當前“正在進行中”的桶來進行補救。但是會讓數鋸有點部分“堆積”。一個更好的修正是把當前正在進行的桶與最老的桶裡的一個互補部分結合,得到一個既無偏差又最新的計數。這種實現留給讀者作為練習。)
如果要更精確,可以使用更多的桶,以使用更多記憶體為交換。但重要的是這種設計使用固定的、可預知的記憶體。
實現時間桶設
如果只用一個類來實現這個設計會產生很多錯綜複雜的程式碼,很難理解。相反,我們會按照第11章中的建議,建立一些不同的類來處理問題的不同部分。
一開始,首先建立一個不同的類來儲存一個時間段裡的計數(如最後一小時)。把它命為TrailingBucketCounter。它基本上是MinuteHourCount的泛化版本,用來處理一個時間段。以下是介面:
// A class that keeps counts for the past N buckets of time.
class TrailingBucketCounter {
public:
// Example: TrailingBucketCounter(30, 60) tracks the last 30 minute-buckets of time.
TrailingBucketCounter(int num_buckets, int secs_per_bucket);
void Add(int county, time_t now);
// Return the total count over the last num_buckets worth of time
int TrailingCount(time_t now);
};
你可能會想為什麼Add()和TrailingCount()需要當前時間(time_t now)來做引數——如果用這些方法自己來計算當前的time()不是更方便嗎?
儘管這看上去有點怪,但傳入當前時間有兩個好處。首先,它讓TrailingBucketCounter成為一個“時鐘無關”的類,一般來講這更容易測試而且會避免bug。其次,它把所有對time()的呼叫保持在MinuteHourCounter中。對於時間敏感的系統,如果能把所有獲得時間的呼叫放在一個地方會有幫助。
假設TrailingBucketCounter已經實現了,那麼MinuteHourCounter就很容易實現了:
class MinuteHourCounter {
TrailingBucketCounter minute_counts;
TrailingBucketCounter hour_counts;
public:
MinuteHourCounter():
minute_counts(/* num_buckets = */ 60, /* secs_per_bucket = */ 1),
hour_counts(/* num_buckets = */ 60, /* secs_per_bucket = */ 60) {
}
void Add(int count) {
time_t now = time();
minute_counts.Add(count, now);
hour_counts.Add(count, now);
}
int MinuteCount() {
time_t now = time();
return minute_counts.TrailingCount(now);
}
int HourCount() {
time_t now = time();
return hour counts.TrailingCount(now);
}
};
這段程式碼更容易讀,也更靈活——如果我們想增加桶的數量(通過增加記憶體使用來改善精度),那將會很容易。
實現TraiIingBucketCounter
現在所有剩下的工作就是實現TraiIingBucketCounter類了。再一次,我們會建立一個輔助類來進一步拆分這個問題。
我們會建立一個叫做ConveyorQueue的資料結構,它的工作是處理其下的計數與總和。 TraiIingBucketCounter類可以關注根據過去了多少時間來移動ConveyorQueue。
下面是ConveyorQueue介面:
// A queue with a maximum number of slots, where old data "falls off" the end.
class ConveyorQueue {
ConveyorQueue(int maxitems);
// Increment the value at the back of the queue.
void AddToBack(int count);
// Each value in the queue is shifted forward by 'numshifted'.
// New items are initialized to 0.
// Oldest items will be removed so there are <= max_itews.
void Shift(int num_shifted);
// Return the total value of all items currently in the queue.
int TotalSum();
};
假設這個類已經實現了,請看TraiIingBucketCounter多麼容易實現:
class TrailingBucketCounter {
ConveyorQueue buckets;
const int secs_per_bucket;
time_t last_update_time; // the last time Update() was called
// Calculate how many buckets of time have passed and Shift() accordingly.
void Update(time_t now) {
int current_bucket = now / secs_per_bucket;
int last_update_bucket = last_update_time / secs_per_bucket;
buckets.Shift(current_bucket - last_update_bucket);
last update_time = now;
}
public:
TrailingBucketCounter(int num_buckets, int secs_per_bucket) :
buckets(num_buckets), secs_per_bucket(secs_per_bucket) {
}
void Add(int count, time_t now) {
Update(now);
buckets.AddToBack(count);
}
int TrailingCount(time_t now) {
Update(now);
return buckets.TotalSum();
}
};
現在它拆成兩個類(TraiIingBucketCounter和ConveyorQueue),這是第11章所討論的又一個例子。我們也可以不用ConveyorQueue,直接把所有的東西存放在 TraiIingBucketCounter。但是這樣程式碼更容易理解。
實現ConveyorQueue
現在剩下的只是實現ConveyorQueue類:
// A queue with a maximum number of slots, where old data gets shifted off the end.
class ConveyorQueue {
queue<int> q;
int max items;
int total_sum; // sum of all items in q
public:
ConveyorQueue(int max_items) : max_items(max_items), total_sum(0) { }
int TotalSum() {
return total_sum;
}
void Shift(int num_shifted) {
// In case too many items shifted, just clear the queue.
if (num_shifted >= max_items) {
q = queue<int>(); // clear the queue
total_sum = 0;
return;
}
// Push all the needed zeros.
while (num_shifted > 0) {
q.push(o);
num_shifted--;
}
// Let all the excess items fall off.
while (q.size() > max_items) {
total_sum -= q.front();
q.pop();
}
}
void AddToBack(int count) {
if (q.emptyO) Shift(l); // Make sure q has at least 1 item.
q.back() += count;
total_sum += count;
}
};
現在我們完成了!我們有一個又快又能有效地使用記憶體的MinuteHourCount,外加一個更靈活的TrailingBucketCounter,它很容易重用。例如,很容易就能建立一個功能更齊全的RecentCounter來計算更多時間間隔範圍,比如過去一天或者過去十分鐘。
比較三種方案
讓我們來比較一下本章中見到的這些方案。下表給出程式碼的大小和效能狀況(假設在一個每秒100次Add()呼叫的高流量用例中):
方案 |
程式碼行數 |
每次HourCount()呼叫的代價 |
記憶體使用情況 |
HourCount()的誤差 |
幼稚方案 |
33 |
O(每小時事件數)(約360萬) |
無約束 |
1/3600 |
傳送代設計 |
55 |
O(1) |
O(每小時事件數)(5MB) |
1/3600 |
時間桶設計(60只桶) |
98 |
O(1) |
O(桶的個數)(約500位元組) |
1/60 |
請注意最後那個有三個類的方案的程式碼數量比任何其他的嘗試都多。然而,效能好得多,並且設計更靈活。而且,每個類自己都更容易讀。這是一個正面的改進:有100行易讀的程式碼比有50行不易讀的要好。
有時,把一個問題拆成多個類可能引入類之間的複雜度(在有單個類的方案中是不會有的)。然而,在本例中,有一個簡單的“線性”鏈條連線著每個類,並且只有一個類暴露給了終端使用者。總體來講,拆分這個問題所得到的好處更大。
總結
讓我們回顧得到最後的MinuteHourCounter設計所走過的路。這是個典型的程式碼片段演進過程。
首先,我們從編寫一個幼稚的方案開始。這幫助我們意識到兩個設計上的挑戰:速度和記憶體使用情況。
接下來,我們嘗試用“傳送帶”設計方案。這種設計改進了速度和記憶體使用情況,但對於高效能應用來講還是不夠好。並且,這個設計不是很靈活:讓這段程式碼能處理其他的時間間隔需要很多工作。
我們最終的設計解決了前面的問題,通過把問題拆分成子問題。下面是建立的三個類,自下向上,以及每個類所解決的子問題:
ConveyorQueue
一個最大長度的佇列,可以“移位”並且維護其總和。
TrailingBucketCounter
根據過去了多少時間來移動ConveyorQueue,並且按所給的精度來維護單一(最後)的時間間隔中的計數。
MinuteHourCounter
簡單地包含兩個TrailingBucketCounters,一個用來統計分鐘,一個用來統計小時。
附錄
深入閱讀
我們通過分析來自產品程式碼中的上百個程式碼例子來找出在實踐中什麼是有用的,從而寫出這本書。但是我們也讀了很多書和文章,這對於我們的寫作也很有幫助。
如果你想學到更多,你可能會喜歡下面這些資源。下面的列表怎麼說都不算完整,但是它們是個好的開端。
關於寫高質量程式碼的書
《Code Complete: A Practical Handbook of Software Construction, 2nd edition》,by Steve McConnell (Microsoft Press, 2004)
一本嚴謹的大部頭,是關於軟體建構的所有方面的,包括程式碼質景以及其他。
《Refactoring: Improving the Design of Existing Code》,by Martin Fowler et al. (Addison- Wesley Professional, 1999)
一本關於增量程式碼改進哲學的好書,包含很多不同重構方法的具體分類,以及要在儘管不破壞東西的情況下做出這些改動所需的步驟。
《The Practice of Programming》,by Brian Kernighan and Rob Pike (Addison-Wesley Professional, 1999)
討論了程式設計的多個方面,包含除錯、測試、可移植性和效能,有很多程式碼示例。
《The Pragmatic Programmer: From Journeyman to Master》, by Andrew Hunt and David Thomas (Addison-Wesley Professional, 1999)
一系列好的程式設計和工程原則,按短小的討論來組織。
《Clean Code: A Handbook of Agile Software Craftsmanship》,by Robert C. Martin (Prentice Hall, 2008)
和本書類似(但是專門為Java),還擴充了其他如錯誤處理和併發等話題。
關於各種程式設計話題的書
《JavaScript: The Good Parts》,by Douglas Crockford (O’Reilly, 2008)
我們認為這本書的精神與我們的書相似,儘管該書不是直接關於可讀性的。它是關於如何使用JavaScript語言中不容易出錯而且更容易理解的一個淸晰子集的。
《Effective Java, 2nd edition》,by Joshua Bloch (Prentice Hall, 2008)
一本傑出的書,是關於讓你的Java程式更易讀和更少bug的。儘管它是關於Java 的,但其中很多原則對所有的語言都適用。強烈推薦。
《Design Patterns: Elements of Reusable Object-Oriented Software》,by Erich Gamma, Richard Helm, Ralph Johnson, and John Vlissides (Addison-Wesley Professional, 1994)
這本書是軟體工程師用來討論面向對向程式設計所用的“模式”這種通用語言的原始出處。作為通用的、有用的模式的一覽,它幫助程式設計師在第一次自己解決棘手問題時避免經常出現的陷阱。
《Programming Pearls, 2nd edition》,by Jon Bentley (Addison-Wesley Professional, 1999)
關於真實軟體問題的一系列文章。每一章都有解決真實世界中問題的真知灼見。
《High Performance Web Sites》, by Steve Souders (O’Reilly, 2007)
儘管這不是一本關於程式設計的書,但這本書也值得注意,因為它描述了幾種不需要寫很多程式碼就可優化網站的方法(與本書第13章的目的一致)。
《Joel on Software: And on Diverse and ...》, by Joel Spolsky
來自於http://www.joelonsoftware.com/的一些優秀文章。Spolsky的作品涉及軟體工程的很多方面,並且對很多相關話題都深有見解。一定要讀一讀“Things You Should Never Do, Part I”和“The Joel Test:12 Steps to Better Code”。
歷史上重要的書目
《Writing Solid Code》,by Steve Maguire (Microsoft Press, 1993)
很遺憾這本書有點過時了,但它絕對在如何讓程式碼中的bug更少方面給出了出色的建議,從而影響了我們。如果你讀這本書,你會注意到很多和我們的建議重複的地方。
《Smalltalk Best Practice Patterns》, by Kent Beck (Prentice Hall, 1996)
儘管例子是用Smalltalk寫的,但這本書有很多好的程式設計原則。
《The Elements of Programming Style》,by Brian Kemighan and PJ. Plauger (Computing McGraw- Hill, 1978)
最早的關於“寫程式碼的最清晰方法”的書之一。大多數例子是用Fortran和PL1寫的。
《Literate Programming》,by Donald E. Knuth (Center for the Study of Language and Information, 1992)
我們發自肺腑地贊同Knuth的說法:“與其把我們主要的任務想象成指示計算機做什麼,不如讓我們關注解釋給人類我們希望讓計算機做什麼”(p.99)。但要小心:這本書中的大部分內容是關於Knuth的WEB文件程式設計環境的。WEB實際上是一種語言,使用可以像寫文學作品一樣來寫程式,以程式碼為輔助內容。
我們自己用過衍生自WEB的系統,我們認為當程式碼變化頻繁時(這很常見),相對於用我們所建議的實踐方法,保持這種所謂的“文學程式設計”來更新程式碼更難。
作者簡介
儘管在馬戲團長大,Dustin Boswell很早就發現他更擅長計算機而不是雜技。Dustin在加州理工學院得到了他的本科學位,在那裡他愛上了電腦科學,於是後來去聖地亞哥加利福尼亞大學讀研究生。他在Google工作了5年,從事過不同的專案,包括網頁爬蟲的基礎結構。他建立了數個網站並且喜歡從事“大資料”和機器學習方面的工作。Dustin目前在一家如Internet創業公司工作,他空閒時間會去聖摩尼卡山中徒步旅行,並且他剛剛做了父親。
Trevor Foucher在微軟和Google從事了超過10年的大型軟體開發。他現在是Google的一名搜尋基礎結構工程師。在他的業餘時間裡,他參與遊戲聚會,閱讀科幻小說,並且是他妻子的時裝創業公司的COO。Trevor畢業於加州大學伯克利分校,獲得電氣工程和電腦科學的本科學位。
相關文章
- 讀《編寫可讀程式碼的藝術》
- 編寫可讀程式碼的藝術
- 如何提高程式碼的可讀性? - 讀《編寫可讀程式碼的藝術》
- 『No22: 編寫可讀程式碼的藝術(1)』
- 編寫可讀性程式碼的藝術--萬字總結,看到即學到
- 《編寫可讀程式碼的藝術》讀書筆記(上)表面層次的改進筆記
- 編寫可讀的程式碼
- 編寫更加穩定、可讀性強的JavaScript程式碼JavaScript
- 編寫可閱讀的程式碼--基本規約
- 編寫更加穩定/可讀的javascript程式碼JavaScript
- 乾淨的程式碼: 編寫可讀的函式函式
- 編寫Linux實用程式的藝術(轉)Linux
- javascript 程式碼可讀性JavaScript
- 編寫超級可讀程式碼的15個最佳實踐
- 想寫無Bug的安全程式碼?看防禦性程式設計的藝術程式設計
- 編寫易讀的程式碼 (轉)
- 程式碼可讀性隨想
- 編寫可測試的 JavaSript 程式碼Java
- 編寫可測試的 JavaScript 程式碼JavaScript
- 編寫小而美函式的藝術函式
- 掌握編寫有效的GitHub提交資訊的藝術Github
- Dave Cheney:編寫簡單,可讀,可維護的Go程式碼的十個工程建議Go
- 用C語言編寫Linux實用程式的藝術(轉)C語言Linux
- 程式碼行數越多可讀性越好?
- 書寫可維護程式碼的重要性
- 程式碼的藝術:如何寫出小而清晰的函式函式
- 使用JSDoc提高程式碼的可讀性JS
- 遠離麵條程式碼:編寫可維護的 JS 程式碼JS
- 讀《程式碼不朽:編寫可維護軟體的10大要則》C# 版C#
- 程式語言的可讀性
- Linux Kernel 程式碼藝術——編譯時斷言Linux編譯
- 如何寫出具有良好可測試性的程式碼?
- 精讀《編寫有彈性的元件》元件
- 提升程式碼的可讀性系列(一)–基礎篇
- 寫介面的藝術: 精簡,可擴充套件套件
- 頗具藝術感的程式碼
- 編碼如作文:寫出高可讀 JS 的 7 條原則JS
- 編寫讓別人能夠讀懂的程式碼