在作IT的公司裏,尤爲是軟件開發部門,通常不會要求工程師衣着正式。在我工做過的一些環境相對寬鬆的公司裏,不少程序員的衣着連得體都算不上(搞笑的T恤、短褲、拖鞋或者乾脆不穿鞋)。我想,我本人也在這個行列裏面。雖然我如今改行作軟件開發方面的諮詢工做,但仍是改不了這副德性。衣着體面的其中一個積極方面是它體現了對周圍人的尊重,以及對所從事工做的尊重。好比,那些研究市場的人要表現出對客戶的尊重。而大多數程序員基本上天天主要的工做就是和其餘程序員打交道。那麼這說明程序員之間就不用互相尊重嗎?並且也不用尊重本身的工做嗎?html
程序員之間的互相尊重體如今他所寫的代碼中。他們對工做的尊重也體如今那裏。前端
在《Clean Code》一書中Bob大叔認爲在代碼閱讀過程當中人們說髒話的頻率是衡量代碼質量的惟一標準。這也是一樣的道理。node
這樣,代碼最重要的讀者就再也不是編譯器、解釋器或者電腦了,而是人。寫出的代碼能ios
讓人快速理解、輕鬆維護、容易擴展的程序員纔是專業的程序員。c++
固然,爲了達到這些目的,僅有編寫程序的禮節是不夠的,還須要不少相關的知識。這些知識既不屬於編程技巧,也不屬於算法設計,而且和單元測試或者測試驅動開發這些話題也相對獨立。這些知識每每只能在公司無人問津的編程規範中才有所說起。這是我所見的僅把代碼可讀性做爲主題的一本書,並且這本書寫得頗有趣!git
既然是「藝術」,不免會有觀點上的多樣性。譯者自己做爲程序員觀點更加「極端」一些。然而兩位做者見多識廣,輕易不會給出極端的建議,如「函數必需要小於10行」或者「註釋不能夠用於解釋代碼在作什麼而只能解釋爲何這樣作」等語句不多出如今本書中。相反,做者給出目標以及判斷的標準。程序員
翻譯書是件費時費力的事情,好在本書剛好涉及我感興趣的話題。但翻譯本書有一點點自相矛盾的地方,由於書中至關的篇幅是在講如何寫出易讀的英語。固然這裏的「英語」大多數的時候只是指「天然語言」,對於中文一樣適用。但鑑於大多數編程語言都是基於英語的(至少到目前爲止),並且要求不少程序員用英語來註釋,在這種狀況下努力學好英語也是必要的。面試
感謝機械工業出版社的各位編輯幫助我接觸和完成這本書的翻譯。這本譯做基本上能夠說是在高鐵和飛機上完成的(我此時正在新加坡飛往香港的飛機上)。所以家庭的支持是很是重要的。尤爲是個人妻子鄭秀雯(是的,新加坡的海關人員也對她的名字感興趣),她是全書的審校者。還有我「上有的老人」和「下有的小孩」,他們給予我幫助和關懷以及不斷前進的動力。ajax
尹哲正則表達式
咱們曾經在很是成功的軟件公司中和出色的工程師一塊兒工做,然而咱們所遇到的代碼仍有很大的改進空間。實際上,咱們曾見到一些很難看的代碼,你可能也見過。
可是當咱們看到寫得很漂亮的代碼時,會很受啓發。好代碼會很明確告訴你它在作什麼。使用它會頗有趣,而且會鼓勵你把本身的代碼寫得更好。
本書旨在幫助你把代碼寫得更好。當咱們說「代碼」時,指的就是你在編輯器裏面要寫的一行一行的代碼。咱們不會討論項目的總體架構,或者所選擇的設計模式。固然那些很重要,但咱們的經驗是程序員的平常工做的大部分時間都花在一些「基本」的事情上,像是給變量命名、寫循環以及在函數級別解決問題。而且這其中很大的一部分是閱讀和編輯已有的代碼。咱們但願本書對你天天的編程工做有不少幫助,而且但願你把本書推薦給你團隊中的每一個人。
這是一本關於如何編寫具備高可讀性代碼的書。本書的關鍵思想是代碼應該寫得容易理解。確切地說,使別人用最短的時間理解你的代碼。
本書解釋了這種思想,而且用不一樣語言的大量例子來說解,包括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,他們一直鼓勵咱們,並給咱們建立條件來口若懸河地談論編程話題。
在過去的五年裏,咱們收集了上百個「壞代碼」的例子(其中很大一部分是咱們本身寫的),而且分析是什麼緣由使它們變壞,使用什麼樣的原則和技術可讓它們變好。咱們發現全部的原則都源自同一個主題思想。
咱們相信這是當你考慮要如何寫代碼時能夠使用的最重要的指導原則。貫穿本書,咱們會展現如何把這條原則應用於你天天編碼工做的各個不一樣方面。但在開始以前,咱們會詳細地介紹這條原則並證實它爲何這麼重要。
大多數程序員(包括兩位做者)依靠直覺和靈感來決定如何編程。咱們都知道這樣的代碼:
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;
所以儘管減小代碼行數是一個好目標,但把理解代碼所需的時間最小化是一個更好的目標。
你可能在想:「那麼其餘約束呢?像是使代碼更有效率,或者有好的架構,或者容易測試等?這些不會在有些時候與使代碼容易理解這個目標衝突嗎?」
咱們發現這些其餘目標根本就不會互相影響。就算是在須要高度優化代碼的領域,仍是有辦法能讓代碼同時可讀性更高。而且讓你的代碼容易理解每每會把它引向好的架構且容易測試。
本書的餘下部分將討論如何把「易讀」這條原則應用在不一樣的場景中。可是請記住,當你猶豫不決時,可讀性基本定理老是先於本書中任何其餘條例或原則。並且,有些程序員對於任何沒有完美地分解的代碼都不自覺地想要修正它。這時很重要的是要停下來而且想一下:「這段代碼容易理解嗎?」若是容易,可能轉而關注其餘代碼是沒有問題的。
是的,要常常地想想其餘人是否是會以爲你的代碼容易理解,這須要額外的時間。這樣作就須要你打開大腦中從前在編碼時可能沒有打開的那部分功能。
但若是你接受了這個目標(像咱們同樣),咱們能夠確定你會成爲一個更好的程序員,會產生更少的缺陷,從工做中得到更多的自豪,而且編寫出你周圍人都愛用的代碼。那麼讓咱們開始吧!
咱們的可讀性之旅從咱們認爲「表面層次」的改進開始:選擇好的名字、寫好的註釋以及把代碼整潔地寫成更好的格式。這些改變很容易應用。你能夠在「原位」作這些改變而沒必要重構代碼或者改變程序的運行方式。你還能夠增量地作這些修改卻不須要投入大量的時間。
這些話題很重要,由於會影響到你代碼庫中的每行代碼。儘管每一個改變可能看上去都很小,彙集在一塊兒形成代碼庫巨大的改進。若是你的代碼有很棒的名字、寫得很好的註釋,而且整潔地使用了空白符,你的代碼會變得易讀得多。
固然,在表面層次之下還有不少關於可讀性的東西(咱們會在本書的後面涵蓋這些內容)。但這一部分的材料幾乎不費吹灰之力就應用得如此普遍,值得咱們首先討論。
不管是命名變量、函數仍是類,均可以使用不少相同的原則。咱們喜歡把名字當作一條小小的註釋。儘管空間不算很大,但選擇一個好名字可讓它承載不少信息。
咱們在程序中見到的不少名字都很模糊,例如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和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"在哪裏?缺陷!
然而,有些狀況下泛泛的名字也承載着意義。讓咱們來看看何時使用它們有意義。
請想象一下交換兩個變量的經典情形:
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()就更具體一些。
這個名字直接地描述了這個方法要作什麼事情。
下面的兩個例子更深刻地描繪了這個概念。
這個例子來自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這個名字,可是它有幾個問題:
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 有目的地使用大小寫、下劃線等——例如,你能夠在類成員和局部變量後面加上"_"來區分它們。
在前一章中,咱們講到了如何把信息塞入名字中。本章會關注另外一個話題:當心可能會有歧義的名字。
關鍵思想:要多問本身幾遍:「這個名字會被別人解讀成其餘的含義嗎?」要仔細審視這個名字。
若是想更有創意一點,那麼能夠主動地尋找「誤解點」。這一步能夠幫助你發現那些二義性名字並更改。
例如,在本章中,當咱們討論每個可能會誤解的名字時,咱們將在內心默讀,而後挑選更好的名字。
假設你在寫一段操做數據庫結果的代碼:
results = Database.all_objects.filter("year <= 2011")
結果如今包含哪些信息?
l 年份小於或等於 2011的對象?
l 年份不小於或等於2011年的對象?
這裏的問題是「filter」是個二義性單詞。咱們不清楚它的含義究竟是「挑出」仍是「減掉」。最好避免使用「filter」這個名字,由於它太容易誤解。
假設你有個函數用來剪切一個段落的內容:
# 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。
假設你的購物車應用程序最多不能超過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.")
下面是另外一個例子,你無法判斷它是「少於」仍是「少於且包含」:
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這兩個名字也適用於包含的範圍,若是它們在上下文中「聽上去合理」的話。
在實踐中,不少時候用包含/排除範圍更方便。例如,若是你想打印全部發生在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開始的方法當作「輕量級訪問器」這樣的用法,它只是簡單地返回一個內部成員變量。若是違背這個習慣極可能會誤導用戶。
如下是一個用Java寫的例子,請不要這樣作:
public class StatisticsCollector {
public void addSample(double x) { ... }
public double getMean() {
// Iterate through all samples and return total / num_samples
}
...
}
在這個例子中,getMean()的實現是要遍歷全部通過的數據並同時計算中值。若是有大量的數據的話,這樣的一步可能會有很大的代價!但一個容易輕信的程序員可能會隨意地調用getMean(),還覺得這是個沒什麼代價的調用。
相反,這個方法應當重命名爲像computeMean()這樣的名字,後者聽起來更像是有些代價的操做。(另外一種作法是,用新的實現方法使它真的成爲一個輕量級的操做。)
下面是一個來自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()是輕量的方法。
不少想法來源於雜誌的版面設計——段落的長度、欄的寬度、文章的順序以及把什麼東西放在封面上等。一本好的雜誌既能夠跳着看,也能夠從頭讀到尾,怎麼看都很容易。
好的源代碼應當「看上去養眼」。本章會告訴你們如何使用好的留白、對齊及順序來讓你的代碼變得更易讀。
確切地說,有三條原則:
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 用空行來把大塊代碼分紅邏輯上的「段落」。
本章旨在幫助你明白應該寫什麼樣的註釋。你可能覺得註釋的目的是「解釋代碼作了什麼」,但這只是其中很小的一部分。
當你寫代碼時,你的腦海裏會有不少有價值的信息。當其餘人讀你的代碼時,這些信息已經丟失了——他們所見到的只是眼前的代碼。
本章會展現許多例子來講明何時應該把你腦海中的信息寫下來。咱們略去了不少對註釋的世俗觀點,相對地,咱們更關注註釋有趣的和「匱乏的」方面。
咱們把本章組織成如下幾個部分:
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 用註釋來總結代碼塊,使讀者不致迷失在細節中。
前一章是關於發現什麼地方要寫註釋的。本章則是關於如何寫出言簡意賅的註釋。
若是你要寫註釋,最好把它寫得精確——越明確和細緻越好。另外,因爲註釋在屏幕上也要佔不少的地方,而且須要花更多的時間來讀,所以,註釋也須要很緊湊。
本章其他部分將舉例說明如何作到這一點。
下面的例子是一個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"——二、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,代碼會變得難以改變,而且使用它也沒那麼有趣了。
若是代碼中沒有條件判斷、循環或者任何其餘的控制流語句,那麼它的可讀性會很好。而跳轉和分支等困難部分則會很快地讓代碼變得混亂。本章就是關於如何把代碼中的控制流變得易讀的。
關鍵思想:把條件、循環以及其餘對控制流的改變作得越「天然」越好。運用一種方式使讀者不用停下來重讀你的代碼。
下面的兩段代碼哪一個更易讀?
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 (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。三目運算符?:只有在最簡單的狀況下使用。
不少推崇的編程語言,包括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;。
除了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常常會致使代碼的可讀性變差。最好不要使用它們,由於老是有更整潔的代替方式。
嵌套的代碼塊須要更加集中精力去理解。每層新的嵌套都須要讀者把更多的上下文「壓入棧」。應該把它們改寫成更加「線性」的代碼來避免深嵌套。
一般來說提前返回能夠減小嵌套並讓代碼整潔。「保護語句」(在函數頂部處理簡單的狀況時)尤爲有用。
巨型烏賊是一種神奇而又聰明的動物,但它近乎完美的身體設計有一個致命的弱點:在它的食管附近圍繞着圓環形的大腦。因此若是它一次吞太多的食物,它的大腦會受到傷害。
這和代碼有什麼關係?嗯,大段大段的代碼也可能會形成相似的效果。最近有研究代表,咱們大多數人同時只能考慮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語句內都沒有超過兩個值。這是理想狀況。可能不是總能作到這樣——有時須要把問題「反向」或者考慮目標的對立面。
最後,儘管本章是關於拆分獨立的表達式的,一樣,這些技術也常應用於大的代碼塊。因此,你能夠在任何見到複雜邏輯的地方大膽地去拆分它們。
在本章裏,你會看到對於變量的草率運用如何讓程序更難理解。確切地說,咱們會討論三個問題:
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++代碼:
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超出做用域後放心地忘掉它了。
假設你有一個長期存在的變量,只有一個函數會用到它:
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中,若是你在變量定義中省略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關鍵宇來定義變量」。這個方法把變量的做用域約束在定義它的(最內層)函數之中。
像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 先用天然語言描述代碼,而後用這個描述來幫助你找到更整潔的解決方案。
最後,咱們會討論你能夠把代碼徹底移除或者一開始就避免寫它的那些狀況——惟一可稱爲改進代碼可讀性的最佳方法。
所謂工程學就是關於把大問題拆分紅小問題再把這些問題的解決方案放回一塊兒。把這條原則應用於代碼會使代碼更健壯而且更容易讀。
本章的建議是「積極地發現並抽取出不相關的子邏輯」。咱們是指:
l 看看某個函數或代碼塊,問問你本身:這段代碼高層次的目標是什麼?
l 對於每一行代碼,問一下:它是直接爲了目標而工做嗎?這段代碼高層次的目標是什麼呢?
l 若是足夠的行數在解決不相關的子問題,抽取代碼到獨立的函數中。
你天天可能都會把代碼抽取到單獨的函數中。但在本章中,咱們決定關注抽取的一個特別情形:不相關的子問題,在這種情形下抽取出的函數無憂無慮,並不關心爲何會調用它。
你將會看到,這是個簡單的技巧卻能夠從根本上改進你的代碼。然而因爲某些緣由,不少程序員沒有充分使用這一技巧。這裏的訣竅就是主動地尋找那些不相關的子問題。
在本章中,咱們會看到幾個不一樣的例子,它們針對你將遇到的不一樣情形來說明這些技巧。
下面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》描述了「組合方法模式」,其中列出了幾條把代碼拆分紅得多小函數的原則。尤爲是其中的一條原則「把一個方法中的全部操做保持在一個抽象層次上」。
這些思想和咱們的建議「抽取不相關的子問題」類似。本章所討論的是抽取方法中的一個簡單而又特定的狀況。
同時在作幾件事的代碼很難理解。一個代碼塊可能初始化對象,清除數據,解析輸入,而後應用業務邏輯,全部這些都同時進行。若是全部這些代碼都糾纏在一塊兒,對於每一個「任務」都很難靠其自身來幫你理解它從哪裏開始,到哪裏結束。
關鍵思想:應該把代碼組織得一次只作一件事情。
換個說法,本章是關於如何給代碼「整理碎片」的。下圖演示了這個過程:左邊所示爲一段代碼所作的各類任務,右邊所示是同一段代碼在組織成一次只作一件事情後的樣子。
你也許據說過這個建議:「一個函數只應當作一件事」。咱們的建議和這差很少,但不是關於函數邊界的。固然,把一個大函數拆分紅多個小一些的函數是好的。可是就算你不這樣作,你仍然能夠在函數內部組織代碼,使得它感受像是有分開的邏輯段。
下面是用於使代碼「一次只作一件事」所用到的流程:
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章所提到的那樣,保存中間結果的 變量每每能夠徹底移除。
在這種方法裏,咱們簡單地把問題從不一樣的角度「切開」。兩種方法都頗有可讀性,由於它們讓讀者一次只須要思考一件事情。
本章給出了一個組織代碼的簡單技巧:一次只作一件事情。
若是你有很難讀的代碼,嘗試把它所作的全部任務列出來。其中一些任務能夠很容易地 變成單獨的函數(或類)。其餘的能夠簡單地成爲一個函數中的邏輯「段落」。具體如何拆分這些任務沒有它們已經分開這個事實那樣重要。難的是要準確地描述你的程序所作的全部這些小事情。
若是你不能把一件事解釋紿你祖母聽的話說明你尚未真正理解它。
阿爾伯特·愛因斯坦
當把一件複雜的事向別人解釋時,那些小細節很容易就會讓他們迷惑。把一個想法用 「天然語言」解釋是個頗有價值的能力,由於這樣其餘知識沒有你這麼淵博的人才能夠理解它。這須要把一個想法精煉成最重要的概念。這樣作不只幫助他人理解,並且也幫助你本身把這個想法想得更清晰。
在你把代碼「展現」給讀者時也應使用一樣的技巧。咱們接受代碼是你解釋程序所作事 情的主要手段這一關點。因此代碼應當用「天然語言」編寫。
在本章中,咱們會用一個簡單的過程來使你編寫更清晰的代碼:
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這樣的短名字,卻不用再考慮那些具體涉及的數據庫字段。
本章討論了一個簡單的技巧,用天然語言描述程序而後用這個描述來幫助你寫出更天然的代碼。這個技巧出人意料地簡單,但很強大。看到你在描述中所用的詞和短語還能夠幫助你發現哪些子問題能夠拆分出來。
可是這個「用天然語言說事情」的過程不只能夠用於寫代碼。例如,某個大學計算機實 驗室的規定聲稱當有學生須要別人幫它調試程序時,他首先要對房間角落的一隻專用的泰迪熊解釋他遇到的問題。使人驚訝的是,僅僅經過大聲把問題描述出來,每每就能幫這個學生找到解決的辦法。這個技巧叫作「橡皮鴨技術」。
另外一個看待這個問題的角度是:若是你不能把問題說明白或者用詞語來作設計,估計是缺乏了什麼東西或者什麼東西缺乏定義。把一個問題(或想法)變成語言真的可讓它更具體。
知道何時不寫代碼可能對於一個程序員來說是他所要學習的最重要的技巧。你所寫的每一行代碼都是要測試和維護的。經過重用庫或者減小功能,你能夠節省時間而且讓你的代碼庫保持精簡節約。
關鍵思想:最好讀的代碼就是沒有代碼。
當你開始一個項目,天然會很興奮而且想着你但願實現的全部很酷的功能。可是程序員傾向於高估有多少功能真的對於他們的項目來說是必不可少的。不少功能結果沒有完成,或者沒有用到,也可能只是讓程序更復雜。
程序員還傾向於低估實現一個功能所要花的工夫。咱們樂觀地估計了實現一個粗糙原型所要花的時間,可是忘記了在未來代碼庫的維護、文件以及後增的「重量」所帶來的額外時間。
不是全部的程序都須要運行得快,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寫的列表(如[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行代碼?我一分鐘就寫出來了!」
這裏的關鍵詞是「最終產品中的」。在一個成熟的庫中,每一行代碼都表明至關大量的設計、調試、重寫、文檔、優化和測試。任何經受了這樣達爾文進化過程同樣的代碼行就是頗有價值的。這就是爲何重用庫有這麼大的好處,不只節省時間,還少寫了代碼。
當一個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,保持對它們的熟悉程度。
本書前三部分覆蓋了使代碼簡單易讀的各類技巧。在該部分中,咱們會把這些技術應用在兩個精選出的話題中。
首先,咱們會討論測試——如何同時寫出有效而又可讀的測試。
而後,咱們會歷經一個設計和實現專門設計的數據結構的過程(一個「分鐘/小時計數器」),這將是一個性能與好的設計以及可讀性互動的例子。
在本章中,咱們會揭示一些寫出整潔而且有效測試的簡單技巧。
測試對不一樣的人意味着不一樣的事。在本章中,「測試」是指任何僅以檢查另外一段(「真實」)代碼的行爲爲目的的代碼。咱們會關注測試的可讀性方面,不會討論你是否應該在寫真實代碼以前寫測試代碼(「測試驅動的開發」)或者測試開發的其餘哲學方面。
測試代碼的可讀性和非測試代碼是一樣重要的。其餘程序員會常常來把測試代碼看作非正式的文檔,它記錄了真實代碼如何工做和應該如何使用。所以若是測試很容易閱讀,使用者對於真實代碼的行爲會有更好的理解。
關鍵思想:測試應當具備可讀性,以便其餘程序員能夠舒服地改變或者増加測試。
當測試代碼多得讓人望而止步,會發生下面的事情:
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(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"]
這更有幫助。
若是有的話,你應該使用這些更有幫助的斷言方法。每當你的測試失敗時,你就會受益。
在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>()這樣的名字。
最重要的是,要使它易於改動和增長新的測試。
讓咱們來看一件真實產品所用代碼中的數據結構:一個「分鐘/小時計數器」。咱們會帶你走過一個工程師可能會經歷的天然的思考過程,首先試着解決問題,而後改進它的性能和增長功能。最重要的是,咱們也會試着讓代碼保持易讀,就用本書中全部的原則。在這個過程當中咱們也會轉錯幾個地方,或者產生些其餘的錯誤。看看你能不能理解並找出這些地方。
咱們須要跟蹤在過去的一分鐘和一個小時裏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個月以後的你本身。
讓咱們來進入下一步,解決這個問題。咱們會從一個很直接的方案開始:就是保持一個有時間戳的「事件」列表:
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()的調用而更新。
咱們須要一個設計來解決前面提到的兩個問題:
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能用固定數量的內存。
你應該已經注意到,前面的兩個實現都有一個小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類了。再一次,咱們會建立一個輔助類來進一步拆分這個問題。
咱們會建立一個叫作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類:
// 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畢業於加州大學伯克利分校,得到電氣工程和計算機科學的本科學位。