版本/狀態ios |
做者程序員 |
參與者面試 |
起止日期算法 |
備註編程 |
V 0.9數組 草稿文件安全 |
林銳網絡
|
|
2001-7-1至數據結構 2001-7-18數據結構和算法 |
林銳起草 |
V 1.0 正式文件 |
林銳
|
|
2001-7-18至 2001-7-24 |
朱洪海審查V 0.9, 林銳修正草稿中的錯誤 |
|
|
|
|
|
|
|
|
|
|
目 錄
7.8 有了malloc/free爲何還要new/delete ?... 52
9.4 示例:類String的構造函數與析構函數... 72
9.6 示例:類String的拷貝構造函數與賦值函數... 73
軟件質量是被大多數程序員掛在嘴上而不是放在心上的東西!
除了徹底外行和真正的編程高手外,初讀本書,你最早的感覺將是驚慌:「哇!我之前捏造的C++/C程序怎麼會有那麼多的毛病?」
別難過,做者只不過比你早幾年、多幾回驚慌而已。
請花一兩個小時認真閱讀這本百頁經書,你將會獲益匪淺,這是前面N-1個讀者的建議。
1、編程老手與高手的誤區
自從計算機問世以來,程序設計就成了使人羨慕的職業,程序員在受人寵愛以後容易發展成爲毛病特多卻常能自我臭美的羣體。
現在在Internet上流傳的「真正」的程序員聽說是這樣的:
(1) 真正的程序員沒有進度表,只有討好領導的馬屁精纔有進度表,真正的程序員會讓領導提心吊膽。
(2) 真正的程序員不寫使用說明書,用戶應當本身去猜測程序的功能。
(3) 真正的程序員幾乎不寫代碼的註釋,若是註釋很難寫,它理所固然也很難讀。
(4) 真正的程序員不畫流程圖,原始人和文盲纔會幹這事。
(5) 真正的程序員不看參考手冊,新手和膽小鬼才會看。
(6) 真正的程序員不寫文檔也不須要文檔,只有看不懂程序的笨蛋才用文檔。
(7) 真正的程序員認爲本身比用戶更明白用戶須要什麼。
(8) 真正的程序員不接受團隊開發的理念,除非他本身是頭頭。
(9) 真正的程序員的程序不會在第一次就正確運行,可是他們願意守着機器進行若干個30小時的調試改錯。
(10)真正的程序員不會在上午9:00到下午5:00之間工做,若是你看到他在上午9:00工做,這代表他從昨晚一直幹到如今。
……
具有上述特徵越多,越顯得水平高,資格老。因此別奇怪,程序員的不少缺點居然能夠被看成優勢來欣賞。就象在武俠小說中,那些獨來獨往、不受約束且帶點邪氣的高手最使人崇拜。我曾經也這樣信奉,而且但願本身成爲那樣的「真正」的程序員,結果沒有獲得好下場。
我從讀大學到博士畢業十年來一直勤奮好學,累計編寫了數十萬行C++/C代碼。有這樣的苦勞和疲勞,我應該稱得上是編程老手了吧?
我開發的軟件都與科研相關(集成電路CAD和3D圖形學領域),動輒數萬行程序,技術複雜,難度頗高。這些軟件頻頻獲獎,有一個軟件得到首屆中國大學生電腦大賽軟件展現一等獎。在1995年開發的一套圖形軟件庫到2000年還有人買。羅列出這些「業績」,能夠說明我算得上是編程高手了吧?
惋惜這種我的感受不等於事實。
讀博期間我曾用一年時間開發了一個近10萬行C++代碼的3D圖形軟件產品,我心裏得意表面謙虛地向一位真正的軟件高手請教。他雖然從未涉足過3D圖形領域,卻在幾十分鐘內指出該軟件多處重大設計錯誤。讓人感受那套軟件是用紙糊的華麗衣服,扯一下掉一塊,戳一下破個洞。我目瞪口呆地意識到這套軟件毫無實用價值,一年的心血白化了,而且害死了本身的軟件公司。
人的頓悟一般發生在最心痛的時刻,在沮喪和心痛以後,我做了深入檢討,「面壁」半年,從新溫習軟件設計的基礎知識。補修「內功」以後,又以爲腰板硬了起來。博士畢業前半年,我曾到微軟中國研究院找工做,接受微軟公司一位資深軟件工程師的面試。他讓我寫函數strcpy的代碼。
太容易了吧?
錯!
這麼一個小不點的函數,他從三個方面考查:
(1)編程風格;
(2)出錯處理;
(3)算法複雜度分析(用於提升性能)。
在大學裏歷來沒有人如此嚴格地考查過個人程序。我化了半個小時,修改了數次,他還不盡滿意,讓我回家好好琢磨。我精神抖擻地進「考場」,大汗淋漓地出「考場」。這「高手」當得也太窩囊了。我又好好地檢討了一次。
我把檢討後的心得體會寫成文章放在網上傳閱,引發了很多軟件開發人員的共鳴。我所以有幸和國產大型IT企業如華爲、上海貝爾、中興等公司的同志們普遍交流。你們認爲提升質量與生產率是軟件工程要解決的核心問題。高質量程序設計是很是重要的環節,畢竟軟件是靠編程來實現的。
咱們心目中的老手們和高手們可否編寫出高質量的程序來?
不見得都能!
就個人經歷與閱從來看,國內大學的計算機教育壓根就沒有灌輸高質量程序設計的觀念,教師們和學生們也不多自覺關心軟件的質量。勤奮好學的程序員長期在低質量的程序堆中滾爬,吃盡苦頭以後纔有一些心得體會,長進極慢,我就是一例。
如今國內IT企業擁有學士、碩士、博士文憑的軟件開發人員比比皆是,但他們在接受大學教育時就「先天不足」,豈能一到企業就忽然實現質的飛躍。試問有多少軟件開發人員對正確性、健壯性、可靠性、效率、易用性、可讀性(可理解性)、可擴展性、可複用性、兼容性、可移植性等質量屬性瞭如指掌?而且能在實踐中運用自如?。「高質量」可不是幹活當心點就能實現的!
咱們有充分的理由疑慮:
(1)編程老手可能會長期用隱含錯誤的方式編程(習慣成天然),發現毛病後都不肯相信那是真的!
(2)編程高手能夠在某一領域寫出極有水平的代碼,但未必能從全局把握軟件質量的方方面面。
事實證實如此。我到上海貝爾工做一年來,陸續面試或測試過近百名「新」「老」程序員的編程技能,質量合格率大約是10%。不多有人可以寫出徹底符合質量要求的if語句,不少程序員對指針、內存管理只知其一;不知其二,……。
領導們不敢相信這是真的。我作過現場試驗:有一次部門新進14名碩士生,在開歡迎會以前對他們進行「C++/C編程技能」摸底考試。我問你們試題難不難?全部的人都回答不難。結果沒有一我的及格,有半數人得零分。競爭對手公司的朋友們也作過試驗,一樣一敗塗地。
真的不是我「心狠手辣」或者要求太高,而是不少軟件開發人員對本身的要求不夠高。
要知道華爲、上海貝爾、中興等公司的員工素質在國內IT企業中是比較前列的,假若他們的編程質量都如此差的話,咱們怎麼敢指望中小公司拿出高質量的軟件呢?連程序都編很差,還談什麼振興民族軟件產業,豈不胡扯。
我打算定義編程老手和編程高手,請您別見笑。
定義1:能長期穩定地編寫出高質量程序的程序員稱爲編程老手。
定義2:能長期穩定地編寫出高難度、高質量程序的程序員稱爲編程高手。
根據上述定義,立刻獲得第一推論:我既不是高手也算不上是老手。
在寫此書前,我閱讀了很多程序設計方面的英文著做,越看越羞慚。由於發現本身連編程基本技能都未能全面掌握,頂多算是二流水平,還好意思談什麼老手和高手。但願和我同樣在國內土生土長的程序員朋友們可以作到:
(1)知錯就改;
(2)常常溫故而知新;
(3)堅持學習,每天向上。
2、本書導讀
首先請作附錄B的C++/C試題(不要看答案),考查本身的編程質量究竟如何。而後參照答案嚴格打分。
(1)若是你只得了幾十分,請不要聲張,也不要太難過。編程質量差每每是因爲不良習慣形成的,與人的智力、能力沒有多大關係,仍是有藥可救的。成績越差,能夠進步的空間就越大,中國不就是在落後中趕超發達資本主義國家嗎?只要你能下決心改掉不良的編程習慣,第二次考試就能及格了。
(2)若是你考及格了,代表你的技術基礎不錯,但願你能虛心學習、不斷進步。若是你尚未找到合適的工做單位,不妨到上海貝爾試一試。
(3)若是你考出85分以上的好成績,你有義務和資格爲你所在的團隊做「C++/C編程」培訓。但願你能和咱們多多交流、相互促進。半年前我曾經發現一顆好苗子,就把他挖到咱們小組來。
(4)若是你在沒有任何提示的狀況下考了滿分,但願你能收我作你的徒弟。
編程考試結束後,請閱讀本書的正文。
本書第一章至第六章主要論述C++/C編程風格。難度不高,可是細節比較多。別小看了,提升質量就是要從這些點點滴滴作起。世上不存在最好的編程風格,一切因需求而定。團隊開發講究風格一致,若是制定了你們承認的編程風格,那麼全部組員都要遵照。若是讀者以爲本書的編程風格比較合你的工做,那麼就採用它,不要只看不作。人在小時候說話發音不許,寫字潦草,若是不改正,總有後悔的時候。編程也是一樣道理。
第七章至第十一章是專題論述,技術難度比較高,看書時要積極思考。特別是第七章「內存管理」,讀了並不表示懂了,懂了並不表示就能正確使用。有一位同事看了第七章後以爲「野指針」寫得不錯,與我切磋了一把。但是過了兩週,他告訴我,他忙了兩天追查出一個Bug,想不到又是「野指針」出問題,只好重讀第七章。
光看本書對提升編程質量是有限的,建議你們閱讀本書的參考文獻,那些都是經典名著。
若是你的編程質量已通過關了,不要就此知足。若是你想成爲優秀的軟件開發人員,建議你閱讀並按照CMMI規範作事,讓本身的綜合水平上升一個臺階。上海貝爾的員工能夠向網絡應用事業部軟件工程研究小組索取CMMI有關資料,最好能參加培訓。
3、版權聲明
本書的大部份內容取材於做者一年前的書籍手稿(還沒有出版),現整理彙編成爲上海貝爾網絡應用事業部的一個規範化文件,同時做爲培訓教材。
因爲C++/C編程是衆所周知的技術,沒有祕密可言。編程的好經驗應該你們共享,咱們本身也是這麼學來的。做者願意公開本書的電子文檔。
版權聲明以下:
(1)讀者能夠任意拷貝、修改本書的內容,但不能夠篡改做者及所屬單位。
(2)未經做者許可,不得出版或大量印發本書。
(3)若是競爭對手公司的員工獲得本書,請勿公開使用,以避免發生糾紛。
預計到2002年7月,咱們將創建切合中國國情的CMMI 3級解決方案。屆時,包括本書在內的約1000頁規範將嚴格受控。
歡迎讀者對本書提出批評建議。
林銳,2001年7月
每一個C++/C程序一般分爲兩個文件。一個文件用於保存程序的聲明(declaration),稱爲頭文件。另外一個文件用於保存程序的實現(implementation),稱爲定義(definition)文件。
C++/C程序的頭文件以「.h」爲後綴,C程序的定義文件以「.c」爲後綴,C++程序的定義文件一般以「.cpp」爲後綴(也有一些系統以「.cc」或「.cxx」爲後綴)。
版權和版本的聲明位於頭文件和定義文件的開頭(參見示例1-1),主要內容有:
(1)版權信息。
(2)文件名稱,標識符,摘要。
(3)當前版本號,做者/修改者,完成日期。
(4)版本歷史信息。
/* * Copyright (c) 2001,上海貝爾有限公司網絡應用事業部 * All rights reserved. * * 文件名稱:filename.h * 文件標識:見配置管理計劃書 * 摘 要:簡要描述本文件的內容 * * 當前版本:1.1 * 做 者:輸入做者(或修改者)名字 * 完成日期:2001年7月20日 * * 取代版本:1.0 * 原做者 :輸入原做者(或修改者)名字 * 完成日期:2001年5月10日 */
|
示例1-1 版權和版本的聲明
頭文件由三部份內容組成:
(1)頭文件開頭處的版權和版本聲明(參見示例1-1)。
(2)預處理塊。
(3)函數和類結構聲明等。
假設頭文件名稱爲 graphics.h,頭文件的結構參見示例1-2。
l 【規則1-2-1】爲了防止頭文件被重複引用,應當用ifndef/define/endif結構產生預處理塊。
l 【規則1-2-2】用 #include <filename.h> 格式來引用標準庫的頭文件(編譯器將從標準庫目錄開始搜索)。
l 【規則1-2-3】用 #include 「filename.h」 格式來引用非標準庫的頭文件(編譯器將從用戶的工做目錄開始搜索)。
² 【建議1-2-1】頭文件中只存放「聲明」而不存放「定義」
在C++ 語法中,類的成員函數能夠在聲明的同時被定義,而且自動成爲內聯函數。這雖然會帶來書寫上的方便,但卻形成了風格不一致,弊大於利。建議將成員函數的定義與聲明分開,不論該函數體有多麼小。
² 【建議1-2-2】不提倡使用全局變量,儘可能不要在頭文件中出現象extern int value 這類聲明。
// 版權和版本聲明見示例1-1,此處省略。
#ifndef GRAPHICS_H // 防止graphics.h被重複引用 #define GRAPHICS_H
#include <math.h> // 引用標準庫的頭文件 … #include 「myheader.h」 // 引用非標準庫的頭文件 … void Function1(…); // 全局函數聲明 … class Box // 類結構聲明 { … }; #endif |
示例1-2 C++/C頭文件的結構
定義文件有三部份內容:
(1) 定義文件開頭處的版權和版本聲明(參見示例1-1)。
(2) 對一些頭文件的引用。
(3) 程序的實現體(包括數據和代碼)。
假設定義文件的名稱爲 graphics.cpp,定義文件的結構參見示例1-3。
// 版權和版本聲明見示例1-1,此處省略。
#include 「graphics.h」 // 引用頭文件 …
// 全局函數的實現體 void Function1(…) { … }
// 類成員函數的實現體 void Box::Draw(…) { … } |
示例1-3 C++/C定義文件的結構
早期的編程語言如Basic、Fortran沒有頭文件的概念,C++/C語言的初學者雖然會用使用頭文件,但經常不明其理。這裏對頭文件的做用略做解釋:
(1)經過頭文件來調用庫功能。在不少場合,源代碼不便(或不許)向用戶公佈,只要向用戶提供頭文件和二進制的庫便可。用戶只須要按照頭文件中的接口聲明來調用庫功能,而沒必要關心接口怎麼實現的。編譯器會從庫中提取相應的代碼。
(2)頭文件能增強類型安全檢查。若是某個接口被實現或被使用時,其方式與頭文件中的聲明不一致,編譯器就會指出錯誤,這一簡單的規則能大大減輕程序員調試、改錯的負擔。
若是一個軟件的頭文件數目比較多(如超過十個),一般應將頭文件和定義文件分別保存於不一樣的目錄,以便於維護。
例如可將頭文件保存於include目錄,將定義文件保存於source目錄(能夠是多級目錄)。
若是某些頭文件是私有的,它不會被用戶的程序直接引用,則沒有必要公開其「聲明」。爲了增強信息隱藏,這些私有的頭文件能夠和定義文件存放於同一個目錄。
版式雖然不會影響程序的功能,但會影響可讀性。程序的版式追求清晰、美觀,是程序風格的重要構成因素。
能夠把程序的版式比喻爲「書法」。好的「書法」可以讓人對程序一目瞭然,看得興致勃勃。差的程序「書法」如螃蟹爬行,讓人看得索然無味,更令維護者煩惱有加。請程序員們學習程序的「書法」,彌補大學計算機教育的漏洞,實在頗有必要。
空行起着分隔程序段落的做用。空行得體(不過多也不過少)將使程序的佈局更加清晰。空行不會浪費內存,雖然打印含有空行的程序是會多消耗一些紙張,可是值得。因此不要捨不得用空行。
l 【規則2-1-1】在每一個類聲明以後、每一個函數定義結束以後都要加空行。參見示例2-1(a)
l 【規則2-1-2】在一個函數體內,邏揖上密切相關的語句之間不加空行,其它地方應加空行分隔。參見示例2-1(b )
// 空行 void Function1(…) { … } // 空行 void Function2(…) { … } // 空行 void Function3(…) { … }
|
// 空行 while (condition) { statement1; // 空行 if (condition) { statement2; } else { statement3; } // 空行 statement4; } |
示例2-1(a) 函數之間的空行 示例2-1(b) 函數內部的空行
l 【規則2-2-1】一行代碼只作一件事情,如只定義一個變量,或只寫一條語句。這樣的代碼容易閱讀,而且方便於寫註釋。
l 【規則2-2-2】if、for、while、do等語句自佔一行,執行語句不得緊跟其後。不論執行語句有多少都要加{}。這樣能夠防止書寫失誤。
示例2-2(a)爲風格良好的代碼行,示例2-2(b)爲風格不良的代碼行。
int width; // 寬度 int height; // 高度 int depth; // 深度 |
int width, height, depth; // 寬度高度深度
|
x = a + b; y = c + d; z = e + f; |
X = a + b; y = c + d; z = e + f;
|
if (width < height) { dosomething(); } |
if (width < height) dosomething(); |
for (initialization; condition; update) { dosomething(); } // 空行 other();
|
for (initialization; condition; update) dosomething(); other();
|
示例2-2(a) 風格良好的代碼行 示例2-2(b) 風格不良的代碼行
² 【建議2-2-1】儘量在定義變量的同時初始化該變量(就近原則)
若是變量的引用處和其定義處相隔比較遠,變量的初始化很容易被忘記。若是引用了未被初始化的變量,可能會致使程序錯誤。本建議能夠減小隱患。例如
int width = 10; // 定義並初紿化width
int height = 10; // 定義並初紿化height
int depth = 10; // 定義並初紿化depth
l 【規則2-3-1】關鍵字以後要留空格。象const、virtual、inline、case 等關鍵字以後至少要留一個空格,不然沒法辨析關鍵字。象if、for、while等關鍵字以後應留一個空格再跟左括號‘(’,以突出關鍵字。
l 【規則2-3-2】函數名以後不要留空格,緊跟左括號‘(’,以與關鍵字區別。
l 【規則2-3-3】‘(’向後緊跟,‘)’、‘,’、‘;’向前緊跟,緊跟處不留空格。
l 【規則2-3-4】‘,’以後要留空格,如Function(x, y, z)。若是‘;’不是一行的結束符號,其後要留空格,如for (initialization; condition; update)。
l 【規則2-3-5】賦值操做符、比較操做符、算術操做符、邏輯操做符、位域操做符,如「=」、「+=」 「>=」、「<=」、「+」、「*」、「%」、「&&」、「||」、「<<」,「^」等二元操做符的先後應當加空格。
l 【規則2-3-6】一元操做符如「!」、「~」、「++」、「--」、「&」(地址運算符)等先後不加空格。
l 【規則2-3-7】象「[]」、「.」、「->」這類操做符先後不加空格。
² 【建議2-3-1】對於表達式比較長的for語句和if語句,爲了緊湊起見能夠適當地去掉一些空格,如for (i=0; i<10; i++)和if ((a<=b) && (c<=d))
void Func1(int x, int y, int z); // 良好的風格 void Func1 (int x,int y,int z); // 不良的風格 |
if (year >= 2000) // 良好的風格 if(year>=2000) // 不良的風格 if ((a>=b) && (c<=d)) // 良好的風格 if(a>=b&&c<=d) // 不良的風格 |
for (i=0; i<10; i++) // 良好的風格 for(i=0;i<10;i++) // 不良的風格 for (i = 0; I < 10; i ++) // 過多的空格 |
x = a < b ? a : b; // 良好的風格 x=a<b?a:b; // 很差的風格 |
int *x = &y; // 良好的風格 int * x = & y; // 不良的風格 |
array[5] = 0; // 不要寫成 array [ 5 ] = 0; a.Function(); // 不要寫成 a . Function(); b->Function(); // 不要寫成 b -> Function();
|
示例2-3 代碼行內的空格
l 【規則2-4-1】程序的分界符‘{’和‘}’應獨佔一行而且位於同一列,同時與引用它們的語句左對齊。
l 【規則2-4-2】{ }以內的代碼塊在‘{’右邊數格處左對齊。
示例2-4(a)爲風格良好的對齊,示例2-4(b)爲風格不良的對齊。
void Function(int x) { … // program code } |
void Function(int x){ … // program code }
|
if (condition) { … // program code } else { … // program code } |
if (condition){ … // program code } else { … // program code } |
for (initialization; condition; update) { … // program code } |
for (initialization; condition; update){ … // program code } |
While (condition) { … // program code } |
while (condition){ … // program code } |
若是出現嵌套的{},則使用縮進對齊,如: { … { … } … } |
|
示例2-4(a) 風格良好的對齊 示例2-4(b) 風格不良的對齊
l 【規則2-5-1】代碼行最大長度宜控制在70至80個字符之內。代碼行不要過長,不然眼睛看不過來,也不便於打印。
l 【規則2-5-2】長表達式要在低優先級操做符處拆分紅新行,操做符放在新行之首(以便突出操做符)。拆分出的新行要進行適當的縮進,使排版整齊,語句可讀。
if ((very_longer_variable1 >= very_longer_variable12) && (very_longer_variable3 <= very_longer_variable14) && (very_longer_variable5 <= very_longer_variable16)) { dosomething(); } |
virtual CMatrix CMultiplyMatrix (CMatrix leftMatrix, CMatrix rightMatrix);
|
for (very_longer_initialization; very_longer_condition; very_longer_update) { dosomething(); } |
示例2-5 長行的拆分
修飾符 * 和 & 應該靠近數據類型仍是該靠近變量名,是個有爭議的活題。
若將修飾符 * 靠近數據類型,例如:int* x; 從語義上講此寫法比較直觀,即x是int 類型的指針。
上述寫法的弊端是容易引發誤解,例如:int* x, y; 此處y容易被誤解爲指針變量。雖然將x和y分行定義能夠避免誤解,但並非人人都願意這樣作。
l 【規則2-6-1】應當將修飾符 * 和 & 緊靠變量名
例如:
char *name;
int *x, y; // 此處y不會被誤解爲指針
C語言的註釋符爲「/*…*/」。C++語言中,程序塊的註釋常採用「/*…*/」,行註釋通常採用「//…」。註釋一般用於:
(1)版本、版權聲明;
(2)函數接口說明;
(3)重要的代碼行或段落提示。
雖然註釋有助於理解代碼,但注意不可過多地使用註釋。參見示例2-6。
l 【規則2-7-1】註釋是對代碼的「提示」,而不是文檔。程序中的註釋不可喧賓奪主,註釋太多了會讓人眼花繚亂。註釋的花樣要少。
l 【規則2-7-2】若是代碼原本就是清楚的,則沒必要加註釋。不然畫蛇添足,使人厭煩。例如
i++; // i 加 1,多餘的註釋
l 【規則2-7-3】邊寫代碼邊註釋,修改代碼同時修改相應的註釋,以保證註釋與代碼的一致性。再也不有用的註釋要刪除。
l 【規則2-7-4】註釋應當準確、易懂,防止註釋有二義性。錯誤的註釋不但無益反而有害。
l 【規則2-7-5】儘可能避免在註釋中使用縮寫,特別是不經常使用縮寫。
l 【規則2-7-6】註釋的位置應與被描述的代碼相鄰,能夠放在代碼的上方或右方,不可放在下方。
l 【規則2-7-8】當代碼比較長,特別是有多重嵌套時,應當在一些段落的結束處加註釋,便於閱讀。
/* * 函數介紹: * 輸入參數: * 輸出參數: * 返回值 : */ void Function(float x, float y, float z) { … } |
if (…) { … while (…) { … } // end of while … } // end of if |
示例2-6 程序的註釋
類能夠將數據和函數封裝在一塊兒,其中函數表示了類的行爲(或稱服務)。類提供關鍵字public、protected和private,分別用於聲明哪些數據和函數是公有的、受保護的或者是私有的。這樣能夠達到信息隱藏的目的,即讓類僅僅公開必需要讓外界知道的內容,而隱藏其它一切內容。咱們不能夠濫用類的封裝功能,不要把它當成火鍋,什麼東西都往裏扔。
類的版式主要有兩種方式:
(1)將private類型的數據寫在前面,而將public類型的函數寫在後面,如示例8-3(a)。採用這種版式的程序員主張類的設計「以數據爲中心」,重點關注類的內部結構。
(2)將public類型的函數寫在前面,而將private類型的數據寫在後面,如示例8.3(b)採用這種版式的程序員主張類的設計「以行爲爲中心」,重點關注的是類應該提供什麼樣的接口(或服務)。
不少C++教課書受到Biarne Stroustrup第一本著做的影響,不知不覺地採用了「以數據爲中心」的書寫方式,並不見得有多少道理。
我建議讀者採用「以行爲爲中心」的書寫方式,即首先考慮類應該提供什麼樣的函數。這是不少人的經驗——「這樣作不只讓本身在設計類時思路清晰,並且方便別人閱讀。由於用戶最關心的是接口,誰願意先看到一堆私有數據成員!」
class A { private: int i, j; float x, y; … public: void Func1(void); void Func2(void); … } |
class A { public: void Func1(void); void Func2(void); … private: int i, j; float x, y; … } |
示例8.3(a) 以數據爲中心版式 示例8.3(b) 以行爲爲中心的版式
比較著名的命名規則當推Microsoft公司的「匈牙利」法,該命名規則的主要思想是「在變量和函數名中加入前綴以增進人們對程序的理解」。例如全部的字符變量均以ch爲前綴,如果指針變量則追加前綴p。若是一個變量由ppch開頭,則代表它是指向字符指針的指針。
「匈牙利」法最大的缺點是煩瑣,例如
int i, j, k;
float x, y, z;
假若採用「匈牙利」命名規則,則應當寫成
int iI, iJ, ik; // 前綴 i表示int類型
float fX, fY, fZ; // 前綴 f表示float類型
如此煩瑣的程序會讓絕大多數程序員沒法忍受。
據考察,沒有一種命名規則可讓全部的程序員贊同,程序設計教科書通常都不指定命名規則。命名規則對軟件產品而言並非「成敗悠關」的事,咱們不要化太多精力試圖發明世界上最好的命名規則,而應當制定一種令大多數項目成員滿意的命名規則,並在項目中貫徹實施。
本節論述的共性規則是被大多數程序員採納的,咱們應當在遵循這些共性規則的前提下,再擴充特定的規則,如3.2節。
l 【規則3-1-1】標識符應當直觀且能夠拼讀,可望文知意,沒必要進行「解碼」。
標識符最好採用英文單詞或其組合,便於記憶和閱讀。切忌使用漢語拼音來命名。程序中的英文單詞通常不會太複雜,用詞應當準確。例如不要把CurrentValue寫成NowValue。
l 【規則3-1-2】標識符的長度應當符合「min-length && max-information」原則。
幾十年前老ANSI C規定名字不許超過6個字符,現今的C++/C再也不有此限制。通常來講,長名字能更好地表達含義,因此函數名、變量名、類名長達十幾個字符不足爲怪。那麼名字是否越長約好?不見得!例如變量名maxval就比maxValueUntilOverflow好用。單字符的名字也是有用的,常見的如i,j,k,m,n,x,y,z等,它們一般可用做函數內的局部變量。
l 【規則3-1-3】命名規則儘可能與所採用的操做系統或開發工具的風格保持一致。
例如Windows應用程序的標識符一般採用「大小寫」混排的方式,如AddChild。而Unix應用程序的標識符一般採用「小寫加下劃線」的方式,如add_child。別把這兩類風格混在一塊兒用。
l 【規則3-1-4】程序中不要出現僅靠大小寫區分的類似的標識符。
例如:
int x, X; // 變量x 與 X 容易混淆
void foo(int x); // 函數foo 與FOO容易混淆
void FOO(float x);
l 【規則3-1-5】程序中不要出現標識符徹底相同的局部變量和全局變量,儘管二者的做用域不一樣而不會發生語法錯誤,但會令人誤解。
l 【規則3-1-6】變量的名字應當使用「名詞」或者「形容詞+名詞」。
例如:
float value;
float oldValue;
float newValue;
l 【規則3-1-7】全局函數的名字應當使用「動詞」或者「動詞+名詞」(動賓詞組)。類的成員函數應當只使用「動詞」,被省略掉的名詞就是對象自己。
例如:
DrawBox(); // 全局函數
box->Draw(); // 類的成員函數
l 【規則3-1-8】用正確的反義詞組命名具備互斥意義的變量或相反動做的函數等。
例如:
int minValue;
int maxValue;
int SetValue(…);
int GetValue(…);
² 【建議3-1-1】儘可能避免名字中出現數字編號,如Value1,Value2等,除非邏輯上的確須要編號。這是爲了防止程序員偷懶,不願爲命名動腦筋而致使產生無心義的名字(由於用數字編號最省事)。
做者對「匈牙利」命名規則作了合理的簡化,下述的命名規則簡單易用,比較適合於Windows應用軟件的開發。
l 【規則3-2-1】類名和函數名用大寫字母開頭的單詞組合而成。
例如:
class Node; // 類名
class LeafNode; // 類名
void Draw(void); // 函數名
void SetValue(int value); // 函數名
l 【規則3-2-2】變量和參數用小寫字母開頭的單詞組合而成。
例如:
BOOL flag;
int drawMode;
l 【規則3-2-3】常量全用大寫的字母,用下劃線分割單詞。
例如:
const int MAX = 100;
const int MAX_LENGTH = 100;
l 【規則3-2-4】靜態變量加前綴s_(表示static)。
例如:
void Init(…)
{
static int s_initValue; // 靜態變量
…
}
l 【規則3-2-5】若是不得已須要全局變量,則使全局變量加前綴g_(表示global)。
例如:
int g_howManyPeople; // 全局變量
int g_howMuchMoney; // 全局變量
l 【規則3-2-6】類的數據成員加前綴m_(表示member),這樣能夠避免數據成員與成員函數的參數同名。
例如:
void Object::SetValue(int width, int height)
{
m_width = width;
m_height = height;
}
l 【規則3-2-7】爲了防止某一軟件庫中的一些標識符和其它軟件庫中的衝突,能夠爲各類標識符加上能反映軟件性質的前綴。例如三維圖形標準OpenGL的全部庫函數均以gl開頭,全部常量(或宏定義)均以GL開頭。
讀者可能懷疑:連if、for、while、goto、switch這樣簡單的東西也要探討編程風格,是否是小題大作?
我真的發覺不少程序員用隱含錯誤的方式寫表達式和基本語句,我本身也犯過相似的錯誤。
表達式和語句都屬於C++/C的短語結構語法。它們看似簡單,但使用時隱患比較多。本章概括了正確使用表達式和語句的一些規則與建議。
C++/C語言的運算符有數十個,運算符的優先級與結合律如表4-1所示。注意一元運算符 + - * 的優先級高於對應的二元運算符。
優先級 |
運算符 |
結合律 |
從
高
到
低
排
列 |
( ) [ ] -> . |
從左至右 |
! ~ ++ -- (類型) sizeof + - * & |
從右至左
|
|
* / % |
從左至右 |
|
+ - |
從左至右 |
|
<< >> |
從左至右 |
|
< <= > >= |
從左至右 |
|
== != |
從左至右 |
|
& |
從左至右 |
|
^ |
從左至右 |
|
| |
從左至右 |
|
&& |
從左至右 |
|
|| |
從右至左 |
|
?: |
從右至左 |
|
= += -= *= /= %= &= ^= |= <<= >>= |
從左至右 |
表4-1 運算符的優先級與結合律
l 【規則4-1-1】若是代碼行中的運算符比較多,用括號肯定表達式的操做順序,避免使用默認的優先級。
因爲將表4-1熟記是比較困難的,爲了防止產生歧義並提升可讀性,應當用括號肯定表達式的操做順序。例如:
word = (high << 8) | low
if ((a | b) && (a & c))
如 a = b = c = 0這樣的表達式稱爲複合表達式。容許複合表達式存在的理由是:(1)書寫簡潔;(2)能夠提升編譯效率。但要防止濫用複合表達式。
l 【規則4-2-1】不要編寫太複雜的複合表達式。
例如:
i = a >= b && c < d && c + f <= g + h ; // 複合表達式過於複雜
l 【規則4-2-2】不要有多用途的複合表達式。
例如:
d = (a = b + c) + r ;
該表達式既求a值又求d值。應該拆分爲兩個獨立的語句:
a = b + c;
d = a + r;
l 【規則4-2-3】不要把程序中的複合表達式與「真正的數學表達式」混淆。
例如:
if (a < b < c) // a < b < c是數學表達式而不是程序表達式
並不表示
if ((a<b) && (b<c))
而是成了使人費解的
if ( (a<b)<c )
if語句是C++/C語言中最簡單、最經常使用的語句,然而不少程序員用隱含錯誤的方式寫if語句。本節以「與零值比較」爲例,展開討論。
4.3.1 布爾變量與零值比較
l 【規則4-3-1】不可將布爾變量直接與TRUE、FALSE或者1、0進行比較。
根據布爾類型的語義,零值爲「假」(記爲FALSE),任何非零值都是「真」(記爲TRUE)。TRUE的值到底是什麼並無統一的標準。例如Visual C++ 將TRUE定義爲1,而Visual Basic則將TRUE定義爲-1。
假設布爾變量名字爲flag,它與零值比較的標準if語句以下:
if (flag) // 表示flag爲真
if (!flag) // 表示flag爲假
其它的用法都屬於不良風格,例如:
if (flag == TRUE)
if (flag == 1 )
if (flag == FALSE)
if (flag == 0)
4.3.2 整型變量與零值比較
l 【規則4-3-2】應當將整型變量用「==」或「!=」直接與0比較。
假設整型變量的名字爲value,它與零值比較的標準if語句以下:
if (value == 0)
if (value != 0)
不可模仿布爾變量的風格而寫成
if (value) // 會讓人誤解 value是布爾變量
if (!value)
4.3.3 浮點變量與零值比較
l 【規則4-3-3】不可將浮點變量用「==」或「!=」與任何數字比較。
千萬要留意,不管是float仍是double類型的變量,都有精度限制。因此必定要避免將浮點變量用「==」或「!=」與數字比較,應該設法轉化成「>=」或「<=」形式。
假設浮點變量的名字爲x,應當將
if (x == 0.0) // 隱含錯誤的比較
轉化爲
if ((x>=-EPSINON) && (x<=EPSINON))
其中EPSINON是容許的偏差(即精度)。
4.3.4 指針變量與零值比較
l 【規則4-3-4】應當將指針變量用「==」或「!=」與NULL比較。
指針變量的零值是「空」(記爲NULL)。儘管NULL的值與0相同,可是二者意義不一樣。假設指針變量的名字爲p,它與零值比較的標準if語句以下:
if (p == NULL) // p與NULL顯式比較,強調p是指針變量
if (p != NULL)
不要寫成
if (p == 0) // 容易讓人誤解p是整型變量
if (p != 0)
或者
if (p) // 容易讓人誤解p是布爾變量
if (!p)
4.3.5 對if語句的補充說明
有時候咱們可能會看到 if (NULL == p) 這樣古怪的格式。不是程序寫錯了,是程序員爲了防止將 if (p == NULL) 誤寫成 if (p = NULL),而有意把p和NULL顛倒。編譯器認爲 if (p = NULL) 是合法的,可是會指出 if (NULL = p)是錯誤的,由於NULL不能被賦值。
程序中有時會遇到if/else/return的組合,應該將以下不良風格的程序
if (condition)
return x;
return y;
改寫爲
if (condition)
{
return x;
}
else
{
return y;
}
或者改寫成更加簡練的
return (condition ? x : y);
C++/C循環語句中,for語句使用頻率最高,while語句其次,do語句不多用。本節重點論述循環體的效率。提升循環體效率的基本辦法是下降循環體的複雜性。
l 【建議4-4-1】在多重循環中,若是有可能,應當將最長的循環放在最內層,最短的循環放在最外層,以減小CPU跨切循環層的次數。例如示例4-4(b)的效率比示例4-4(a)的高。
for (row=0; row<100; row++) { for ( col=0; col<5; col++ ) { sum = sum + a[row][col]; } } |
for (col=0; col<5; col++ ) { for (row=0; row<100; row++) { sum = sum + a[row][col]; } } |
示例4-4(a) 低效率:長循環在最外層 示例4-4(b) 高效率:長循環在最內層
l 【建議4-4-2】若是循環體內存在邏輯判斷,而且循環次數很大,宜將邏輯判斷移到循環體的外面。示例4-4(c)的程序比示例4-4(d)多執行了N-1次邏輯判斷。而且因爲前者老要進行邏輯判斷,打斷了循環「流水線」做業,使得編譯器不能對循環進行優化處理,下降了效率。若是N很是大,最好採用示例4-4(d)的寫法,能夠提升效率。若是N很是小,二者效率差異並不明顯,採用示例4-4(c)的寫法比較好,由於程序更加簡潔。
for (i=0; i<N; i++) { if (condition) DoSomething(); else DoOtherthing(); } |
if (condition) { for (i=0; i<N; i++) DoSomething(); } else { for (i=0; i<N; i++) DoOtherthing(); } |
表4-4(c) 效率低但程序簡潔 表4-4(d) 效率高但程序不簡潔
l 【規則4-5-1】不可在for 循環體內修改循環變量,防止for 循環失去控制。
l 【建議4-5-1】建議for語句的循環控制變量的取值採用「半開半閉區間」寫法。
示例4-5(a)中的x值屬於半開半閉區間「0 =< x < N」,起點到終點的間隔爲N,循環次數爲N。
示例4-5(b)中的x值屬於閉區間「0 =< x <= N-1」,起點到終點的間隔爲N-1,循環次數爲N。
相比之下,示例4-5(a)的寫法更加直觀,儘管二者的功能是相同的。
for (int x=0; x<N; x++) { … } |
for (int x=0; x<=N-1; x++) { … } |
示例4-5(a) 循環變量屬於半開半閉區間 示例4-5(b) 循環變量屬於閉區間
有了if語句爲何還要switch語句?
switch是多分支選擇語句,而if語句只有兩個分支可供選擇。雖然能夠用嵌套的if語句來實現多分支選擇,但那樣的程序冗長難讀。這是switch語句存在的理由。
switch語句的基本格式是:
switch (variable)
{
case value1 : …
break;
case value2 : …
break;
…
default : …
break;
}
l 【規則4-6-1】每一個case語句的結尾不要忘了加break,不然將致使多個分支重疊(除非有意使多個分支重疊)。
l 【規則4-6-2】不要忘記最後那個default分支。即便程序真的不須要default處理,也應該保留語句 default : break; 這樣作並不是畫蛇添足,而是爲了防止別人誤覺得你忘了default處理。
自從提倡結構化設計以來,goto就成了有爭議的語句。首先,因爲goto語句能夠靈活跳轉,若是不加限制,它的確會破壞結構化設計風格。其次,goto語句常常帶來錯誤或隱患。它可能跳過了某些對象的構造、變量的初始化、重要的計算等語句,例如:
goto state;
String s1, s2; // 被goto跳過
int sum = 0; // 被goto跳過
…
state:
…
若是編譯器不能發覺此類錯誤,每用一次goto語句均可能留下隱患。
不少人建議廢除C++/C的goto語句,以絕後患。但實事求是地說,錯誤是程序員本身形成的,不是goto的過錯。goto 語句至少有一處可顯神通,它能從多重循環體中咻地一會兒跳到外面,用不着寫不少次的break語句; 例如
{ …
{ …
{ …
goto error;
}
}
}
error:
…
就象樓房着火了,來不及從樓梯一級一級往下走,可從窗口跳出火坑。因此咱們主張少用、慎用goto語句,而不是禁用。
常量是一種標識符,它的值在運行期間恆定不變。C語言用 #define來定義常量(稱爲宏常量)。C++ 語言除了 #define外還能夠用const來定義常量(稱爲const常量)。
若是不使用常量,直接在程序中填寫數字或字符串,將會有什麼麻煩?
(1) 程序的可讀性(可理解性)變差。程序員本身會忘記那些數字或字符串是什麼意思,用戶則更加不知它們從何處來、表示什麼。
(2) 在程序的不少地方輸入一樣的數字或字符串,難保不發生書寫錯誤。
(3) 若是要修改數字或字符串,則會在不少地方改動,既麻煩又容易出錯。
l 【規則5-1-1】 儘可能使用含義直觀的常量來表示那些將在程序中屢次出現的數字或字符串。
例如:
#define MAX 100 /* C語言的宏常量 */
const int MAX = 100; // C++ 語言的const常量
const float PI = 3.14159; // C++ 語言的const常量
C++ 語言能夠用const來定義常量,也能夠用 #define來定義常量。可是前者比後者有更多的優勢:
(1) const常量有數據類型,而宏常量沒有數據類型。編譯器能夠對前者進行類型安全檢查。而對後者只進行字符替換,沒有類型安全檢查,而且在字符替換可能會產生意料不到的錯誤(邊際效應)。
(2) 有些集成化的調試工具能夠對const常量進行調試,可是不能對宏常量進行調試。
l 【規則5-2-1】在C++ 程序中只使用const常量而不使用宏常量,即const常量徹底取代宏常量。
l 【規則5-3-1】須要對外公開的常量放在頭文件中,不須要對外公開的常量放在定義文件的頭部。爲便於管理,能夠把不一樣模塊的常量集中存放在一個公共的頭文件中。
l 【規則5-3-2】若是某一常量與其它常量密切相關,應在定義中包含這種關係,而不該給出一些孤立的值。
例如:
const float RADIUS = 100;
const float DIAMETER = RADIUS * 2;
有時咱們但願某些常量只在類中有效。因爲#define定義的宏常量是全局的,不能達到目的,因而想固然地以爲應該用const修飾數據成員來實現。const數據成員的確是存在的,但其含義卻不是咱們所指望的。const數據成員只在某個對象生存期內是常量,而對於整個類而言倒是可變的,由於類能夠建立多個對象,不一樣的對象其const數據成員的值能夠不一樣。
不能在類聲明中初始化const數據成員。如下用法是錯誤的,由於類的對象未被建立時,編譯器不知道SIZE的值是什麼。
class A
{…
const int SIZE = 100; // 錯誤,企圖在類聲明中初始化const數據成員
int array[SIZE]; // 錯誤,未知的SIZE
};
const數據成員的初始化只能在類構造函數的初始化表中進行,例如
class A
{…
A(int size); // 構造函數
const int SIZE ;
};
A::A(int size) : SIZE(size) // 構造函數的初始化表
{
…
}
A a(100); // 對象 a 的SIZE值爲100
A b(200); // 對象 b 的SIZE值爲200
怎樣才能創建在整個類中都恆定的常量呢?別期望const數據成員了,應該用類中的枚舉常量來實現。例如
class A
{…
enum { SIZE1 = 100, SIZE2 = 200}; // 枚舉常量
int array1[SIZE1];
int array2[SIZE2];
};
枚舉常量不會佔用對象的存儲空間,它們在編譯時被所有求值。枚舉常量的缺點是:它的隱含數據類型是整數,其最大值有限,且不能表示浮點數(如PI=3.14159)。
函數是C++/C程序的基本功能單元,其重要性不言而喻。函數設計的細微缺點很容易致使該函數被錯用,因此光使函數的功能正確是不夠的。本章重點論述函數的接口設計和內部實現的一些規則。
函數接口的兩個要素是參數和返回值。C語言中,函數的參數和返回值的傳遞方式有兩種:值傳遞(pass by value)和指針傳遞(pass by pointer)。C++ 語言中多了引用傳遞(pass by reference)。因爲引用傳遞的性質象指針傳遞,而使用方式卻象值傳遞,初學者經常疑惑不解,容易引發混亂,請先閱讀6.6節「引用與指針的比較」。
l 【規則6-1-1】參數的書寫要完整,不要貪圖省事只寫參數的類型而省略參數名字。若是函數沒有參數,則用void填充。
例如:
void SetValue(int width, int height); // 良好的風格
void SetValue(int, int); // 不良的風格
float GetValue(void); // 良好的風格
float GetValue(); // 不良的風格
l 【規則6-1-2】參數命名要恰當,順序要合理。
例如編寫字符串拷貝函數StringCopy,它有兩個參數。若是把參數名字起爲str1和str2,例如
void StringCopy(char *str1, char *str2);
那麼咱們很難搞清楚到底是把str1拷貝到str2中,仍是恰好倒過來。
能夠把參數名字起得更有意義,如叫strSource和strDestination。這樣從名字上就能夠看出應該把strSource拷貝到strDestination。
還有一個問題,這兩個參數那一個該在前那一個該在後?參數的順序要遵循程序員的習慣。通常地,應將目的參數放在前面,源參數放在後面。
若是將函數聲明爲:
void StringCopy(char *strSource, char *strDestination);
別人在使用時可能會不假思索地寫成以下形式:
char str[20];
StringCopy(str, 「Hello World」); // 參數順序顛倒
l 【規則6-1-3】若是參數是指針,且僅做輸入用,則應在類型前加const,以防止該指針在函數體內被意外修改。
例如:
void StringCopy(char *strDestination,const char *strSource);
l 【規則6-1-4】若是輸入參數以值傳遞的方式傳遞對象,則宜改用「const &」方式來傳遞,這樣能夠省去臨時對象的構造和析構過程,從而提升效率。
² 【建議6-1-1】避免函數有太多的參數,參數個數儘可能控制在5個之內。若是參數太多,在使用時容易將參數類型或順序搞錯。
² 【建議6-1-2】儘可能不要使用類型和數目不肯定的參數。
C標準庫函數printf是採用不肯定參數的典型表明,其原型爲:
int printf(const chat *format[, argument]…);
這種風格的函數在編譯時喪失了嚴格的類型安全檢查。
l 【規則6-2-1】不要省略返回值的類型。
C語言中,凡不加類型說明的函數,一概自動按整型處理。這樣作不會有什麼好處,卻容易被誤解爲void類型。
C++語言有很嚴格的類型安全檢查,不容許上述狀況發生。因爲C++程序能夠調用C函數,爲了不混亂,規定任何C++/ C函數都必須有類型。若是函數沒有返回值,那麼應聲明爲void類型。
l 【規則6-2-2】函數名字與返回值類型在語義上不可衝突。
違反這條規則的典型表明是C標準庫函數getchar。
例如:
char c;
c = getchar();
if (c == EOF)
…
按照getchar名字的意思,將變量c聲明爲char類型是很天然的事情。但不幸的是getchar的確不是char類型,而是int類型,其原型以下:
int getchar(void);
因爲c是char類型,取值範圍是[-128,127],若是宏EOF的值在char的取值範圍以外,那麼if語句將老是失敗,這種「危險」人們通常哪裏料獲得!致使本例錯誤的責任並不在用戶,是函數getchar誤導了使用者。
l 【規則6-2-3】不要將正常值和錯誤標誌混在一塊兒返回。正常值用輸出參數得到,而錯誤標誌用return語句返回。
回顧上例,C標準庫函數的設計者爲何要將getchar聲明爲使人迷糊的int類型呢?他會那麼傻嗎?
在正常狀況下,getchar的確返回單個字符。但若是getchar碰到文件結束標誌或發生讀錯誤,它必須返回一個標誌EOF。爲了區別於正常的字符,只好將EOF定義爲負數(一般爲負1)。所以函數getchar就成了int類型。
咱們在實際工做中,常常會碰到上述使人爲難的問題。爲了不出現誤解,咱們應該將正常值和錯誤標誌分開。即:正常值用輸出參數得到,而錯誤標誌用return語句返回。
函數getchar能夠改寫成 BOOL GetChar(char *c);
雖然gechar比GetChar靈活,例如 putchar(getchar()); 可是若是getchar用錯了,它的靈活性又有什麼用呢?
² 【建議6-2-1】有時候函數本來不須要返回值,但爲了增長靈活性如支持鏈式表達,能夠附加返回值。
例如字符串拷貝函數strcpy的原型:
char *strcpy(char *strDest,const char *strSrc);
strcpy函數將strSrc拷貝至輸出參數strDest中,同時函數的返回值又是strDest。這樣作並不是畫蛇添足,能夠得到以下靈活性:
char str[20];
int length = strlen( strcpy(str, 「Hello World」) );
² 【建議6-2-2】若是函數的返回值是一個對象,有些場合用「引用傳遞」替換「值傳遞」能夠提升效率。而有些場合只能用「值傳遞」而不能用「引用傳遞」,不然會出錯。
例如:
class String
{…
// 賦值函數
String & operate=(const String &other);
// 相加函數,若是沒有friend修飾則只許有一個右側參數
friend String operate+( const String &s1, const String &s2);
private:
char *m_data;
}
String的賦值函數operate = 的實現以下:
String & String::operate=(const String &other)
{
if (this == &other)
return *this;
delete m_data;
m_data = new char[strlen(other.data)+1];
strcpy(m_data, other.data);
return *this; // 返回的是 *this的引用,無需拷貝過程
}
對於賦值函數,應當用「引用傳遞」的方式返回String對象。若是用「值傳遞」的方式,雖然功能仍然正確,但因爲return語句要把 *this拷貝到保存返回值的外部存儲單元之中,增長了沒必要要的開銷,下降了賦值函數的效率。例如:
String a,b,c;
…
a = b; // 若是用「值傳遞」,將產生一次 *this 拷貝
a = b = c; // 若是用「值傳遞」,將產生兩次 *this 拷貝
String的相加函數operate + 的實現以下:
String operate+(const String &s1, const String &s2)
{
String temp;
delete temp.data; // temp.data是僅含‘\0’的字符串
temp.data = new char[strlen(s1.data) + strlen(s2.data) +1];
strcpy(temp.data, s1.data);
strcat(temp.data, s2.data);
return temp;
}
對於相加函數,應當用「值傳遞」的方式返回String對象。若是改用「引用傳遞」,那麼函數返回值是一個指向局部對象temp的「引用」。因爲temp在函數結束時被自動銷燬,將致使返回的「引用」無效。例如:
c = a + b;
此時 a + b 並不返回指望值,c什麼也得不到,流下了隱患。
不一樣功能的函數其內部實現各不相同,看起來彷佛沒法就「內部實現」達成一致的觀點。但根據經驗,咱們能夠在函數體的「入口處」和「出口處」從嚴把關,從而提升函數的質量。
l 【規則6-3-1】在函數體的「入口處」,對參數的有效性進行檢查。
不少程序錯誤是由非法參數引發的,咱們應該充分理解並正確使用「斷言」(assert)來防止此類錯誤。詳見6.5節「使用斷言」。
l 【規則6-3-2】在函數體的「出口處」,對return語句的正確性和效率進行檢查。
若是函數有返回值,那麼函數的「出口處」是return語句。咱們不要輕視return語句。若是return語句寫得很差,函數要麼出錯,要麼效率低下。
注意事項以下:
(1)return語句不可返回指向「棧內存」的「指針」或者「引用」,由於該內存在函數體結束時被自動銷燬。例如
char * Func(void)
{
char str[] = 「hello world」; // str的內存位於棧上
…
return str; // 將致使錯誤
}
(2)要搞清楚返回的到底是「值」、「指針」仍是「引用」。
(3)若是函數返回值是一個對象,要考慮return語句的效率。例如
return String(s1 + s2);
這是臨時對象的語法,表示「建立一個臨時對象並返回它」。不要覺得它與「先建立一個局部對象temp並返回它的結果」是等價的,如
String temp(s1 + s2);
return temp;
實質否則,上述代碼將發生三件事。首先,temp對象被建立,同時完成初始化;而後拷貝構造函數把temp拷貝到保存返回值的外部存儲單元中;最後,temp在函數結束時被銷燬(調用析構函數)。然而「建立一個臨時對象並返回它」的過程是不一樣的,編譯器直接把臨時對象建立並初始化在外部存儲單元中,省去了拷貝和析構的化費,提升了效率。
相似地,咱們不要將
return int(x + y); // 建立一個臨時變量並返回它
寫成
int temp = x + y;
return temp;
因爲內部數據類型如int,float,double的變量不存在構造函數與析構函數,雖然該「臨時變量的語法」不會提升多少效率,可是程序更加簡潔易讀。
² 【建議6-4-1】函數的功能要單一,不要設計多用途的函數。
² 【建議6-4-2】函數體的規模要小,儘可能控制在50行代碼以內。
² 【建議6-4-3】儘可能避免函數帶有「記憶」功能。相同的輸入應當產生相同的輸出。
帶有「記憶」功能的函數,其行爲多是不可預測的,由於它的行爲可能取決於某種「記憶狀態」。這樣的函數既不易理解又不利於測試和維護。在C/C++語言中,函數的static局部變量是函數的「記憶」存儲器。建議儘可能少用static局部變量,除非必需。
² 【建議6-4-4】不只要檢查輸入參數的有效性,還要檢查經過其它途徑進入函數體內的變量的有效性,例如全局變量、文件句柄等。
² 【建議6-4-5】用於出錯處理的返回值必定要清楚,讓使用者不容易忽視或誤解錯誤狀況。
程序通常分爲Debug版本和Release版本,Debug版本用於內部調試,Release版本發行給用戶使用。
斷言assert是僅在Debug版本起做用的宏,它用於檢查「不該該」發生的狀況。示例6-5是一個內存複製函數。在運行過程當中,若是assert的參數爲假,那麼程序就會停止(通常地還會出現提示對話,說明在什麼地方引起了assert)。
void *memcpy(void *pvTo, const void *pvFrom, size_t size) { assert((pvTo != NULL) && (pvFrom != NULL)); // 使用斷言 byte *pbTo = (byte *) pvTo; // 防止改變pvTo的地址 byte *pbFrom = (byte *) pvFrom; // 防止改變pvFrom的地址 while(size -- > 0 ) *pbTo ++ = *pbFrom ++ ; return pvTo; } |
示例6-5 複製不重疊的內存塊
assert不是一個倉促拼湊起來的宏。爲了避免在程序的Debug版本和Release版本引發差異,assert不該該產生任何反作用。因此assert不是函數,而是宏。程序員能夠把assert當作一個在任何系統狀態下均可以安全使用的無害測試手段。若是程序在assert處終止了,並非說含有該assert的函數有錯誤,而是調用者出了差錯,assert能夠幫助咱們找到發生錯誤的緣由。
不多有比跟蹤到程序的斷言,殊不知道該斷言的做用更讓人沮喪的事了。你化了不少時間,不是爲了排除錯誤,而只是爲了弄清楚這個錯誤究竟是什麼。有的時候,程序員偶爾還會設計出有錯誤的斷言。因此若是搞不清楚斷言檢查的是什麼,就很難判斷錯誤是出如今程序中,仍是出如今斷言中。幸運的是這個問題很好解決,只要加上清晰的註釋便可。這本是顯而易見的事情,但是不多有程序員這樣作。這比如一我的在森林裏,看到樹上釘着一塊「危險」的大牌子。但危險究竟是什麼?樹要倒?有廢井?有野獸?除非告訴人們「危險」是什麼,不然這個警告牌難以起到積極有效的做用。難以理解的斷言經常被程序員忽略,甚至被刪除。[Maguire, p8-p30]
l 【規則6-5-1】使用斷言捕捉不該該發生的非法狀況。不要混淆非法狀況與錯誤狀況之間的區別,後者是必然存在的而且是必定要做出處理的。
l 【規則6-5-2】在函數的入口處,使用斷言檢查參數的有效性(合法性)。
l 【建議6-5-1】在編寫函數時,要進行反覆的考查,而且自問:「我打算作哪些假定?」一旦肯定了的假定,就要使用斷言對假定進行檢查。
l 【建議6-5-2】通常教科書都鼓勵程序員們進行防錯設計,但要記住這種編程風格可能會隱瞞錯誤。當進行防錯設計時,若是「不可能發生」的事情的確發生了,則要使用斷言進行報警。
引用是C++中的概念,初學者容易把引用和指針混淆一塊兒。一下程序中,n是m的一個引用(reference),m是被引用物(referent)。
int m;
int &n = m;
n至關於m的別名(綽號),對n的任何操做就是對m的操做。例若有人名叫王小毛,他的綽號是「三毛」。說「三毛」怎麼怎麼的,其實就是對王小毛說三道四。因此n既不是m的拷貝,也不是指向m的指針,其實n就是m它本身。
引用的一些規則以下:
(1)引用被建立的同時必須被初始化(指針則能夠在任什麼時候候被初始化)。
(2)不能有NULL引用,引用必須與合法的存儲單元關聯(指針則能夠是NULL)。
(3)一旦引用被初始化,就不能改變引用的關係(指針則能夠隨時改變所指的對象)。
如下示例程序中,k被初始化爲i的引用。語句k = j並不能將k修改爲爲j的引用,只是把k的值改變成爲6。因爲k是i的引用,因此i的值也變成了6。
int i = 5;
int j = 6;
int &k = i;
k = j; // k和i的值都變成了6;
上面的程序看起來象在玩文字遊戲,沒有體現出引用的價值。引用的主要功能是傳遞函數的參數和返回值。C++語言中,函數的參數和返回值的傳遞方式有三種:值傳遞、指針傳遞和引用傳遞。
如下是「值傳遞」的示例程序。因爲Func1函數體內的x是外部變量n的一份拷貝,改變x的值不會影響n, 因此n的值仍然是0。
void Func1(int x)
{
x = x + 10;
}
…
int n = 0;
Func1(n);
cout << 「n = 」 << n << endl; // n = 0
如下是「指針傳遞」的示例程序。因爲Func2函數體內的x是指向外部變量n的指針,改變該指針的內容將致使n的值改變,因此n的值成爲10。
void Func2(int *x)
{
(* x) = (* x) + 10;
}
…
int n = 0;
Func2(&n);
cout << 「n = 」 << n << endl; // n = 10
如下是「引用傳遞」的示例程序。因爲Func3函數體內的x是外部變量n的引用,x和n是同一個東西,改變x等於改變n,因此n的值成爲10。
void Func3(int &x)
{
x = x + 10;
}
…
int n = 0;
Func3(n);
cout << 「n = 」 << n << endl; // n = 10
對比上述三個示例程序,會發現「引用傳遞」的性質象「指針傳遞」,而書寫方式象「值傳遞」。實際上「引用」能夠作的任何事情「指針」也都可以作,爲何還要「引用」這東西?
答案是「用適當的工具作恰如其分的工做」。
指針可以毫無約束地操做內存中的如何東西,儘管指針功能強大,可是很是危險。就象一把刀,它能夠用來砍樹、裁紙、修指甲、理髮等等,誰敢這樣用?
若是的確只須要借用一下某個對象的「別名」,那麼就用「引用」,而不要用「指針」,以避免發生意外。好比說,某人須要一份證實,原本在文件上蓋上公章的印子就好了,若是把取公章的鑰匙交給他,那麼他就得到了不應有的權利。
歡迎進入內存這片雷區。偉大的Bill Gates 曾經失言:
640K ought to be enough for everybody
— Bill Gates 1981
程序員們常常編寫內存管理程序,每每提心吊膽。若是不想觸雷,惟一的解決辦法就是發現全部潛伏的地雷而且排除它們,躲是躲不了的。本章的內容比通常教科書的要深刻得多,讀者需細心閱讀,作到真正地通曉內存管理。
內存分配方式有三種:
(1) 從靜態存儲區域分配。內存在程序編譯的時候就已經分配好,這塊內存在程序的整個運行期間都存在。例如全局變量,static變量。
(2) 在棧上建立。在執行函數時,函數內局部變量的存儲單元均可以在棧上建立,函數執行結束時這些存儲單元自動被釋放。棧內存分配運算內置於處理器的指令集中,效率很高,可是分配的內存容量有限。
(3) 從堆上分配,亦稱動態內存分配。程序在運行的時候用malloc或new申請任意多少的內存,程序員本身負責在什麼時候用free或delete釋放內存。動態內存的生存期由咱們決定,使用很是靈活,但問題也最多。
發生內存錯誤是件很是麻煩的事情。編譯器不能自動發現這些錯誤,一般是在程序運行時才能捕捉到。而這些錯誤大多沒有明顯的症狀,時隱時現,增長了改錯的難度。有時用戶怒氣衝衝地把你找來,程序卻沒有發生任何問題,你一走,錯誤又發做了。
常見的內存錯誤及其對策以下:
u 內存分配未成功,卻使用了它。
編程新手常犯這種錯誤,由於他們沒有意識到內存分配會不成功。經常使用解決辦法是,在使用內存以前檢查指針是否爲NULL。若是指針p是函數的參數,那麼在函數的入口處用assert(p!=NULL)進行檢查。若是是用malloc或new來申請內存,應該用if(p==NULL) 或if(p!=NULL)進行防錯處理。
u 內存分配雖然成功,可是還沒有初始化就引用它。
犯這種錯誤主要有兩個原由:一是沒有初始化的觀念;二是誤覺得內存的缺省初值全爲零,致使引用初值錯誤(例如數組)。
內存的缺省初值到底是什麼並無統一的標準,儘管有些時候爲零值,咱們寧肯信其無不可信其有。因此不管用何種方式建立數組,都別忘了賦初值,即使是賦零值也不可省略,不要嫌麻煩。
u 內存分配成功而且已經初始化,但操做越過了內存的邊界。
例如在使用數組時常常發生下標「多1」或者「少1」的操做。特別是在for循環語句中,循環次數很容易搞錯,致使數組操做越界。
u 忘記了釋放內存,形成內存泄露。
含有這種錯誤的函數每被調用一次就丟失一塊內存。剛開始時系統的內存充足,你看不到錯誤。終有一次程序忽然死掉,系統出現提示:內存耗盡。
動態內存的申請與釋放必須配對,程序中malloc與free的使用次數必定要相同,不然確定有錯誤(new/delete同理)。
u 釋放了內存卻繼續使用它。
有三種狀況:
(1)程序中的對象調用關係過於複雜,實在難以搞清楚某個對象到底是否已經釋放了內存,此時應該從新設計數據結構,從根本上解決對象管理的混亂局面。
(2)函數的return語句寫錯了,注意不要返回指向「棧內存」的「指針」或者「引用」,由於該內存在函數體結束時被自動銷燬。
(3)使用free或delete釋放了內存後,沒有將指針設置爲NULL。致使產生「野指針」。
l 【規則7-2-1】用malloc或new申請內存以後,應該當即檢查指針值是否爲NULL。防止使用指針值爲NULL的內存。
l 【規則7-2-2】不要忘記爲數組和動態內存賦初值。防止將未被初始化的內存做爲右值使用。
l 【規則7-2-3】避免數組或指針的下標越界,特別要小心發生「多1」或者「少1」操做。
l 【規則7-2-4】動態內存的申請與釋放必須配對,防止內存泄漏。
l 【規則7-2-5】用free或delete釋放了內存以後,當即將指針設置爲NULL,防止產生「野指針」。
C++/C程序中,指針和數組在很多地方能夠相互替換着用,讓人產生一種錯覺,覺得二者是等價的。
數組要麼在靜態存儲區被建立(如全局數組),要麼在棧上被建立。數組名對應着(而不是指向)一塊內存,其地址與容量在生命期內保持不變,只有數組的內容能夠改變。
指針能夠隨時指向任意類型的內存塊,它的特徵是「可變」,因此咱們經常使用指針來操做動態內存。指針遠比數組靈活,但也更危險。
下面以字符串爲例比較指針與數組的特性。
7.3.1 修改內容
示例7-3-1中,字符數組a的容量是6個字符,其內容爲hello\0。a的內容能夠改變,如a[0]= ‘X’。指針p指向常量字符串「world」(位於靜態存儲區,內容爲world\0),常量字符串的內容是不能夠被修改的。從語法上看,編譯器並不以爲語句p[0]= ‘X’有什麼不妥,可是該語句企圖修改常量字符串的內容而致使運行錯誤。
char a[] = 「hello」; a[0] = ‘X’; cout << a << endl; char *p = 「world」; // 注意p指向常量字符串 p[0] = ‘X’; // 編譯器不能發現該錯誤 cout << p << endl; |
示例7-3-1 修改數組和指針的內容
7.3.2 內容複製與比較
不能對數組名進行直接複製與比較。示例7-3-2中,若想把數組a的內容複製給數組b,不能用語句 b = a ,不然將產生編譯錯誤。應該用標準庫函數strcpy進行復制。同理,比較b和a的內容是否相同,不能用if(b==a) 來判斷,應該用標準庫函數strcmp進行比較。
語句p = a 並不能把a的內容複製指針p,而是把a的地址賦給了p。要想複製a的內容,能夠先用庫函數malloc爲p申請一塊容量爲strlen(a)+1個字符的內存,再用strcpy進行字符串複製。同理,語句if(p==a) 比較的不是內容而是地址,應該用庫函數strcmp來比較。
// 數組… char a[] = "hello"; char b[10]; strcpy(b, a); // 不能用 b = a; if(strcmp(b, a) == 0) // 不能用 if (b == a) … |
// 指針… int len = strlen(a); char *p = (char *)malloc(sizeof(char)*(len+1)); strcpy(p,a); // 不要用 p = a; if(strcmp(p, a) == 0) // 不要用 if (p == a) … |
示例7-3-2 數組和指針的內容複製與比較
7.3.3 計算內存容量
用運算符sizeof能夠計算出數組的容量(字節數)。示例7-3-3(a)中,sizeof(a)的值是12(注意別忘了’\0’)。指針p指向a,可是sizeof(p)的值倒是4。這是由於sizeof(p)獲得的是一個指針變量的字節數,至關於sizeof(char*),而不是p所指的內存容量。C++/C語言沒有辦法知道指針所指的內存容量,除非在申請內存時記住它。
注意當數組做爲函數的參數進行傳遞時,該數組自動退化爲同類型的指針。示例7-3-3(b)中,不論數組a的容量是多少,sizeof(a)始終等於sizeof(char *)。
char a[] = "hello world"; char *p = a; cout<< sizeof(a) << endl; // 12字節 cout<< sizeof(p) << endl; // 4字節 |
示例7-3-3(a) 計算數組和指針的內存容量
void Func(char a[100]) { cout<< sizeof(a) << endl; // 4字節而不是100字節 } |
示例7-3-3(b) 數組退化爲指針
若是函數的參數是一個指針,不要期望用該指針去申請動態內存。示例7-4-1中,Test函數的語句GetMemory(str, 200)並無使str得到指望的內存,str依舊是NULL,爲何?
void GetMemory(char *p, int num) { p = (char *)malloc(sizeof(char) * num); } |
void Test(void) { char *str = NULL; GetMemory(str, 100); // str 仍然爲 NULL strcpy(str, "hello"); // 運行錯誤 } |
示例7-4-1 試圖用指針參數申請動態內存
毛病出在函數GetMemory中。編譯器老是要爲函數的每一個參數製做臨時副本,指針參數p的副本是 _p,編譯器使 _p = p。若是函數體內的程序修改了_p的內容,就致使參數p的內容做相應的修改。這就是指針能夠用做輸出參數的緣由。在本例中,_p申請了新的內存,只是把_p所指的內存地址改變了,可是p絲毫未變。因此函數GetMemory並不能輸出任何東西。事實上,每執行一次GetMemory就會泄露一塊內存,由於沒有用free釋放內存。
若是非得要用指針參數去申請內存,那麼應該改用「指向指針的指針」,見示例7-4-2。
void GetMemory2(char **p, int num) { *p = (char *)malloc(sizeof(char) * num); } |
void Test2(void) { char *str = NULL; GetMemory2(&str, 100); // 注意參數是 &str,而不是str strcpy(str, "hello"); cout<< str << endl; free(str); } |
示例7-4-2用指向指針的指針申請動態內存
因爲「指向指針的指針」這個概念不容易理解,咱們能夠用函數返回值來傳遞動態內存。這種方法更加簡單,見示例7-4-3。
char *GetMemory3(int num) { char *p = (char *)malloc(sizeof(char) * num); return p; } |
void Test3(void) { char *str = NULL; str = GetMemory3(100); strcpy(str, "hello"); cout<< str << endl; free(str); } |
示例7-4-3 用函數返回值來傳遞動態內存
用函數返回值來傳遞動態內存這種方法雖然好用,可是經常有人把return語句用錯了。這裏強調不要用return語句返回指向「棧內存」的指針,由於該內存在函數結束時自動消亡,見示例7-4-4。
char *GetString(void) { char p[] = "hello world"; return p; // 編譯器將提出警告 } |
void Test4(void) { char *str = NULL; str = GetString(); // str 的內容是垃圾 cout<< str << endl; } |
示例7-4-4 return語句返回指向「棧內存」的指針
用調試器逐步跟蹤Test4,發現執行str = GetString語句後str再也不是NULL指針,可是str的內容不是「hello world」而是垃圾。
若是把示例7-4-4改寫成示例7-4-5,會怎麼樣?
char *GetString2(void) { char *p = "hello world"; return p; } |
void Test5(void) { char *str = NULL; str = GetString2(); cout<< str << endl; } |
示例7-4-5 return語句返回常量字符串
函數Test5運行雖然不會出錯,可是函數GetString2的設計概念倒是錯誤的。由於GetString2內的「hello world」是常量字符串,位於靜態存儲區,它在程序生命期內恆定不變。不管何時調用GetString2,它返回的始終是同一個「只讀」的內存塊。
別看free和delete的名字惡狠狠的(尤爲是delete),它們只是把指針所指的內存給釋放掉,但並無把指針自己幹掉。
用調試器跟蹤示例7-5,發現指針p被free之後其地址仍然不變(非NULL),只是該地址對應的內存是垃圾,p成了「野指針」。若是此時不把p設置爲NULL,會讓人誤覺得p是個合法的指針。
若是程序比較長,咱們有時記不住p所指的內存是否已經被釋放,在繼續使用p以前,一般會用語句if (p != NULL)進行防錯處理。很遺憾,此時if語句起不到防錯做用,由於即使p不是NULL指針,它也不指向合法的內存塊。
char *p = (char *) malloc(100); strcpy(p, 「hello」); free(p); // p 所指的內存被釋放,可是p所指的地址仍然不變 … if(p != NULL) // 沒有起到防錯做用 { strcpy(p, 「world」); // 出錯 } |
示例7-5 p成爲野指針
函數體內的局部變量在函數結束時自動消亡。不少人誤覺得示例7-6是正確的。理由是p是局部的指針變量,它消亡的時候會讓它所指的動態內存一塊兒完蛋。這是錯覺!
void Func(void) { char *p = (char *) malloc(100); // 動態內存會自動釋放嗎? } |
示例7-6 試圖讓動態內存自動釋放
咱們發現指針有一些「似是而非」的特徵:
(1)指針消亡了,並不表示它所指的內存會被自動釋放。
(2)內存被釋放了,並不表示指針會消亡或者成了NULL指針。
這代表釋放內存並非一件能夠草率對待的事。也許有人不服氣,必定要找出能夠草率行事的理由:
若是程序終止了運行,一切指針都會消亡,動態內存會被操做系統回收。既然如此,在程序臨終前,就能夠沒必要釋放內存、沒必要將指針設置爲NULL了。終於能夠偷懶而不會發生錯誤了吧?
想得美。若是別人把那段程序取出來用到其它地方怎麼辦?
「野指針」不是NULL指針,是指向「垃圾」內存的指針。人們通常不會錯用NULL指針,由於用if語句很容易判斷。可是「野指針」是很危險的,if語句對它不起做用。
「野指針」的成因主要有兩種:
(1)指針變量沒有被初始化。任何指針變量剛被建立時不會自動成爲NULL指針,它的缺省值是隨機的,它會亂指一氣。因此,指針變量在建立的同時應當被初始化,要麼將指針設置爲NULL,要麼讓它指向合法的內存。例如
char *p = NULL;
char *str = (char *) malloc(100);
(2)指針p被free或者delete以後,沒有置爲NULL,讓人誤覺得p是個合法的指針。參見7.5節。
(3)指針操做超越了變量的做用範圍。這種狀況讓人防不勝防,示例程序以下:
class A
{
public:
void Func(void){ cout << 「Func of class A」 << endl; }
};
void Test(void)
{
A *p;
{
A a;
p = &a; // 注意 a 的生命期
}
p->Func(); // p是「野指針」
}
函數Test在執行語句p->Func()時,對象a已經消失,而p是指向a的,因此p就成了「野指針」。但奇怪的是我運行這個程序時竟然沒有出錯,這可能與編譯器有關。
malloc與free是C++/C語言的標準庫函數,new/delete是C++的運算符。它們均可用於申請動態內存和釋放內存。
對於非內部數據類型的對象而言,光用maloc/free沒法知足動態對象的要求。對象在建立的同時要自動執行構造函數,對象在消亡以前要自動執行析構函數。因爲malloc/free是庫函數而不是運算符,不在編譯器控制權限以內,不可以把執行構造函數和析構函數的任務強加於malloc/free。
所以C++語言須要一個能完成動態內存分配和初始化工做的運算符new,以及一個能完成清理與釋放內存工做的運算符delete。注意new/delete不是庫函數。
咱們先看一看malloc/free和new/delete如何實現對象的動態內存管理,見示例7-8。
class Obj { public : Obj(void){ cout << 「Initialization」 << endl; } ~Obj(void){ cout << 「Destroy」 << endl; } void Initialize(void){ cout << 「Initialization」 << endl; } void Destroy(void){ cout << 「Destroy」 << endl; } }; |
void UseMallocFree(void) { Obj *a = (obj *)malloc(sizeof(obj)); // 申請動態內存 a->Initialize(); // 初始化 //… a->Destroy(); // 清除工做 free(a); // 釋放內存 } |
void UseNewDelete(void) { Obj *a = new Obj; // 申請動態內存而且初始化 //… delete a; // 清除而且釋放內存 } |
示例7-8 用malloc/free和new/delete如何實現對象的動態內存管理
類Obj的函數Initialize模擬了構造函數的功能,函數Destroy模擬了析構函數的功能。函數UseMallocFree中,因爲malloc/free不能執行構造函數與析構函數,必須調用成員函數Initialize和Destroy來完成初始化與清除工做。函數UseNewDelete則簡單得多。
因此咱們不要企圖用malloc/free來完成動態對象的內存管理,應該用new/delete。因爲內部數據類型的「對象」沒有構造與析構的過程,對它們而言malloc/free和new/delete是等價的。
既然new/delete的功能徹底覆蓋了malloc/free,爲何C++不把malloc/free淘汰出局呢?這是由於C++程序常常要調用C函數,而C程序只能用malloc/free管理動態內存。
若是用free釋放「new建立的動態對象」,那麼該對象因沒法執行析構函數而可能致使程序出錯。若是用delete釋放「malloc申請的動態內存」,理論上講程序不會出錯,可是該程序的可讀性不好。因此new/delete必須配對使用,malloc/free也同樣。
若是在申請動態內存時找不到足夠大的內存塊,malloc和new將返回NULL指針,宣告內存申請失敗。一般有三種方式處理「內存耗盡」問題。
(1)判斷指針是否爲NULL,若是是則立刻用return語句終止本函數。例如:
void Func(void)
{
A *a = new A;
if(a == NULL)
{
return;
}
…
}
(2)判斷指針是否爲NULL,若是是則立刻用exit(1)終止整個程序的運行。例如:
void Func(void)
{
A *a = new A;
if(a == NULL)
{
cout << 「Memory Exhausted」 << endl;
exit(1);
}
…
}
(3)爲new和malloc設置異常處理函數。例如Visual C++能夠用_set_new_hander函數爲new設置用戶本身定義的異常處理函數,也可讓malloc享用與new相同的異常處理函數。詳細內容請參考C++使用手冊。
上述(1)(2)方式使用最廣泛。若是一個函數內有多處須要申請動態內存,那麼方式(1)就顯得力不從心(釋放內存很麻煩),應該用方式(2)來處理。
不少人不忍心用exit(1),問:「不編寫出錯處理程序,讓操做系統本身解決行不行?」
不行。若是發生「內存耗盡」這樣的事情,通常說來應用程序已經無藥可救。若是不用exit(1) 把壞程序殺死,它可能會害死操做系統。道理如同:若是不把歹徒擊斃,歹徒在老死以前會犯下更多的罪。
有一個很重要的現象要告訴你們。對於32位以上的應用程序而言,不管怎樣使用malloc與new,幾乎不可能致使「內存耗盡」。我在Windows 98下用Visual C++編寫了測試程序,見示例7-9。這個程序會無休止地運行下去,根本不會終止。由於32位操做系統支持「虛存」,內存用完了,自動用硬盤空間頂替。我只聽到硬盤嘎吱嘎吱地響,Window 98已經累得對鍵盤、鼠標毫無反應。
我能夠得出這麼一個結論:對於32位以上的應用程序,「內存耗盡」錯誤處理程序毫無用處。這下可把Unix和Windows程序員們樂壞了:反正錯誤處理程序不起做用,我就不寫了,省了不少麻煩。
我不想誤導讀者,必須強調:不加錯誤處理將致使程序的質量不好,千萬不可因小失大。
void main(void) { float *p = NULL; while(TRUE) { p = new float[1000000]; cout << 「eat memory」 << endl; if(p==NULL) exit(1); } } |
示例7-9試圖耗盡操做系統的內存
函數malloc的原型以下:
void * malloc(size_t size);
用malloc申請一塊長度爲length的整數類型的內存,程序以下:
int *p = (int *) malloc(sizeof(int) * length);
咱們應當把注意力集中在兩個要素上:「類型轉換」和「sizeof」。
u malloc返回值的類型是void *,因此在調用malloc時要顯式地進行類型轉換,將void * 轉換成所須要的指針類型。
u malloc函數自己並不識別要申請的內存是什麼類型,它只關心內存的總字節數。咱們一般記不住int, float等數據類型的變量的確切字節數。例如int變量在16位系統下是2個字節,在32位下是4個字節;而float變量在16位系統下是4個字節,在32位下也是4個字節。最好用如下程序做一次測試:
cout << sizeof(char) << endl;
cout << sizeof(int) << endl;
cout << sizeof(unsigned int) << endl;
cout << sizeof(long) << endl;
cout << sizeof(unsigned long) << endl;
cout << sizeof(float) << endl;
cout << sizeof(double) << endl;
cout << sizeof(void *) << endl;
在malloc的「()」中使用sizeof運算符是良好的風格,但要小心有時咱們會昏了頭,寫出 p = malloc(sizeof(p))這樣的程序來。
u 函數free的原型以下:
void free( void * memblock );
爲何free函數不象malloc函數那樣複雜呢?這是由於指針p的類型以及它所指的內存的容量事先都是知道的,語句free(p)能正確地釋放內存。若是p是NULL指針,那麼free對p不管操做多少次都不會出問題。若是p不是NULL指針,那麼free對p連續操做兩次就會致使程序運行錯誤。
運算符new使用起來要比函數malloc簡單得多,例如:
int *p1 = (int *)malloc(sizeof(int) * length);
int *p2 = new int[length];
這是由於new內置了sizeof、類型轉換和類型安全檢查功能。對於非內部數據類型的對象而言,new在建立動態對象的同時完成了初始化工做。若是對象有多個構造函數,那麼new的語句也能夠有多種形式。例如
class Obj
{
public :
Obj(void); // 無參數的構造函數
Obj(int x); // 帶一個參數的構造函數
…
}
void Test(void)
{
Obj *a = new Obj;
Obj *b = new Obj(1); // 初值爲1
…
delete a;
delete b;
}
若是用new建立對象數組,那麼只能使用對象的無參數構造函數。例如
Obj *objects = new Obj[100]; // 建立100個動態對象
不能寫成
Obj *objects = new Obj[100](1);// 建立100個動態對象的同時賦初值1
在用delete釋放對象數組時,留意不要丟了符號‘[]’。例如
delete []objects; // 正確的用法
delete objects; // 錯誤的用法
後者至關於delete objects[0],漏掉了另外99個對象。
我認識很多技術不錯的C++/C程序員,不多有人能拍拍胸脯說通曉指針與內存管理(包括我本身)。我最初學習C語言時特別怕指針,致使我開發第一個應用軟件(約1萬行C代碼)時沒有使用一個指針,全用數組來頂替指針,實在蠢笨得過度。躲避指針不是辦法,後來我改寫了這個軟件,代碼量縮小到原先的一半。
個人經驗教訓是:
(1)越是怕指針,就越要使用指針。不會正確使用指針,確定算不上是合格的程序員。
(2)必須養成「使用調試器逐步跟蹤程序」的習慣,只有這樣才能發現問題的本質。
對比於C語言的函數,C++增長了重載(overloaded)、內聯(inline)、const和virtual四種新機制。其中重載和內聯機制既可用於全局函數也可用於類的成員函數,const與virtual機制僅用於類的成員函數。
重載和內聯確定有其好處纔會被C++語言採納,可是不能夠當成免費的午飯而濫用。本章將探究重載和內聯的優勢與侷限性,說明什麼狀況下應該採用、不應採用以及要警戒錯用。
8.1.1 重載的起源
天然語言中,一個詞能夠有許多不一樣的含義,即該詞被重載了。人們能夠經過上下文來判斷該詞究竟是哪一種含義。「詞的重載」能夠使語言更加簡練。例如「吃飯」的含義十分普遍,人們沒有必要每次非得說清楚具體吃什麼不可。別迂腐得象孔已己,說茴香豆的茴字有四種寫法。
在C++程序中,能夠將語義、功能類似的幾個函數用同一個名字表示,即函數重載。這樣便於記憶,提升了函數的易用性,這是C++語言採用重載機制的一個理由。例如示例8-1-1中的函數EatBeef,EatFish,EatChicken能夠用同一個函數名Eat表示,用不一樣類型的參數加以區別。
void EatBeef(…); // 能夠改成 void Eat(Beef …); void EatFish(…); // 能夠改成 void Eat(Fish …); void EatChicken(…); // 能夠改成 void Eat(Chicken …);
|
示例8-1-1 重載函數Eat
C++語言採用重載機制的另外一個理由是:類的構造函數須要重載機制。由於C++規定構造函數與類同名(請參見第9章),構造函數只能有一個名字。若是想用幾種不一樣的方法建立對象該怎麼辦?別無選擇,只能用重載機制來實現。因此類能夠有多個同名的構造函數。
8.1.2 重載是如何實現的?
幾個同名的重載函數仍然是不一樣的函數,它們是如何區分的呢?咱們天然想到函數接口的兩個要素:參數與返回值。
若是同名函數的參數不一樣(包括類型、順序不一樣),那麼容易區別出它們是不一樣的函數。
若是同名函數僅僅是返回值類型不一樣,有時能夠區分,有時卻不能。例如:
void Function(void);
int Function (void);
上述兩個函數,第一個沒有返回值,第二個的返回值是int類型。若是這樣調用函數:
int x = Function ();
則能夠判斷出Function是第二個函數。問題是在C++/C程序中,咱們能夠忽略函數的返回值。在這種狀況下,編譯器和程序員都不知道哪一個Function函數被調用。
因此只能靠參數而不能靠返回值類型的不一樣來區分重載函數。編譯器根據參數爲每一個重載函數產生不一樣的內部標識符。例如編譯器爲示例8-1-1中的三個Eat函數產生象_eat_beef、_eat_fish、_eat_chicken之類的內部標識符(不一樣的編譯器可能產生不一樣風格的內部標識符)。
若是C++程序要調用已經被編譯後的C函數,該怎麼辦?
假設某個C函數的聲明以下:
void foo(int x, int y);
該函數被C編譯器編譯後在庫中的名字爲_foo,而C++編譯器則會產生像_foo_int_int之類的名字用來支持函數重載和類型安全鏈接。因爲編譯後的名字不一樣,C++程序不能直接調用C函數。C++提供了一個C鏈接交換指定符號extern「C」來解決這個問題。例如:
extern 「C」
{
void foo(int x, int y);
… // 其它函數
}
或者寫成
extern 「C」
{
#include 「myheader.h」
… // 其它C頭文件
}
這就告訴C++編譯譯器,函數foo是個C鏈接,應該到庫中找名字_foo而不是找_foo_int_int。C++編譯器開發商已經對C標準庫的頭文件做了extern「C」處理,因此咱們能夠用#include 直接引用這些頭文件。
注意並非兩個函數的名字相同就能構成重載。全局函數和類的成員函數同名不算重載,由於函數的做用域不一樣。例如:
void Print(…); // 全局函數
class A
{…
void Print(…); // 成員函數
}
不論兩個Print函數的參數是否不一樣,若是類的某個成員函數要調用全局函數Print,爲了與成員函數Print區別,全局函數被調用時應加‘::’標誌。如
::Print(…); // 表示Print是全局函數而非成員函數
8.1.3 小心隱式類型轉換致使重載函數產生二義性
示例8-1-3中,第一個output函數的參數是int類型,第二個output函數的參數是float類型。因爲數字自己沒有類型,將數字看成參數時將自動進行類型轉換(稱爲隱式類型轉換)。語句output(0.5)將產生編譯錯誤,由於編譯器不知道該將0.5轉換成int仍是float類型的參數。隱式類型轉換在不少地方能夠簡化程序的書寫,可是也可能留下隱患。
# include <iostream.h> void output( int x); // 函數聲明 void output( float x); // 函數聲明
void output( int x) { cout << " output int " << x << endl ; }
void output( float x) { cout << " output float " << x << endl ; }
void main(void) { int x = 1; float y = 1.0; output(x); // output int 1 output(y); // output float 1 output(1); // output int 1 // output(0.5); // error! ambiguous call, 由於自動類型轉換 output(int(0.5)); // output int 0 output(float(0.5)); // output float 0.5 } |
示例8-1-3 隱式類型轉換致使重載函數產生二義性
成員函數的重載、覆蓋(override)與隱藏很容易混淆,C++程序員必需要搞清楚概念,不然錯誤將防不勝防。
8.2.1 重載與覆蓋
成員函數被重載的特徵:
(1)相同的範圍(在同一個類中);
(2)函數名字相同;
(3)參數不一樣;
(4)virtual關鍵字無關緊要。
覆蓋是指派生類函數覆蓋基類函數,特徵是:
(1)不一樣的範圍(分別位於派生類與基類);
(2)函數名字相同;
(3)參數相同;
(4)基類函數必須有virtual關鍵字。
示例8-2-1中,函數Base::f(int)與Base::f(float)相互重載,而Base::g(void)被Derived::g(void)覆蓋。
#include <iostream.h> class Base { public: void f(int x){ cout << "Base::f(int) " << x << endl; } void f(float x){ cout << "Base::f(float) " << x << endl; } virtual void g(void){ cout << "Base::g(void)" << endl;} };
|
class Derived : public Base { public: virtual void g(void){ cout << "Derived::g(void)" << endl;} };
|
void main(void) { Derived d; Base *pb = &d; pb->f(42); // Base::f(int) 42 pb->f(3.14f); // Base::f(float) 3.14 pb->g(); // Derived::g(void) } |
示例8-2-1成員函數的重載和覆蓋
8.2.2 使人迷惑的隱藏規則
原本僅僅區別重載與覆蓋並不算困難,可是C++的隱藏規則使問題複雜性陡然增長。這裏「隱藏」是指派生類的函數屏蔽了與其同名的基類函數,規則以下:
(1)若是派生類的函數與基類的函數同名,可是參數不一樣。此時,不論有無virtual關鍵字,基類的函數將被隱藏(注意別與重載混淆)。
(2)若是派生類的函數與基類的函數同名,而且參數也相同,可是基類函數沒有virtual關鍵字。此時,基類的函數被隱藏(注意別與覆蓋混淆)。
示例程序8-2-2(a)中:
(1)函數Derived::f(float)覆蓋了Base::f(float)。
(2)函數Derived::g(int)隱藏了Base::g(float),而不是重載。
(3)函數Derived::h(float)隱藏了Base::h(float),而不是覆蓋。
#include <iostream.h> class Base { public: virtual void f(float x){ cout << "Base::f(float) " << x << endl; } void g(float x){ cout << "Base::g(float) " << x << endl; } void h(float x){ cout << "Base::h(float) " << x << endl; } }; |
class Derived : public Base { public: virtual void f(float x){ cout << "Derived::f(float) " << x << endl; } void g(int x){ cout << "Derived::g(int) " << x << endl; } void h(float x){ cout << "Derived::h(float) " << x << endl; } }; |
示例8-2-2(a)成員函數的重載、覆蓋和隱藏
據做者考察,不少C++程序員沒有意識到有「隱藏」這回事。因爲認識不夠深入,「隱藏」的發生可謂神出鬼沒,經常產生使人迷惑的結果。
示例8-2-2(b)中,bp和dp指向同一地址,按理說運行結果應該是相同的,可事實並不是這樣。
void main(void) { Derived d; Base *pb = &d; Derived *pd = &d; // Good : behavior depends solely on type of the object pb->f(3.14f); // Derived::f(float) 3.14 pd->f(3.14f); // Derived::f(float) 3.14
// Bad : behavior depends on type of the pointer pb->g(3.14f); // Base::g(float) 3.14 pd->g(3.14f); // Derived::g(int) 3 (surprise!)
// Bad : behavior depends on type of the pointer pb->h(3.14f); // Base::h(float) 3.14 (surprise!) pd->h(3.14f); // Derived::h(float) 3.14 } |
示例8-2-2(b) 重載、覆蓋和隱藏的比較
8.2.3 擺脫隱藏
隱藏規則引發了很多麻煩。示例8-2-3程序中,語句pd->f(10)的本意是想調用函數Base::f(int),可是Base::f(int)不幸被Derived::f(char *)隱藏了。因爲數字10不能被隱式地轉化爲字符串,因此在編譯時出錯。
class Base { public: void f(int x); }; |
class Derived : public Base { public: void f(char *str); }; |
void Test(void) { Derived *pd = new Derived; pd->f(10); // error } |
示例8-2-3 因爲隱藏而致使錯誤
從示例8-2-3看來,隱藏規則彷佛很愚蠢。可是隱藏規則至少有兩個存在的理由:
u 寫語句pd->f(10)的人可能真的想調用Derived::f(char *)函數,只是他誤將參數寫錯了。有了隱藏規則,編譯器就能夠明確指出錯誤,這未必不是好事。不然,編譯器會靜悄悄地將錯就錯,程序員將很難發現這個錯誤,流下禍根。
u 假如類Derived有多個基類(多重繼承),有時搞不清楚哪些基類定義了函數f。若是沒有隱藏規則,那麼pd->f(10)可能會調用一個出乎意料的基類函數f。儘管隱藏規則看起來不怎麼有道理,但它的確能消滅這些意外。
示例8-2-3中,若是語句pd->f(10)必定要調用函數Base::f(int),那麼將類Derived修改成以下便可。
class Derived : public Base
{
public:
void f(char *str);
void f(int x) { Base::f(x); }
};
有一些參數的值在每次函數調用時都相同,書寫這樣的語句會令人厭煩。C++語言採用參數的缺省值使書寫變得簡潔(在編譯時,缺省值由編譯器自動插入)。
參數缺省值的使用規則:
l 【規則8-3-1】參數缺省值只能出如今函數的聲明中,而不能出如今定義體中。
例如:
void Foo(int x=0, int y=0); // 正確,缺省值出如今函數的聲明中
void Foo(int x=0, int y=0) // 錯誤,缺省值出如今函數的定義體中
{
…
}
爲何會這樣?我想是有兩個緣由:一是函數的實現(定義)原本就與參數是否有缺省值無關,因此沒有必要讓缺省值出如今函數的定義體中。二是參數的缺省值可能會改動,顯然修改函數的聲明比修改函數的定義要方便。
l 【規則8-3-2】若是函數有多個參數,參數只能從後向前挨個兒缺省,不然將致使函數調用語句怪模怪樣。
正確的示例以下:
void Foo(int x, int y=0, int z=0);
錯誤的示例以下:
void Foo(int x=0, int y, int z=0);
要注意,使用參數的缺省值並無賦予函數新的功能,僅僅是使書寫變得簡潔一些。它可能會提升函數的易用性,可是也可能會下降函數的可理解性。因此咱們只能適當地使用參數的缺省值,要防止使用不當產生負面效果。示例8-3-2中,不合理地使用參數的缺省值將致使重載函數output產生二義性。
#include <iostream.h> void output( int x); void output( int x, float y=0.0);
|
void output( int x) { cout << " output int " << x << endl ; }
|
void output( int x, float y) { cout << " output int " << x << " and float " << y << endl ; }
|
void main(void) { int x=1; float y=0.5; // output(x); // error! ambiguous call output(x,y); // output int 1 and float 0.5 }
|
示例8-3-2 參數的缺省值將致使重載函數產生二義性
8.4.1 概念
在C++語言中,能夠用關鍵字operator加上運算符來表示函數,叫作運算符重載。例如兩個複數相加函數:
Complex Add(const Complex &a, const Complex &b);
能夠用運算符重載來表示:
Complex operator +(const Complex &a, const Complex &b);
運算符與普通函數在調用時的不一樣之處是:對於普通函數,參數出如今圓括號內;而對於運算符,參數出如今其左、右側。例如
Complex a, b, c;
…
c = Add(a, b); // 用普通函數
c = a + b; // 用運算符 +
若是運算符被重載爲全局函數,那麼只有一個參數的運算符叫作一元運算符,有兩個參數的運算符叫作二元運算符。
若是運算符被重載爲類的成員函數,那麼一元運算符沒有參數,二元運算符只有一個右側參數,由於對象本身成了左側參數。
從語法上講,運算符既能夠定義爲全局函數,也能夠定義爲成員函數。文獻[Murray , p44-p47]對此問題做了較多的闡述,並總結了表8-4-1的規則。
運算符 |
規則 |
全部的一元運算符 |
建議重載爲成員函數 |
= () [] -> |
只能重載爲成員函數 |
+= -= /= *= &= |= ~= %= >>= <<= |
建議重載爲成員函數 |
全部其它運算符 |
建議重載爲全局函數 |
表8-4-1 運算符的重載規則
因爲C++語言支持函數重載,才能將運算符當成函數來用,C語言就不行。咱們要以日常心來對待運算符重載:
(1)不要過度擔憂本身不會用,它的本質仍然是程序員們熟悉的函數。
(2)不要過度熱心地使用,若是它不能使代碼變得更加易讀易寫,那就別用,不然會自找麻煩。
8.4.2 不能被重載的運算符
在C++運算符集合中,有一些運算符是不容許被重載的。這種限制是出於安全方面的考慮,可防止錯誤和混亂。
(1)不能改變C++內部數據類型(如int,float等)的運算符。
(2)不能重載‘.’,由於‘.’在類中對任何成員都有意義,已經成爲標準用法。
(3)不能重載目前C++運算符集合中沒有的符號,如#,@,$等。緣由有兩點,一是難以理解,二是難以肯定優先級。
(4)對已經存在的運算符進行重載時,不能改變優先級規則,不然將引發混亂。
8.5.1 用內聯取代宏代碼
C++ 語言支持函數內聯,其目的是爲了提升函數的執行效率(速度)。
在C程序中,能夠用宏代碼提升執行效率。宏代碼自己不是函數,但使用起來象函數。預處理器用複製宏代碼的方式代替函數調用,省去了參數壓棧、生成彙編語言的CALL調用、返回參數、執行return等過程,從而提升了速度。使用宏代碼最大的缺點是容易出錯,預處理器在複製宏代碼時經常產生意想不到的邊際效應。例如
#define MAX(a, b) (a) > (b) ? (a) : (b)
語句
result = MAX(i, j) + 2 ;
將被預處理器解釋爲
result = (i) > (j) ? (i) : (j) + 2 ;
因爲運算符‘+’比運算符‘:’的優先級高,因此上述語句並不等價於指望的
result = ( (i) > (j) ? (i) : (j) ) + 2 ;
若是把宏代碼改寫爲
#define MAX(a, b) ( (a) > (b) ? (a) : (b) )
則能夠解決由優先級引發的錯誤。可是即便使用修改後的宏代碼也不是萬無一失的,例如語句
result = MAX(i++, j);
將被預處理器解釋爲
result = (i++) > (j) ? (i++) : (j);
對於C++ 而言,使用宏代碼還有另外一種缺點:沒法操做類的私有數據成員。
讓咱們看看C++ 的「函數內聯」是如何工做的。對於任何內聯函數,編譯器在符號表裏放入函數的聲明(包括名字、參數類型、返回值類型)。若是編譯器沒有發現內聯函數存在錯誤,那麼該函數的代碼也被放入符號表裏。在調用一個內聯函數時,編譯器首先檢查調用是否正確(進行類型安全檢查,或者進行自動類型轉換,固然對全部的函數都同樣)。若是正確,內聯函數的代碼就會直接替換函數調用,因而省去了函數調用的開銷。這個過程與預處理有顯著的不一樣,由於預處理器不能進行類型安全檢查,或者進行自動類型轉換。假如內聯函數是成員函數,對象的地址(this)會被放在合適的地方,這也是預處理器辦不到的。
C++ 語言的函數內聯機制既具有宏代碼的效率,又增長了安全性,並且能夠自由操做類的數據成員。因此在C++ 程序中,應該用內聯函數取代全部宏代碼,「斷言assert」恐怕是惟一的例外。assert是僅在Debug版本起做用的宏,它用於檢查「不該該」發生的狀況。爲了避免在程序的Debug版本和Release版本引發差異,assert不該該產生任何反作用。若是assert是函數,因爲函數調用會引發內存、代碼的變更,那麼將致使Debug版本與Release版本存在差別。因此assert不是函數,而是宏。(參見6.5節「使用斷言」)
8.5.2 內聯函數的編程風格
關鍵字inline必須與函數定義體放在一塊兒才能使函數成爲內聯,僅將inline放在函數聲明前面不起任何做用。以下風格的函數Foo不能成爲內聯函數:
inline void Foo(int x, int y); // inline僅與函數聲明放在一塊兒
void Foo(int x, int y)
{
…
}
而以下風格的函數Foo則成爲內聯函數:
void Foo(int x, int y);
inline void Foo(int x, int y) // inline與函數定義體放在一塊兒
{
…
}
因此說,inline是一種「用於實現的關鍵字」,而不是一種「用於聲明的關鍵字」。通常地,用戶能夠閱讀函數的聲明,可是看不到函數的定義。儘管在大多數教科書中內聯函數的聲明、定義體前面都加了inline關鍵字,但我認爲inline不該該出如今函數的聲明中。這個細節雖然不會影響函數的功能,可是體現了高質量C++/C程序設計風格的一個基本原則:聲明與定義不可混爲一談,用戶沒有必要、也不該該知道函數是否須要內聯。
定義在類聲明之中的成員函數將自動地成爲內聯函數,例如
class A
{
public:
void Foo(int x, int y) { … } // 自動地成爲內聯函數
}
將成員函數的定義體放在類聲明之中雖然能帶來書寫上的方便,但不是一種良好的編程風格,上例應該改爲:
// 頭文件
class A
{
public:
void Foo(int x, int y);
}
// 定義文件
inline void A::Foo(int x, int y)
{
…
}
8.5.3 慎用內聯
內聯能提升函數的執行效率,爲何不把全部的函數都定義成內聯函數?
若是全部的函數都是內聯函數,還用得着「內聯」這個關鍵字嗎?
內聯是以代碼膨脹(複製)爲代價,僅僅省去了函數調用的開銷,從而提升函數的執行效率。若是執行函數體內代碼的時間,相比於函數調用的開銷較大,那麼效率的收穫會不多。另外一方面,每一處內聯函數的調用都要複製代碼,將使程序的總代碼量增大,消耗更多的內存空間。如下狀況不宜使用內聯:
(1)若是函數體內的代碼比較長,使用內聯將致使內存消耗代價較高。
(2)若是函數體內出現循環,那麼執行函數體內代碼的時間要比函數調用的開銷大。
類的構造函數和析構函數容易讓人誤解成使用內聯更有效。要小心構造函數和析構函數可能會隱藏一些行爲,如「偷偷地」執行了基類或成員對象的構造函數和析構函數。因此不要隨便地將構造函數和析構函數的定義體放在類聲明中。
一個好的編譯器將會根據函數的定義體,自動地取消不值得的內聯(這進一步說明了inline不該該出如今函數的聲明中)。
C++ 語言中的重載、內聯、缺省參數、隱式轉換等機制展示了不少優勢,可是這些優勢的背後都隱藏着一些隱患。正如人們的飲食,少食和暴食都不可取,應當恰到好處。咱們要辨證地看待C++的新機制,應該恰如其分地使用它們。雖然這會使咱們編程時多費一些心思,少了一些痛快,但這纔是編程的藝術。
構造函數、析構函數與賦值函數是每一個類最基本的函數。它們太普通以至讓人容易麻痹大意,其實這些貌似簡單的函數就象沒有頂蓋的下水道那樣危險。
每一個類只有一個析構函數和一個賦值函數,但能夠有多個構造函數(包含一個拷貝構造函數,其它的稱爲普通構造函數)。對於任意一個類A,若是不想編寫上述函數,C++編譯器將自動爲A產生四個缺省的函數,如
A(void); // 缺省的無參數構造函數
A(const A &a); // 缺省的拷貝構造函數
~A(void); // 缺省的析構函數
A & operate =(const A &a); // 缺省的賦值函數
這不由讓人疑惑,既然能自動生成函數,爲何還要程序員編寫?
緣由以下:
(1)若是使用「缺省的無參數構造函數」和「缺省的析構函數」,等於放棄了自主「初始化」和「清除」的機會,C++發明人Stroustrup的好心好意白費了。
(2)「缺省的拷貝構造函數」和「缺省的賦值函數」均採用「位拷貝」而非「值拷貝」的方式來實現,假若類中含有指針變量,這兩個函數註定將出錯。
對於那些沒有吃夠苦頭的C++程序員,若是他說編寫構造函數、析構函數與賦值函數很容易,能夠不用動腦筋,代表他的認識還比較膚淺,水平有待於提升。
本章以類String的設計與實現爲例,深刻闡述被不少教科書忽視了的道理。String的結構以下:
class String
{
public:
String(const char *str = NULL); // 普通構造函數
String(const String &other); // 拷貝構造函數
~ String(void); // 析構函數
String & operate =(const String &other); // 賦值函數
private:
char *m_data; // 用於保存字符串
};
做爲比C更先進的語言,C++提供了更好的機制來加強程序的安全性。C++編譯器具備嚴格的類型安全檢查功能,它幾乎能找出程序中全部的語法問題,這的確幫了程序員的大忙。可是程序經過了編譯檢查並不表示錯誤已經不存在了,在「錯誤」的你們庭裏,「語法錯誤」的地位只能算是小弟弟。級別高的錯誤一般隱藏得很深,就象狡猾的罪犯,想逮住他可不容易。
根據經驗,很多難以察覺的程序錯誤是因爲變量沒有被正確初始化或清除形成的,而初始化和清除工做很容易被人遺忘。Stroustrup在設計C++語言時充分考慮了這個問題並很好地予以解決:把對象的初始化工做放在構造函數中,把清除工做放在析構函數中。當對象被建立時,構造函數被自動執行。當對象消亡時,析構函數被自動執行。這下就不用擔憂忘了對象的初始化和清除工做。
構造函數與析構函數的名字不能隨便起,必須讓編譯器認得出才能夠被自動執行。Stroustrup的命名方法既簡單又合理:讓構造函數、析構函數與類同名,因爲析構函數的目的與構造函數的相反,就加前綴‘~’以示區別。
除了名字外,構造函數與析構函數的另外一個特別之處是沒有返回值類型,這與返回值類型爲void的函數不一樣。構造函數與析構函數的使命很是明確,就象出生與死亡,光溜溜地來光溜溜地去。若是它們有返回值類型,那麼編譯器將不知所措。爲了防止節外生枝,乾脆規定沒有返回值類型。(以上典故參考了文獻[Eekel, p55-p56])
構造函數有個特殊的初始化方式叫「初始化表達式表」(簡稱初始化表)。初始化表位於函數參數表以後,卻在函數體 {} 以前。這說明該表裏的初始化工做發生在函數體內的任何代碼被執行以前。
構造函數初始化表的使用規則:
u 若是類存在繼承關係,派生類必須在其初始化表裏調用基類的構造函數。
例如
class A
{…
A(int x); // A的構造函數
};
class B : public A
{…
B(int x, int y);// B的構造函數
};
B::B(int x, int y)
: A(x) // 在初始化表裏調用A的構造函數
{
…
}
u 類的const常量只能在初始化表裏被初始化,由於它不能在函數體內用賦值的方式來初始化(參見5.4節)。
u 類的數據成員的初始化能夠採用初始化表或函數體內賦值兩種方式,這兩種方式的效率不徹底相同。
非內部數據類型的成員對象應當採用第一種方式初始化,以獲取更高的效率。例如
class A
{…
A(void); // 無參數構造函數
A(const A &other); // 拷貝構造函數
A & operate =( const A &other); // 賦值函數
};
class B
{
public:
B(const A &a); // B的構造函數
private:
A m_a; // 成員對象
};
示例9-2(a)中,類B的構造函數在其初始化表裏調用了類A的拷貝構造函數,從而將成員對象m_a初始化。
示例9-2 (b)中,類B的構造函數在函數體內用賦值的方式將成員對象m_a初始化。咱們看到的只是一條賦值語句,但實際上B的構造函數幹了兩件事:先暗地裏建立m_a對象(調用了A的無參數構造函數),再調用類A的賦值函數,將參數a賦給m_a。
B::B(const A &a) : m_a(a) { … } |
B::B(const A &a) { m_a = a; … } |
示例9-2(a) 成員對象在初始化表中被初始化 示例9-2(b) 成員對象在函數體內被初始化
對於內部數據類型的數據成員而言,兩種初始化方式的效率幾乎沒有區別,但後者的程序版式彷佛更清晰些。若類F的聲明以下:
class F
{
public:
F(int x, int y); // 構造函數
private:
int m_x, m_y;
int m_i, m_j;
}
示例9-2(c)中F的構造函數採用了第一種初始化方式,示例9-2(d)中F的構造函數採用了第二種初始化方式。
F::F(int x, int y) : m_x(x), m_y(y) { m_i = 0; m_j = 0; } |
F::F(int x, int y) { m_x = x; m_y = y; m_i = 0; m_j = 0; } |
示例9-2(c) 數據成員在初始化表中被初始化 示例9-2(d) 數據成員在函數體內被初始化
構造從類層次的最根處開始,在每一層中,首先調用基類的構造函數,而後調用成員對象的構造函數。析構則嚴格按照與構造相反的次序執行,該次序是惟一的,不然編譯器將沒法自動執行析構過程。
一個有趣的現象是,成員對象初始化的次序徹底不受它們在初始化表中次序的影響,只由成員對象在類中聲明的次序決定。這是由於類的聲明是惟一的,而類的構造函數能夠有多個,所以會有多個不一樣次序的初始化表。若是成員對象按照初始化表的次序進行構造,這將致使析構函數沒法獲得惟一的逆序。[Eckel, p260-261]
// String的普通構造函數
String::String(const char *str)
{
if(str==NULL)
{
m_data = new char[1];
*m_data = ‘\0’;
}
else
{
int length = strlen(str);
m_data = new char[length+1];
strcpy(m_data, str);
}
}
// String的析構函數
String::~String(void)
{
delete [] m_data;
// 因爲m_data是內部數據類型,也能夠寫成 delete m_data;
}
因爲並不是全部的對象都會使用拷貝構造函數和賦值函數,程序員可能對這兩個函數有些輕視。請先記住如下的警告,在閱讀正文時就會多心:
u 本章開頭講過,若是不主動編寫拷貝構造函數和賦值函數,編譯器將以「位拷貝」的方式自動生成缺省的函數。假若類中含有指針變量,那麼這兩個缺省的函數就隱含了錯誤。以類String的兩個對象a,b爲例,假設a.m_data的內容爲「hello」,b.m_data的內容爲「world」。
現將a賦給b,缺省賦值函數的「位拷貝」意味着執行b.m_data = a.m_data。這將形成三個錯誤:一是b.m_data原有的內存沒被釋放,形成內存泄露;二是b.m_data和a.m_data指向同一塊內存,a或b任何一方變更都會影響另外一方;三是在對象被析構時,m_data被釋放了兩次。
u 拷貝構造函數和賦值函數很是容易混淆,常致使錯寫、錯用。拷貝構造函數是在對象被建立時調用的,而賦值函數只能被已經存在了的對象調用。如下程序中,第三個語句和第四個語句很類似,你分得清楚哪一個調用了拷貝構造函數,哪一個調用了賦值函數嗎?
String a(「hello」);
String b(「world」);
String c = a; // 調用了拷貝構造函數,最好寫成 c(a);
c = b; // 調用了賦值函數
本例中第三個語句的風格較差,宜改寫成String c(a) 以區別於第四個語句。
// 拷貝構造函數
String::String(const String &other)
{
// 容許操做other的私有成員m_data
int length = strlen(other.m_data);
m_data = new char[length+1];
strcpy(m_data, other.m_data);
}
// 賦值函數
String & String::operate =(const String &other)
{
// (1) 檢查自賦值
if(this == &other)
return *this;
// (2) 釋放原有的內存資源
delete [] m_data;
// (3)分配新的內存資源,並複製內容
int length = strlen(other.m_data);
m_data = new char[length+1];
strcpy(m_data, other.m_data);
// (4)返回本對象的引用
return *this;
}
類String拷貝構造函數與普通構造函數(參見9.4節)的區別是:在函數入口處無需與NULL進行比較,這是由於「引用」不多是NULL,而「指針」能夠爲NULL。
類String的賦值函數比構造函數複雜得多,分四步實現:
(1)第一步,檢查自賦值。你可能會認爲畫蛇添足,難道有人會愚蠢到寫出 a = a 這樣的自賦值語句!的確不會。可是間接的自賦值仍有可能出現,例如
// 內容自賦值 b = a; … c = b; … a = c; |
// 地址自賦值 b = &a; … a = *b; |
也許有人會說:「即便出現自賦值,我也能夠不理睬,大不了化點時間讓對象複製本身而已,反正不會出錯!」
他真的說錯了。看看第二步的delete,自殺後還能複製本身嗎?因此,若是發現自賦值,應該立刻終止函數。注意不要將檢查自賦值的if語句
if(this == &other)
錯寫成爲
if( *this == other)
(2)第二步,用delete釋放原有的內存資源。若是如今不釋放,之後就沒機會了,將形成內存泄露。
(3)第三步,分配新的內存資源,並複製字符串。注意函數strlen返回的是有效字符串長度,不包含結束符‘\0’。函數strcpy則連‘\0’一塊兒複製。
(4)第四步,返回本對象的引用,目的是爲了實現象 a = b = c 這樣的鏈式表達。注意不要將 return *this 錯寫成 return this 。那麼可否寫成return other 呢?效果不是同樣嗎?
不能夠!由於咱們不知道參數other的生命期。有可能other是個臨時對象,在賦值結束後它立刻消失,那麼return other返回的將是垃圾。
若是咱們實在不想編寫拷貝構造函數和賦值函數,又不容許別人使用編譯器生成的缺省函數,怎麼辦?
偷懶的辦法是:只需將拷貝構造函數和賦值函數聲明爲私有函數,不用編寫代碼。
例如:
class A
{ …
private:
A(const A &a); // 私有的拷貝構造函數
A & operate =(const A &a); // 私有的賦值函數
};
若是有人試圖編寫以下程序:
A b(a); // 調用了私有的拷貝構造函數
b = a; // 調用了私有的賦值函數
編譯器將指出錯誤,由於外界不能夠操做A的私有函數。
基類的構造函數、析構函數、賦值函數都不能被派生類繼承。若是類之間存在繼承關係,在編寫上述基本函數時應注意如下事項:
u 派生類的構造函數應在其初始化表裏調用基類的構造函數。
u 基類與派生類的析構函數應該爲虛(即加virtual關鍵字)。例如
#include <iostream.h>
class Base
{
public:
virtual ~Base() { cout<< "~Base" << endl ; }
};
class Derived : public Base
{
public:
virtual ~Derived() { cout<< "~Derived" << endl ; }
};
void main(void)
{
Base * pB = new Derived; // upcast
delete pB;
}
輸出結果爲:
~Derived
~Base
若是析構函數不爲虛,那麼輸出結果爲
~Base
u 在編寫派生類的賦值函數時,注意不要忘記對基類的數據成員從新賦值。例如:
class Base
{
public:
…
Base & operate =(const Base &other); // 類Base的賦值函數
private:
int m_i, m_j, m_k;
};
class Derived : public Base
{
public:
…
Derived & operate =(const Derived &other); // 類Derived的賦值函數
private:
int m_x, m_y, m_z;
};
Derived & Derived::operate =(const Derived &other)
{
//(1)檢查自賦值
if(this == &other)
return *this;
//(2)對基類的數據成員從新賦值
Base::operate =(other); // 由於不能直接操做私有數據成員
//(3)對派生類的數據成員賦值
m_x = other.m_x;
m_y = other.m_y;
m_z = other.m_z;
//(4)返回本對象的引用
return *this;
}
有些C++程序設計書籍稱構造函數、析構函數和賦值函數是類的「Big-Three」,它們的確是任何類最重要的函數,不容輕視。
也許你認爲本章的內容已經夠多了,學會了就能平安無事,我不能做這個保證。若是你但願吃透「Big-Three」,請好好閱讀參考文獻[Cline] [Meyers] [Murry]。
對象(Object)是類(Class)的一個實例(Instance)。若是將對象比做房子,那麼類就是房子的設計圖紙。因此面向對象設計的重點是類的設計,而不是對象的設計。
對於C++程序而言,設計孤立的類是比較容易的,難的是正確設計基類及其派生類。本章僅僅論述「繼承」(Inheritance)和「組合」(Composition)的概念。
注意,當前面向對象技術的應用熱點是COM和CORBA,這些內容超出了C++教材的範疇,請閱讀COM和CORBA相關論著。
若是A是基類,B是A的派生類,那麼B將繼承A的數據和函數。例如:
class A
{
public:
void Func1(void);
void Func2(void);
};
class B : public A
{
public:
void Func3(void);
void Func4(void);
};
main()
{
B b;
b.Func1(); // B從A繼承了函數Func1
b.Func2(); // B從A繼承了函數Func2
b.Func3();
b.Func4();
}
這個簡單的示例程序說明了一個事實:C++的「繼承」特性能夠提升程序的可複用性。正由於「繼承」太有用、太容易用,纔要防止亂用「繼承」。咱們應當給「繼承」立一些使用規則。
l 【規則10-1-1】若是類A和類B絕不相關,不能夠爲了使B的功能更多些而讓B繼承A的功能和屬性。不要以爲「白吃白不吃」,讓一個好端端的健壯青年平白無故地吃人蔘補身體。
l 【規則10-1-2】若在邏輯上B是A的「一種」(a kind of ),則容許B繼承A的功能和屬性。例如男人(Man)是人(Human)的一種,男孩(Boy)是男人的一種。那麼類Man能夠從類Human派生,類Boy能夠從類Man派生。
class Human
{
…
};
class Man : public Human
{
…
};
class Boy : public Man
{
…
};
u 注意事項
【規則10-1-2】看起來很簡單,可是實際應用時可能會有意外,繼承的概念在程序世界與現實世界並不徹底相同。
例如從生物學角度講,鴕鳥(Ostrich)是鳥(Bird)的一種,按理說類Ostrich應該能夠從類Bird派生。可是鴕鳥不能飛,那麼Ostrich::Fly是什麼東西?
class Bird
{
public:
virtual void Fly(void);
…
};
class Ostrich : public Bird
{
…
};
例如從數學角度講,圓(Circle)是一種特殊的橢圓(Ellipse),按理說類Circle應該能夠從類Ellipse派生。可是橢圓有長軸和短軸,若是圓繼承了橢圓的長軸和短軸,豈非多此一舉?
因此更加嚴格的繼承規則應當是:若在邏輯上B是A的「一種」,而且A的全部功能和屬性對B而言都有意義,則容許B繼承A的功能和屬性。
l 【規則10-2-1】若在邏輯上A是B的「一部分」(a part of),則不容許B從A派生,而是要用A和其它東西組合出B。
例如眼(Eye)、鼻(Nose)、口(Mouth)、耳(Ear)是頭(Head)的一部分,因此類Head應該由類Eye、Nose、Mouth、Ear組合而成,不是派生而成。如示例10-2-1所示。
class Eye { void Look(void); }; |
class Nose { void Smell(void); }; |
class Mouth { void Eat(void); }; |
class Ear { void Listen(void); }; |
// 正確的設計,雖然代碼冗長。 class Head { public: void Look(void) { m_eye.Look(); } void Smell(void) { m_nose.Smell(); } void Eat(void) { m_mouth.Eat(); } void Listen(void) { m_ear.Listen(); } private: Eye m_eye; Nose m_nose; Mouth m_mouth; Ear m_ear; }; |
示例10-2-1 Head由Eye、Nose、Mouth、Ear組合而成
若是容許Head從Eye、Nose、Mouth、Ear派生而成,那麼Head將自動具備Look、 Smell、Eat、Listen這些功能。示例10-2-2十分簡短而且運行正確,可是這種設計方法倒是不對的。
// 功能正確而且代碼簡潔,可是設計方法不對。 class Head : public Eye, public Nose, public Mouth, public Ear { }; |
示例10-2-2 Head從Eye、Nose、Mouth、Ear派生而成
一隻公雞使勁地追打一隻剛下了蛋的母雞,你知道爲何嗎?
由於母雞下了鴨蛋。
不少程序員經不起「繼承」的誘惑而犯下設計錯誤。「運行正確」的程序不見得是高質量的程序,此處就是一個例證。
看到const關鍵字,C++程序員首先想到的多是const常量。這可不是良好的條件反射。若是隻知道用const定義常量,那麼至關於把火藥僅用於製做鞭炮。const更大的魅力是它能夠修飾函數的參數、返回值,甚至函數的定義體。
const是constant的縮寫,「恆定不變」的意思。被const修飾的東西都受到強制保護,能夠預防意外的變更,能提升程序的健壯性。因此不少C++程序設計書籍建議:「Use const whenever you need」。
11.1.1 用const修飾函數的參數
若是參數做輸出用,不論它是什麼數據類型,也不論它採用「指針傳遞」仍是「引用傳遞」,都不能加const修飾,不然該參數將失去輸出功能。
const只能修飾輸入參數:
u 若是輸入參數採用「指針傳遞」,那麼加const修飾能夠防止意外地改動該指針,起到保護做用。
例如StringCopy函數:
void StringCopy(char *strDestination, const char *strSource);
其中strSource是輸入參數,strDestination是輸出參數。給strSource加上const修飾後,若是函數體內的語句試圖改動strSource的內容,編譯器將指出錯誤。
u 若是輸入參數採用「值傳遞」,因爲函數將自動產生臨時變量用於複製該參數,該輸入參數原本就無需保護,因此不要加const修飾。
例如不要將函數void Func1(int x) 寫成void Func1(const int x)。同理不要將函數void Func2(A a) 寫成void Func2(const A a)。其中A爲用戶自定義的數據類型。
u 對於非內部數據類型的參數而言,象void Func(A a) 這樣聲明的函數註定效率比較底。由於函數體內將產生A類型的臨時對象用於複製參數a,而臨時對象的構造、複製、析構過程都將消耗時間。
爲了提升效率,能夠將函數聲明改成void Func(A &a),由於「引用傳遞」僅借用一下參數的別名而已,不須要產生臨時對象。可是函數void Func(A &a) 存在一個缺點:「引用傳遞」有可能改變參數a,這是咱們不指望的。解決這個問題很容易,加const修飾便可,所以函數最終成爲void Func(const A &a)。
以此類推,是否應將void Func(int x) 改寫爲void Func(const int &x),以便提升效率?徹底沒有必要,由於內部數據類型的參數不存在構造、析構的過程,而複製也很是快,「值傳遞」和「引用傳遞」的效率幾乎至關。
問題是如此的纏綿,我只好將「const &」修飾輸入參數的用法總結一下,如表11-1-1所示。
對於非內部數據類型的輸入參數,應該將「值傳遞」的方式改成「const引用傳遞」,目的是提升效率。例如將void Func(A a) 改成void Func(const A &a)。
|
對於內部數據類型的輸入參數,不要將「值傳遞」的方式改成「const引用傳遞」。不然既達不到提升效率的目的,又下降了函數的可理解性。例如void Func(int x) 不該該改成void Func(const int &x)。
|
表11-1-1 「const &」修飾輸入參數的規則
11.1.2 用const修飾函數的返回值
u 若是給以「指針傳遞」方式的函數返回值加const修飾,那麼函數返回值(即指針)的內容不能被修改,該返回值只能被賦給加const修飾的同類型指針。
例如函數
const char * GetString(void);
以下語句將出現編譯錯誤:
char *str = GetString();
正確的用法是
const char *str = GetString();
u 若是函數返回值採用「值傳遞方式」,因爲函數會把返回值複製到外部臨時的存儲單元中,加const修飾沒有任何價值。
例如不要把函數int GetInt(void) 寫成const int GetInt(void)。
同理不要把函數A GetA(void) 寫成const A GetA(void),其中A爲用戶自定義的數據類型。
若是返回值不是內部數據類型,將函數A GetA(void) 改寫爲const A & GetA(void)的確能提升效率。但此時千萬千萬要當心,必定要搞清楚函數到底是想返回一個對象的「拷貝」仍是僅返回「別名」就能夠了,不然程序會出錯。見6.2節「返回值的規則」。
u 函數返回值採用「引用傳遞」的場合並很少,這種方式通常只出如今類的賦值函數中,目的是爲了實現鏈式表達。
例如
class A
{…
A & operate = (const A &other); // 賦值函數
};
A a, b, c; // a, b, c 爲A的對象
…
a = b = c; // 正常的鏈式賦值
(a = b) = c; // 不正常的鏈式賦值,但合法
若是將賦值函數的返回值加const修飾,那麼該返回值的內容不容許被改動。上例中,語句 a = b = c仍然正確,可是語句 (a = b) = c 則是非法的。
11.1.3 const成員函數
任何不會修改數據成員的函數都應該聲明爲const類型。若是在編寫const成員函數時,不慎修改了數據成員,或者調用了其它非const成員函數,編譯器將指出錯誤,這無疑會提升程序的健壯性。
如下程序中,類stack的成員函數GetCount僅用於計數,從邏輯上講GetCount應當爲const函數。編譯器將指出GetCount函數中的錯誤。
class Stack
{
public:
void Push(int elem);
int Pop(void);
int GetCount(void) const; // const成員函數
private:
int m_num;
int m_data[100];
};
int Stack::GetCount(void) const
{
++ m_num; // 編譯錯誤,企圖修改數據成員m_num
Pop(); // 編譯錯誤,企圖調用非const函數
return m_num;
}
const成員函數的聲明看起來怪怪的:const關鍵字只能放在函數聲明的尾部,大概是由於其它地方都已經被佔用了。
程序的時間效率是指運行速度,空間效率是指程序佔用內存或者外存的情況。
全局效率是指站在整個系統的角度上考慮的效率,局部效率是指站在模塊或函數角度上考慮的效率。
l 【規則11-2-1】不要一味地追求程序的效率,應當在知足正確性、可靠性、健壯性、可讀性等質量因素的前提下,設法提升程序的效率。
l 【規則11-2-2】以提升程序的全局效率爲主,提升局部效率爲輔。
l 【規則11-2-3】在優化程序的效率時,應當先找出限制效率的「瓶頸」,不要在可有可無之處優化。
l 【規則11-2-4】先優化數據結構和算法,再優化執行代碼。
l 【規則11-2-5】有時候時間效率和空間效率可能對立,此時應當分析那個更重要,做出適當的折衷。例如多花費一些內存來提升性能。
l 【規則11-2-6】不要追求緊湊的代碼,由於緊湊的代碼並不能產生高效的機器碼。
² 【建議11-3-1】小心那些視覺上不易分辨的操做符發生書寫錯誤。
咱們常常會把「==」誤寫成「=」,象「||」、「&&」、「<=」、「>=」這類符號也很容易發生「丟1」失誤。然而編譯器卻不必定能自動指出這類錯誤。
² 【建議11-3-2】變量(指針、數組)被建立以後應當及時把它們初始化,以防止把未被初始化的變量當成右值使用。
² 【建議11-3-3】小心變量的初值、缺省值錯誤,或者精度不夠。
² 【建議11-3-4】小心數據類型轉換髮生錯誤。儘可能使用顯式的數據類型轉換(讓人們知道發生了什麼事),避免讓編譯器輕悄悄地進行隱式的數據類型轉換。
² 【建議11-3-5】小心變量發生上溢或下溢,數組的下標越界。
² 【建議11-3-6】小心忘記編寫錯誤處理程序,小心錯誤處理程序自己有誤。
² 【建議11-3-7】小心文件I/O有錯誤。
² 【建議11-3-8】避免編寫技巧性很高代碼。
² 【建議11-3-9】不要設計面面俱到、很是靈活的數據結構。
² 【建議11-3-10】若是原有的代碼質量比較好,儘可能複用它。可是不要修補不好勁的代碼,應當從新編寫。
² 【建議11-3-11】儘可能使用標準庫函數,不要「發明」已經存在的庫函數。
² 【建議11-3-12】儘可能不要使用與具體硬件或軟件環境關係密切的變量。
² 【建議11-3-13】把編譯器的選擇項設置爲最嚴格狀態。
² 【建議11-3-14】若是可能的話,使用PC-Lint、LogiScope等工具進行代碼審查。
[Cline] Marshall P. Cline and Greg A. Lomow, C++ FAQs, Addison-Wesley, 1995
[Eckel] Bruce Eckel, Thinking in C++(C++ 編程思想,劉宗田 等譯),機械工業出版社,2000
[Maguire] Steve Maguire, Writing Clean Code(編程精粹,姜靜波 等譯),電子工業出版社,1993
[Meyers] Scott Meyers, Effective C++, Addison-Wesley, 1992
[Murry] Robert B. Murry, C++ Strategies and Tactics, Addison-Wesley, 1993
[Summit] Steve Summit, C Programming FAQs, Addison-Wesley, 1996
文件結構 |
||
重要性 |
審查項 |
結論 |
|
頭文件和定義文件的名稱是否合理? |
|
|
頭文件和定義文件的目錄結構是否合理? |
|
|
版權和版本聲明是否完整? |
|
重要 |
頭文件是否使用了 ifndef/define/endif 預處理塊? |
|
|
頭文件中是否只存放「聲明」而不存放「定義」 |
|
|
…… |
|
程序的版式 |
||
重要性 |
審查項 |
結論 |
|
空行是否得體? |
|
|
代碼行內的空格是否得體? |
|
|
長行拆分是否得體? |
|
|
「{」 和 「}」 是否各佔一行而且對齊於同一列? |
|
重要 |
一行代碼是否只作一件事?如只定義一個變量,只寫一條語句。 |
|
重要 |
If、for、while、do等語句自佔一行,不論執行語句多少都要加「{}」。 |
|
重要 |
在定義變量(或參數)時,是否將修飾符 * 和 & 緊靠變量名? |
|
|
註釋是否清晰而且必要? |
|
重要 |
註釋是否有錯誤或者可能致使誤解? |
|
重要 |
類結構的public, protected, private順序是否在全部的程序中保持一致? |
|
|
…… |
|
命名規則 |
||
重要性 |
審查項 |
結論 |
重要 |
命名規則是否與所採用的操做系統或開發工具的風格保持一致? |
|
|
標識符是否直觀且能夠拼讀? |
|
|
標識符的長度應當符合「min-length && max-information」原則? |
|
重要 |
程序中是否出現相同的局部變量和所有變量? |
|
|
類名、函數名、變量和參數、常量的書寫格式是否遵循必定的規則? |
|
|
靜態變量、全局變量、類的成員變量是否加前綴? |
|
|
…… |
|
表達式與基本語句 |
||
重要性 |
審查項 |
結論 |
重要 |
若是代碼行中的運算符比較多,是否已經用括號清楚地肯定表達式的操做順序? |
|
|
是否編寫太複雜或者多用途的複合表達式? |
|
重要 |
是否將複合表達式與「真正的數學表達式」混淆? |
|
重要 |
是否用隱含錯誤的方式寫if語句? 例如 (1)將布爾變量直接與TRUE、FALSE或者1、0進行比較。 (2)將浮點變量用「==」或「!=」與任何數字比較。 (3)將指針變量用「==」或「!=」與NULL比較。 |
|
|
若是循環體內存在邏輯判斷,而且循環次數很大,是否已經將邏輯判斷移到循環體的外面? |
|
重要 |
Case語句的結尾是否忘了加break? |
|
重要 |
是否忘記寫switch的default分支? |
|
重要 |
使用goto 語句時是否留下隱患? 例如跳過了某些對象的構造、變量的初始化、重要的計算等。 |
|
|
…… |
|
常量 |
||
重要性 |
審查項 |
結論 |
|
是否使用含義直觀的常量來表示那些將在程序中屢次出現的數字或字符串? |
|
|
在C++ 程序中,是否用const常量取代宏常量? |
|
重要 |
若是某一常量與其它常量密切相關,是否在定義中包含了這種關係? |
|
|
是否誤解了類中的const數據成員?由於const數據成員只在某個對象生存期內是常量,而對於整個類而言倒是可變的。 |
|
|
…… |
|
函數設計 |
||
重要性 |
審查項 |
結論 |
|
參數的書寫是否完整?不要貪圖省事只寫參數的類型而省略參數名字。 |
|
|
參數命名、順序是否合理? |
|
|
參數的個數是否太多? |
|
|
是否使用類型和數目不肯定的參數? |
|
|
是否省略了函數返回值的類型? |
|
|
函數名字與返回值類型在語義上是否衝突? |
|
重要 |
是否將正常值和錯誤標誌混在一塊兒返回?正常值應當用輸出參數得到,而錯誤標誌用return語句返回。 |
|
重要 |
在函數體的「入口處」,是否用assert對參數的有效性進行檢查? |
|
重要 |
使用濫用了assert? 例如混淆非法狀況與錯誤狀況,後者是必然存在的而且是必定要做出處理的。 |
|
重要 |
return語句是否返回指向「棧內存」的「指針」或者「引用」? |
|
|
是否使用const提升函數的健壯性?const能夠強制保護函數的參數、返回值,甚至函數的定義體。「Use const whenever you need」 |
|
|
…… |
|
內存管理 |
||
重要性 |
審查項 |
結論 |
重要 |
用malloc或new申請內存以後,是否當即檢查指針值是否爲NULL?(防止使用指針值爲NULL的內存) |
|
重要 |
是否忘記爲數組和動態內存賦初值?(防止將未被初始化的內存做爲右值使用) |
|
重要 |
數組或指針的下標是否越界? |
|
重要 |
動態內存的申請與釋放是否配對?(防止內存泄漏) |
|
重要 |
是否有效地處理了「內存耗盡」問題? |
|
重要 |
是否修改「指向常量的指針」的內容? |
|
重要 |
是否出現野指針?例如 (1)指針變量沒有被初始化。 (2)用free或delete釋放了內存以後,忘記將指針設置爲NULL。 |
|
重要 |
是否將malloc/free 和 new/delete 混淆使用? |
|
重要 |
malloc語句是否正確無誤?例如字節數是否正確?類型轉換是否正確? |
|
重要 |
在建立與釋放動態對象數組時,new/delete的語句是否正確無誤? |
|
|
…… |
|
C++ 函數的高級特性 |
||
重要性 |
審查項 |
結論 |
|
重載函數是否有二義性? |
|
重要 |
是否混淆了成員函數的重載、覆蓋與隱藏? |
|
|
運算符的重載是否符合制定的編程規範? |
|
|
是否濫用內聯函數?例如函數體內的代碼比較長,函數體內出現循環。 |
|
重要 |
是否用內聯函數取代了宏代碼? |
|
|
…… |
|
類的構造函數、析構函數和賦值函數 |
||
重要性 |
審查項 |
結論 |
重要 |
是否違背編程規範而讓C++ 編譯器自動爲類產生四個缺省的函數:(1)缺省的無參數構造函數;(2)缺省的拷貝構造函數;(3)缺省的析構函數;(4)缺省的賦值函數。 |
|
重要 |
構造函數中是否遺漏了某些初始化工做? |
|
重要 |
是否正確地使用構造函數的初始化表? |
|
重要 |
析構函數中是否遺漏了某些清除工做? |
|
|
是否錯寫、錯用了拷貝構造函數和賦值函數? |
|
重要 |
賦值函數通常分四個步驟:(1)檢查自賦值;(2)釋放原有內存資源;(3)分配新的內存資源,並複製內容;(4)返回 *this。是否遺漏了重要步驟? |
|
重要 |
是否正確地編寫了派生類的構造函數、析構函數、賦值函數?注意事項: (1)派生類不可能繼承基類的構造函數、析構函數、賦值函數。 (2)派生類的構造函數應在其初始化表裏調用基類的構造函數。 (3)基類與派生類的析構函數應該爲虛(即加virtual關鍵字)。 (4)在編寫派生類的賦值函數時,注意不要忘記對基類的數據成員從新賦值。 |
|
|
…… |
|
類的高級特性 |
||
重要性 |
審查項 |
結論 |
重要 |
是否違背了繼承和組合的規則? (1)若在邏輯上B是A的「一種」,而且A的全部功能和屬性對B而言都有意義,則容許B繼承A的功能和屬性。 (2)若在邏輯上A是B的「一部分」(a part of),則不容許B從A派生,而是要用A和其它東西組合出B。 |
|
|
…… |
|
其它常見問題 |
||
重要性 |
審查項 |
結論 |
重要 |
數據類型問題: (1)變量的數據類型有錯誤嗎? (2)存在不一樣數據類型的賦值嗎? (3)存在不一樣數據類型的比較嗎? |
|
重要 |
變量值問題: (1)變量的初始化或缺省值有錯誤嗎? (2)變量發生上溢或下溢嗎? (3)變量的精度夠嗎? |
|
重要 |
邏輯判斷問題: (1)因爲精度緣由致使比較無效嗎? (2)表達式中的優先級有誤嗎? (3)邏輯判斷結果顛倒嗎? |
|
重要 |
循環問題: (1)循環終止條件不正確嗎? (2)沒法正常終止(死循環)嗎? (3)錯誤地修改循環變量嗎? (4)存在偏差累積嗎? |
|
重要 |
錯誤處理問題: (1)忘記進行錯誤處理嗎? (2)錯誤處理程序塊一直沒有機會被運行? (3)錯誤處理程序塊自己就有毛病嗎?如報告的錯誤與實際錯誤不一致,處理方式不正確等等。 (4)錯誤處理程序塊是「馬後炮」嗎?如在被它被調用以前軟件已經出錯。 |
|
重要 |
文件I/O問題: (1)對不存在的或者錯誤的文件進行操做嗎? (2)文件以不正確的方式打開嗎? (3)文件結束判斷不正確嗎? (4)沒有正確地關閉文件嗎? |
|
本試題僅用於考查C++/C程序員的基本編程技能。內容限於C++/C經常使用語法,不涉及數據結構、算法以及深奧的語法。考試成績能反映出考生的編程質量以及對C++/C的理解程度,但不能反映考生的智力和軟件開發能力。
筆試時間90分鐘。請考生認真答題,切勿輕視。
1、請填寫BOOL , float, 指針變量 與「零值」比較的 if 語句。(10分)
提示:這裏「零值」能夠是0, 0.0 , FALSE或者「空指針」。例如 int 變量 n 與「零值」比較的 if 語句爲:
if ( n == 0 )
if ( n != 0 )
以此類推。
請寫出 BOOL flag 與「零值」比較的 if 語句:
|
請寫出 float x 與「零值」比較的 if 語句:
|
請寫出 char *p 與「零值」比較的 if 語句:
|
2、如下爲Windows NT下的32位C++程序,請計算sizeof的值(10分)
char str[] = 「Hello」 ; char *p = str ; int n = 10; 請計算 sizeof (str ) =
sizeof ( p ) =
sizeof ( n ) = |
void Func ( char str[100]) { 請計算 sizeof( str ) = }
|
void *p = malloc( 100 ); 請計算 sizeof ( p ) =
|
3、簡答題(25分)
1、頭文件中的 ifndef/define/endif 幹什麼用?
2、#include <filename.h> 和 #include 「filename.h」 有什麼區別?
3、const 有什麼用途?(請至少說明兩種)
4、在C++ 程序中調用被 C編譯器編譯後的函數,爲何要加 extern 「C」聲明?
5、請簡述如下兩個for循環的優缺點
// 第一個 for (i=0; i<N; i++) { if (condition) DoSomething(); else DoOtherthing(); } |
// 第二個 if (condition) { for (i=0; i<N; i++) DoSomething(); } else { for (i=0; i<N; i++) DoOtherthing(); } |
優勢:
缺點:
|
優勢:
缺點:
|
4、有關內存的思考題(20分)
void GetMemory(char *p) { p = (char *)malloc(100); } void Test(void) { char *str = NULL; GetMemory(str); strcpy(str, "hello world"); printf(str); }
請問運行Test函數會有什麼樣的結果? 答:
|
char *GetMemory(void) { char p[] = "hello world"; return p; } void Test(void) { char *str = NULL; str = GetMemory(); printf(str); }
請問運行Test函數會有什麼樣的結果? 答: |
Void GetMemory2(char **p, int num) { *p = (char *)malloc(num); } void Test(void) { char *str = NULL; GetMemory(&str, 100); strcpy(str, "hello"); printf(str); } 請問運行Test函數會有什麼樣的結果? 答:
|
void Test(void) { char *str = (char *) malloc(100); strcpy(str, 「hello」); free(str); if(str != NULL) { strcpy(str, 「world」); printf(str); } } 請問運行Test函數會有什麼樣的結果? 答:
|
5、編寫strcpy函數(10分)
已知strcpy函數的原型是
char *strcpy(char *strDest, const char *strSrc);
其中strDest是目的字符串,strSrc是源字符串。
(1)不調用C++/C的字符串庫函數,請編寫函數 strcpy
(2)strcpy能把strSrc的內容複製到strDest,爲何還要char * 類型的返回值?
6、編寫類String的構造函數、析構函數和賦值函數(25分)
已知類String的原型爲:
class String
{
public:
String(const char *str = NULL); // 普通構造函數
String(const String &other); // 拷貝構造函數
~ String(void); // 析構函數
String & operate =(const String &other); // 賦值函數
private:
char *m_data; // 用於保存字符串
};
請編寫String的上述4個函數。
1、請填寫BOOL , float, 指針變量 與「零值」比較的 if 語句。(10分)
請寫出 BOOL flag 與「零值」比較的 if 語句。(3分) |
|
標準答案: if ( flag ) if ( !flag ) |
以下寫法均屬不良風格,不得分。 if (flag == TRUE) if (flag == 1 ) if (flag == FALSE) if (flag == 0) |
請寫出 float x 與「零值」比較的 if 語句。(4分) |
|
標準答案示例: const float EPSINON = 0.00001; if ((x >= - EPSINON) && (x <= EPSINON) 不可將浮點變量用「==」或「!=」與數字比較,應該設法轉化成「>=」或「<=」此類形式。
|
以下是錯誤的寫法,不得分。 if (x == 0.0) if (x != 0.0)
|
請寫出 char *p 與「零值」比較的 if 語句。(3分) |
|
標準答案: if (p == NULL) if (p != NULL) |
以下寫法均屬不良風格,不得分。 if (p == 0) if (p != 0) if (p) if (!) |
2、如下爲Windows NT下的32位C++程序,請計算sizeof的值(10分)
char str[] = 「Hello」 ; char *p = str ; int n = 10; 請計算 sizeof (str ) = 6 (2分)
sizeof ( p ) = 4 (2分)
sizeof ( n ) = 4 (2分) |
void Func ( char str[100]) { 請計算 sizeof( str ) = 4 (2分) }
|
void *p = malloc( 100 ); 請計算 sizeof ( p ) = 4 (2分)
|
3、簡答題(25分)
1、頭文件中的 ifndef/define/endif 幹什麼用?(5分)
答:防止該頭文件被重複引用。
2、#include <filename.h> 和 #include 「filename.h」 有什麼區別?(5分)
答:對於#include <filename.h> ,編譯器從標準庫路徑開始搜索 filename.h
對於#include 「filename.h」 ,編譯器從用戶的工做路徑開始搜索 filename.h
3、const 有什麼用途?(請至少說明兩種)(5分)
答:(1)能夠定義 const 常量
(2)const能夠修飾函數的參數、返回值,甚至函數的定義體。被const修飾的東西都受到強制保護,能夠預防意外的變更,能提升程序的健壯性。
4、在C++ 程序中調用被 C編譯器編譯後的函數,爲何要加 extern 「C」? (5分)
答:C++語言支持函數重載,C語言不支持函數重載。函數被C++編譯後在庫中的名字與C語言的不一樣。假設某個函數的原型爲: void foo(int x, int y);
該函數被C編譯器編譯後在庫中的名字爲_foo,而C++編譯器則會產生像_foo_int_int之類的名字。
C++提供了C鏈接交換指定符號extern「C」來解決名字匹配問題。
5、請簡述如下兩個for循環的優缺點(5分)
for (i=0; i<N; i++) { if (condition) DoSomething(); else DoOtherthing(); } |
if (condition) { for (i=0; i<N; i++) DoSomething(); } else { for (i=0; i<N; i++) DoOtherthing(); } |
優勢:程序簡潔
缺點:多執行了N-1次邏輯判斷,而且打斷了循環「流水線」做業,使得編譯器不能對循環進行優化處理,下降了效率。 |
優勢:循環的效率高
缺點:程序不簡潔
|
4、有關內存的思考題(每小題5分,共20分)
void GetMemory(char *p) { p = (char *)malloc(100); } void Test(void) { char *str = NULL; GetMemory(str); strcpy(str, "hello world"); printf(str); }
請問運行Test函數會有什麼樣的結果? 答:程序崩潰。 由於GetMemory並不能傳遞動態內存, Test函數中的 str一直都是 NULL。 strcpy(str, "hello world");將使程序崩潰。
|
char *GetMemory(void) { char p[] = "hello world"; return p; } void Test(void) { char *str = NULL; str = GetMemory(); printf(str); }
請問運行Test函數會有什麼樣的結果? 答:多是亂碼。 由於GetMemory返回的是指向「棧內存」的指針,該指針的地址不是 NULL,但其原現的內容已經被清除,新內容不可知。 |
void GetMemory2(char **p, int num) { *p = (char *)malloc(num); } void Test(void) { char *str = NULL; GetMemory(&str, 100); strcpy(str, "hello"); printf(str); } 請問運行Test函數會有什麼樣的結果? 答: (1)可以輸出hello (2)內存泄漏
|
void Test(void) { char *str = (char *) malloc(100); strcpy(str, 「hello」); free(str); if(str != NULL) { strcpy(str, 「world」); printf(str); } } 請問運行Test函數會有什麼樣的結果? 答:篡改動態內存區的內容,後果難以預料,很是危險。 由於free(str);以後,str成爲野指針, if(str != NULL)語句不起做用。
|
5、編寫strcpy函數(10分)
已知strcpy函數的原型是
char *strcpy(char *strDest, const char *strSrc);
其中strDest是目的字符串,strSrc是源字符串。
(1)不調用C++/C的字符串庫函數,請編寫函數 strcpy
char *strcpy(char *strDest, const char *strSrc);
{
assert((strDest!=NULL) && (strSrc !=NULL)); // 2分
char *address = strDest; // 2分
while( (*strDest++ = * strSrc++) != ‘\0’ ) // 2分
NULL ;
return address ; // 2分
}
(2)strcpy能把strSrc的內容複製到strDest,爲何還要char * 類型的返回值?
答:爲了實現鏈式表達式。 // 2分
例如 int length = strlen( strcpy( strDest, 「hello world」) );
6、編寫類String的構造函數、析構函數和賦值函數(25分)
已知類String的原型爲:
class String
{
public:
String(const char *str = NULL); // 普通構造函數
String(const String &other); // 拷貝構造函數
~ String(void); // 析構函數
String & operate =(const String &other); // 賦值函數
private:
char *m_data; // 用於保存字符串
};
請編寫String的上述4個函數。
標準答案:
// String的析構函數
String::~String(void) // 3分
{
delete [] m_data;
// 因爲m_data是內部數據類型,也能夠寫成 delete m_data;
}
// String的普通構造函數
String::String(const char *str) // 6分
{
if(str==NULL)
{
m_data = new char[1]; // 若能加 NULL 判斷則更好
*m_data = ‘\0’;
}
else
{
int length = strlen(str);
m_data = new char[length+1]; // 若能加 NULL 判斷則更好
strcpy(m_data, str);
}
}
// 拷貝構造函數
String::String(const String &other) // 3分
{
int length = strlen(other.m_data);
m_data = new char[length+1]; // 若能加 NULL 判斷則更好
strcpy(m_data, other.m_data);
}
// 賦值函數
String & String::operate =(const String &other) // 13分
{
// (1) 檢查自賦值 // 4分
if(this == &other)
return *this;
// (2) 釋放原有的內存資源 // 3分
delete [] m_data;
// (3)分配新的內存資源,並複製內容 // 3分
int length = strlen(other.m_data);
m_data = new char[length+1]; // 若能加 NULL 判斷則更好
strcpy(m_data, other.m_data);
// (4)返回本對象的引用 // 3分
return *this;
}
版本/狀態 |
做者 |
參與者 |
起止日期 |
備註 |
V 0.9 草稿文件 |
林銳
|
|
2001-7-1至 2001-7-18 |
林銳起草 |
V 1.0 正式文件 |
林銳
|
|
2001-7-18至 2001-7-24 |
朱洪海審查V 0.9, 林銳修正草稿中的錯誤 |
|
|
|
|
|
|
|
|
|
|
目 錄
7.8 有了malloc/free爲何還要new/delete ?... 52
9.4 示例:類String的構造函數與析構函數... 72
9.6 示例:類String的拷貝構造函數與賦值函數... 73
軟件質量是被大多數程序員掛在嘴上而不是放在心上的東西!
除了徹底外行和真正的編程高手外,初讀本書,你最早的感覺將是驚慌:「哇!我之前捏造的C++/C程序怎麼會有那麼多的毛病?」
別難過,做者只不過比你早幾年、多幾回驚慌而已。
請花一兩個小時認真閱讀這本百頁經書,你將會獲益匪淺,這是前面N-1個讀者的建議。
1、編程老手與高手的誤區
自從計算機問世以來,程序設計就成了使人羨慕的職業,程序員在受人寵愛以後容易發展成爲毛病特多卻常能自我臭美的羣體。
現在在Internet上流傳的「真正」的程序員聽說是這樣的:
(1) 真正的程序員沒有進度表,只有討好領導的馬屁精纔有進度表,真正的程序員會讓領導提心吊膽。
(2) 真正的程序員不寫使用說明書,用戶應當本身去猜測程序的功能。
(3) 真正的程序員幾乎不寫代碼的註釋,若是註釋很難寫,它理所固然也很難讀。
(4) 真正的程序員不畫流程圖,原始人和文盲纔會幹這事。
(5) 真正的程序員不看參考手冊,新手和膽小鬼才會看。
(6) 真正的程序員不寫文檔也不須要文檔,只有看不懂程序的笨蛋才用文檔。
(7) 真正的程序員認爲本身比用戶更明白用戶須要什麼。
(8) 真正的程序員不接受團隊開發的理念,除非他本身是頭頭。
(9) 真正的程序員的程序不會在第一次就正確運行,可是他們願意守着機器進行若干個30小時的調試改錯。
(10)真正的程序員不會在上午