高質量C++/C程式設計指南(林銳)
版本/狀態 | 作者 | 參與者 | 起止日期 | 備註 |
V 0.9 草稿檔案 | 林銳
|
| 2001-7-1至 2001-7-18 | 林銳起草 |
V 1.0 正式檔案 | 林銳
|
| 2001-7-18至 2001-7-24 | 朱洪海審查V 0.9, 林銳修正草稿中的錯誤 |
|
|
|
|
|
|
|
|
|
|
目 錄
前 言... 6
第1章 檔案結構... 11
1.1 版權和版本的宣告... 11
1.2 標頭檔案的結構... 12
1.3 定義檔案的結構... 13
1.4 標頭檔案的作用... 13
1.5 目錄結構... 14
第2章 程式的版式... 15
2.1 空行... 15
2.2 程式碼行... 16
2.3 程式碼行內的空格... 17
2.4 對齊... 18
2.5 長行拆分... 19
2.6 修飾符的位置... 19
2.7 註釋... 20
2.8 類的版式... 21
第3章 命名規則... 22
3.1 共性規則... 22
3.2 簡單的Windows應用程式命名規則... 23
3.3 簡單的Unix應用程式命名規則... 25
第4章 表示式和基本語句... 26
4.1 運算子的優先順序... 26
4.2 複合表示式... 27
4.3 if 語句... 27
4.4 迴圈語句的效率... 29
4.5 for 語句的迴圈控制變數... 30
4.6 switch語句... 30
4.7 goto語句... 31
第5章 常量... 33
5.1 為什麼需要常量... 33
5.2 const 與 #define的比較... 33
5.3 常量定義規則... 33
5.4 類中的常量... 34
第6章 函式設計... 36
6.1 引數的規則... 36
6.2 返回值的規則... 37
6.3 函式內部實現的規則... 39
6.4 其它建議... 40
6.5 使用斷言... 41
6.6 引用與指標的比較... 42
第7章 記憶體管理... 44
7.1記憶體分配方式... 44
7.2常見的記憶體錯誤及其對策... 44
7.3指標與陣列的對比... 45
7.4指標引數是如何傳遞記憶體的?... 47
7.5 free和delete把指標怎麼啦?... 50
7.6 動態記憶體會被自動釋放嗎?... 50
7.7 杜絕“野指標”... 51
7.8 有了malloc/free為什麼還要new/delete ?... 52
7.9 記憶體耗盡怎麼辦?... 53
7.10 malloc/free 的使用要點... 54
7.11 new/delete 的使用要點... 55
7.12 一些心得體會... 56
第8章 C++函式的高階特性... 57
8.1 函式過載的概念... 57
8.2 成員函式的過載、覆蓋與隱藏... 60
8.3 引數的預設值... 63
8.4 運算子過載... 64
8.5 函式內聯... 65
8.6 一些心得體會... 68
第9章 類的建構函式、解構函式與賦值函式... 69
9.1 建構函式與解構函式的起源... 69
9.2 建構函式的初始化表... 70
9.3 構造和析構的次序... 72
9.4 示例:類String的建構函式與解構函式... 72
9.5 不要輕視拷貝建構函式與賦值函式... 73
9.6 示例:類String的拷貝建構函式與賦值函式... 73
9.7 偷懶的辦法處理拷貝建構函式與賦值函式... 75
9.8 如何在派生類中實現類的基本函式... 75
9.9 一些心得體會... 77
第10章 類的繼承與組合... 78
10.1 繼承... 78
10.2 組合... 80
第11章 其它程式設計經驗... 82
11.1 使用const提高函式的健壯性... 82
11.2 提高程式的效率... 84
11.3 一些有益的建議... 85
參考文獻... 87
附錄A :C++/C程式碼審查表... 88
附錄B :C++/C試題... 93
附錄C :C++/C試題的答案與評分標準... 97
前 言
軟體質量是被大多數程式設計師掛在嘴上而不是放在心上的東西!
除了完全外行和真正的程式設計高手外,初讀本書,你最先的感受將是驚慌:“哇!我以前捏造的C++/C程式怎麼會有那麼多的毛病?”
別難過,作者只不過比你早幾年、多幾次驚慌而已。
請花一兩個小時認真閱讀這本百頁經書,你將會獲益匪淺,這是前面N-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)堅持學習,天天向上。
二、本書導讀
首先請做附錄B的C++/C試題(不要看答案),考查自己的程式設計質量究竟如何。然後參照答案嚴格打分。
(1)如果你只得了幾十分,請不要聲張,也不要太難過。程式設計質量差往往是由於不良習慣造成的,與人的智力、能力沒有多大關係,還是有藥可救的。成績越差,可以進步的空間就越大,中國不就是在落後中趕超發達資本主義國家嗎?只要你能下決心改掉不良的程式設計習慣,第二次考試就能及格了。
(2)如果你考及格了,表明你的技術基礎不錯,希望你能虛心學習、不斷進步。如果你還沒有找到合適的工作單位,不妨到上海貝爾試一試。
(3)如果你考出85分以上的好成績,你有義務和資格為你所在的團隊作“C++/C程式設計”培訓。希望你能和我們多多交流、相互促進。半年前我曾經發現一顆好苗子,就把他挖到我們小組來。
(4)如果你在沒有任何提示的情況下考了滿分,希望你能收我做你的徒弟。
程式設計考試結束後,請閱讀本書的正文。
本書第一章至第六章主要論述C++/C程式設計風格。難度不高,但是細節比較多。別小看了,提高質量就是要從這些點點滴滴做起。世上不存在最好的程式設計風格,一切因需求而定。團隊開發講究風格一致,如果制定了大家認可的程式設計風格,那麼所有組員都要遵守。如果讀者覺得本書的程式設計風格比較合你的工作,那麼就採用它,不要只看不做。人在小時候說話發音不準,寫字潦草,如果不改正,總有後悔的時候。程式設計也是同樣道理。
第七章至第十一章是專題論述,技術難度比較高,看書時要積極思考。特別是第七章“記憶體管理”,讀了並不表示懂了,懂了並不表示就能正確使用。有一位同事看了第七章後覺得“野指標”寫得不錯,與我切磋了一把。可是過了兩週,他告訴我,他忙了兩天追查出一個Bug,想不到又是“野指標”出問題,只好重讀第七章。
光看本書對提高程式設計質量是有限的,建議大家閱讀本書的參考文獻,那些都是經典名著。
如果你的程式設計質量已經過關了,不要就此滿足。如果你想成為優秀的軟體開發人員,建議你閱讀並按照CMMI規範做事,讓自己的綜合水平上升一個臺階。上海貝爾的員工可以向網路應用事業部軟體工程研究小組索取CMMI有關資料,最好能參加培訓。
三、版權宣告
本書的大部分內容取材於作者一年前的書籍手稿(尚未出版),現整理彙編成為上海貝爾網路應用事業部的一個規範化檔案,同時作為培訓教材。
由於C++/C程式設計是眾所周知的技術,沒有祕密可言。程式設計的好經驗應該大家共享,我們自己也是這麼學來的。作者願意公開本書的電子文件。
版權宣告如下:
(1)讀者可以任意拷貝、修改本書的內容,但不可以篡改作者及所屬單位。
(2)未經作者許可,不得出版或大量印發本書。
(3)如果競爭對手公司的員工得到本書,請勿公開使用,以免發生糾紛。
預計到2002年7月,我們將建立切合中國國情的CMMI 3級解決方案。屆時,包括本書在內的約1000頁規範將嚴格受控。
歡迎讀者對本書提出批評建議。
林銳,2001年7月
第1章 檔案結構
每個C++/C程式通常分為兩個檔案。一個檔案用於儲存程式的宣告(declaration),稱為標頭檔案。另一個檔案用於儲存程式的實現(implementation),稱為定義(definition)檔案。
C++/C程式的標頭檔案以“.h”為字尾,C程式的定義檔案以“.c”為字尾,C++程式的定義檔案通常以“.cpp”為字尾(也有一些系統以“.cc”或“.cxx”為字尾)。
1.1 版權和版本的宣告
版權和版本的宣告位於標頭檔案和定義檔案的開頭(參見示例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.2 標頭檔案的結構
標頭檔案由三部分內容組成:
(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.3 定義檔案的結構
定義檔案有三部分內容:
(1) 定義檔案開頭處的版權和版本宣告(參見示例1-1)。
(2) 對一些標頭檔案的引用。
(3) 程式的實現體(包括資料和程式碼)。
假設定義檔案的名稱為 graphics.cpp,定義檔案的結構參見示例1-3。
// 版權和版本宣告見示例1-1,此處省略。
#include “graphics.h” // 引用標頭檔案 …
// 全域性函式的實現體 void Function1(…) { … }
// 類成員函式的實現體 void Box::Draw(…) { … } |
示例1-3 C++/C定義檔案的結構
1.4 標頭檔案的作用
早期的程式語言如Basic、Fortran沒有標頭檔案的概念,C++/C語言的初學者雖然會用使用標頭檔案,但常常不明其理。這裡對標頭檔案的作用略作解釋:
(1)通過標頭檔案來呼叫庫功能。在很多場合,原始碼不便(或不準)向使用者公佈,只要向使用者提供標頭檔案和二進位制的庫即可。使用者只需要按照標頭檔案中的介面宣告來呼叫庫功能,而不必關心介面怎麼實現的。編譯器會從庫中提取相應的程式碼。
(2)標頭檔案能加強型別安全檢查。如果某個介面被實現或被使用時,其方式與標頭檔案中的宣告不一致,編譯器就會指出錯誤,這一簡單的規則能大大減輕程式設計師除錯、改錯的負擔。
1.5 目錄結構
如果一個軟體的標頭檔案數目比較多(如超過十個),通常應將標頭檔案和定義檔案分別儲存於不同的目錄,以便於維護。
例如可將標頭檔案儲存於include目錄,將定義檔案儲存於source目錄(可以是多級目錄)。
如果某些標頭檔案是私有的,它不會被使用者的程式直接引用,則沒有必要公開其“宣告”。為了加強資訊隱藏,這些私有的標頭檔案可以和定義檔案存放於同一個目錄。
第2章 程式的版式
版式雖然不會影響程式的功能,但會影響可讀性。程式的版式追求清晰、美觀,是程式風格的重要構成因素。
可以把程式的版式比喻為“書法”。好的“書法”可讓人對程式一目瞭然,看得興致勃勃。差的程式“書法”如螃蟹爬行,讓人看得索然無味,更令維護者煩惱有加。請程式設計師們學習程式的“書法”,彌補大學計算機教育的漏洞,實在很有必要。
2.1 空行
空行起著分隔程式段落的作用。空行得體(不過多也不過少)將使程式的佈局更加清晰。空行不會浪費記憶體,雖然列印含有空行的程式是會多消耗一些紙張,但是值得。所以不要捨不得用空行。
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) 函式內部的空行
2.2 程式碼行
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
2.3 程式碼行內的空格
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 程式碼行內的空格
2.4 對齊
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) 風格不良的對齊
2.5 長行拆分
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 長行的拆分
2.6 修飾符的位置
修飾符 * 和 & 應該靠近資料型別還是該靠近變數名,是個有爭議的活題。
若將修飾符 * 靠近資料型別,例如:int* x; 從語義上講此寫法比較直觀,即x是int 型別的指標。
上述寫法的弊端是容易引起誤解,例如:int* x, y; 此處y容易被誤解為指標變數。雖然將x和y分行定義可以避免誤解,但並不是人人都願意這樣做。
l 【規則2-6-1】應當將修飾符 * 和 & 緊靠變數名
例如:
char *name;
int *x, y; // 此處y不會被誤解為指標
2.7 註釋
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 程式的註釋
2.8 類的版式
類可以將資料和函式封裝在一起,其中函式表示了類的行為(或稱服務)。類提供關鍵字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) 以行為為中心的版式
第3章 命名規則
比較著名的命名規則當推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.1 共性規則
本節論述的共性規則是被大多數程式設計師採納的,我們應當在遵循這些共性規則的前提下,再擴充特定的規則,如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等,除非邏輯上的確需要編號。這是為了防止程式設計師偷懶,不肯為命名動腦筋而導致產生無意義的名字(因為用數字編號最省事)。
3.2 簡單的Windows應用程式命名規則
作者對“匈牙利”命名規則做了合理的簡化,下述的命名規則簡單易用,比較適合於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開頭。
3.3 簡單的Unix應用程式命名規則
第4章 表示式和基本語句
讀者可能懷疑:連if、for、while、goto、switch這樣簡單的東西也要探討程式設計風格,是不是小題大做?
我真的發覺很多程式設計師用隱含錯誤的方式寫表示式和基本語句,我自己也犯過類似的錯誤。
表示式和語句都屬於C++/C的短語結構語法。它們看似簡單,但使用時隱患比較多。本章歸納了正確使用表示式和語句的一些規則與建議。
4.1 運算子的優先順序
C++/C語言的運算子有數十個,運算子的優先順序與結合律如表4-1所示。注意一元運算子 + - * 的優先順序高於對應的二元運算子。
優先順序 | 運算子 | 結合律 |
從
高
到
低
排
列 | ( ) [ ] -> . | 從左至右 |
! ~ ++ -- (型別) sizeof + - * & | 從右至左
| |
* / % | 從左至右 | |
+ - | 從左至右 | |
<< >> | 從左至右 | |
< <= > >= | 從左至右 | |
== != | 從左至右 | |
& | 從左至右 | |
^ | 從左至右 | |
| | 從左至右 | |
&& | 從左至右 | |
|| | 從右至左 | |
?: | 從右至左 | |
= += -= *= /= %= &= ^= |= <<= >>= | 從左至右 |
表4-1 運算子的優先順序與結合律
l 【規則4-1-1】如果程式碼行中的運算子比較多,用括號確定表示式的操作順序,避免使用預設的優先順序。
由於將表4-1熟記是比較困難的,為了防止產生歧義並提高可讀性,應當用括號確定表示式的操作順序。例如:
word = (high << 8) | low
if ((a | b) && (a & c))
4.2 複合表示式
如 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 )
4.3 if 語句
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);
4.4 迴圈語句的效率
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) 效率高但程式不簡潔
4.5 for 語句的迴圈控制變數
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) 迴圈變數屬於閉區間
4.6 switch語句
有了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處理。
4.7 goto語句
自從提倡結構化設計以來,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語句,而不是禁用。
第5章 常量
常量是一種識別符號,它的值在執行期間恆定不變。C語言用 #define來定義常量(稱為巨集常量)。C++ 語言除了 #define外還可以用const來定義常量(稱為const常量)。
5.1 為什麼需要常量
如果不使用常量,直接在程式中填寫數字或字串,將會有什麼麻煩?
(1) 程式的可讀性(可理解性)變差。程式設計師自己會忘記那些數字或字串是什麼意思,使用者則更加不知它們從何處來、表示什麼。
(2) 在程式的很多地方輸入同樣的數字或字串,難保不發生書寫錯誤。
(3) 如果要修改數字或字串,則會在很多地方改動,既麻煩又容易出錯。
l 【規則5-1-1】 儘量使用含義直觀的常量來表示那些將在程式中多次出現的數字或字串。
例如:
#define MAX 100 /* C語言的巨集常量 */
const int MAX = 100; // C++ 語言的const常量
const float PI = 3.14159; // C++ 語言的const常量
5.2 const 與 #define的比較
C++ 語言可以用const來定義常量,也可以用 #define來定義常量。但是前者比後者有更多的優點:
(1) const常量有資料型別,而巨集常量沒有資料型別。編譯器可以對前者進行型別安全檢查。而對後者只進行字元替換,沒有型別安全檢查,並且在字元替換可能會產生意料不到的錯誤(邊際效應)。
(2) 有些整合化的除錯工具可以對const常量進行除錯,但是不能對巨集常量進行除錯。
l 【規則5-2-1】在C++ 程式中只使用const常量而不使用巨集常量,即const常量完全取代巨集常量。
5.3 常量定義規則
l 【規則5-3-1】需要對外公開的常量放在標頭檔案中,不需要對外公開的常量放在定義檔案的頭部。為便於管理,可以把不同模組的常量集中存放在一個公共的標頭檔案中。
l 【規則5-3-2】如果某一常量與其它常量密切相關,應在定義中包含這種關係,而不應給出一些孤立的值。
例如:
const float RADIUS = 100;
const float DIAMETER = RADIUS * 2;
5.4 類中的常量
有時我們希望某些常量只在類中有效。由於#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)。
第6章 函式設計
函式是C++/C程式的基本功能單元,其重要性不言而喻。函式設計的細微缺點很容易導致該函式被錯用,所以光使函式的功能正確是不夠的。本章重點論述函式的介面設計和內部實現的一些規則。
函式介面的兩個要素是引數和返回值。C語言中,函式的引數和返回值的傳遞方式有兩種:值傳遞(pass by value)和指標傳遞(pass by pointer)。C++ 語言中多了引用傳遞(pass by reference)。由於引用傳遞的性質象指標傳遞,而使用方式卻象值傳遞,初學者常常迷惑不解,容易引起混亂,請先閱讀6.6節“引用與指標的比較”。
6.1 引數的規則
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]…);
這種風格的函式在編譯時喪失了嚴格的型別安全檢查。
6.2 返回值的規則
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什麼也得不到,流下了隱患。
6.3 函式內部實現的規則
不同功能的函式其內部實現各不相同,看起來似乎無法就“內部實現”達成一致的觀點。但根據經驗,我們可以在函式體的“入口處”和“出口處”從嚴把關,從而提高函式的質量。
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 其它建議
² 【建議6-4-1】函式的功能要單一,不要設計多用途的函式。
² 【建議6-4-2】函式體的規模要小,儘量控制在50行程式碼之內。
² 【建議6-4-3】儘量避免函式帶有“記憶”功能。相同的輸入應當產生相同的輸出。
帶有“記憶”功能的函式,其行為可能是不可預測的,因為它的行為可能取決於某種“記憶狀態”。這樣的函式既不易理解又不利於測試和維護。在C/C++語言中,函式的static區域性變數是函式的“記憶”儲存器。建議儘量少用static區域性變數,除非必需。
² 【建議6-4-4】不僅要檢查輸入引數的有效性,還要檢查通過其它途徑進入函式體內的變數的有效性,例如全域性變數、檔案控制程式碼等。
² 【建議6-4-5】用於出錯處理的返回值一定要清楚,讓使用者不容易忽視或誤解錯誤情況。
6.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】一般教科書都鼓勵程式設計師們進行防錯設計,但要記住這種程式設計風格可能會隱瞞錯誤。當進行防錯設計時,如果“不可能發生”的事情的確發生了,則要使用斷言進行報警。
6.6 引用與指標的比較
引用是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
對比上述三個示例程式,會發現“引用傳遞”的性質象“指標傳遞”,而書寫方式象“值傳遞”。實際上“引用”可以做的任何事情“指標”也都能夠做,為什麼還要“引用”這東西?
答案是“用適當的工具做恰如其分的工作”。
指標能夠毫無約束地操作記憶體中的如何東西,儘管指標功能強大,但是非常危險。就象一把刀,它可以用來砍樹、裁紙、修指甲、理髮等等,誰敢這樣用?
如果的確只需要借用一下某個物件的“別名”,那麼就用“引用”,而不要用“指標”,以免發生意外。比如說,某人需要一份證明,本來在檔案上蓋上公章的印子就行了,如果把取公章的鑰匙交給他,那麼他就獲得了不該有的權利。
第7章 記憶體管理
歡迎進入記憶體這片雷區。偉大的Bill Gates 曾經失言:
640K ought to be enough for everybody
— Bill Gates 1981
程式設計師們經常編寫記憶體管理程式,往往提心吊膽。如果不想觸雷,唯一的解決辦法就是發現所有潛伏的地雷並且排除它們,躲是躲不了的。本章的內容比一般教科書的要深入得多,讀者需細心閱讀,做到真正地通曉記憶體管理。
7.1記憶體分配方式
記憶體分配方式有三種:
(1) 從靜態儲存區域分配。記憶體在程式編譯的時候就已經分配好,這塊記憶體在程式的整個執行期間都存在。例如全域性變數,static變數。
(2) 在棧上建立。在執行函式時,函式內區域性變數的儲存單元都可以在棧上建立,函式執行結束時這些儲存單元自動被釋放。棧記憶體分配運算內建於處理器的指令集中,效率很高,但是分配的記憶體容量有限。
(3) 從堆上分配,亦稱動態記憶體分配。程式在執行的時候用malloc或new申請任意多少的記憶體,程式設計師自己負責在何時用free或delete釋放記憶體。動態記憶體的生存期由我們決定,使用非常靈活,但問題也最多。
7.2常見的記憶體錯誤及其對策
發生記憶體錯誤是件非常麻煩的事情。編譯器不能自動發現這些錯誤,通常是在程式執行時才能捕捉到。而這些錯誤大多沒有明顯的症狀,時隱時現,增加了改錯的難度。有時使用者怒氣衝衝地把你找來,程式卻沒有發生任何問題,你一走,錯誤又發作了。
常見的記憶體錯誤及其對策如下:
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,防止產生“野指標”。
7.3指標與陣列的對比
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指標引數是如何傳遞記憶體的?
如果函式的引數是一個指標,不要指望用該指標去申請動態記憶體。示例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,它返回的始終是同一個“只讀”的記憶體塊。
7.5 free和delete把指標怎麼啦?
別看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 動態記憶體會被自動釋放嗎?
函式體內的區域性變數在函式結束時自動消亡。很多人誤以為示例7-6是正確的。理由是p是區域性的指標變數,它消亡的時候會讓它所指的動態記憶體一起完蛋。這是錯覺!
void Func(void) { char *p = (char *) malloc(100); // 動態記憶體會自動釋放嗎? } |
示例7-6 試圖讓動態記憶體自動釋放
我們發現指標有一些“似是而非”的特徵:
(1)指標消亡了,並不表示它所指的記憶體會被自動釋放。
(2)記憶體被釋放了,並不表示指標會消亡或者成了NULL指標。
這表明釋放記憶體並不是一件可以草率對待的事。也許有人不服氣,一定要找出可以草率行事的理由:
如果程式終止了執行,一切指標都會消亡,動態記憶體會被作業系統回收。既然如此,在程式臨終前,就可以不必釋放記憶體、不必將指標設定為NULL了。終於可以偷懶而不會發生錯誤了吧?
想得美。如果別人把那段程式取出來用到其它地方怎麼辦?
7.7 杜絕“野指標”
“野指標”不是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就成了“野指標”。但奇怪的是我執行這個程式時居然沒有出錯,這可能與編譯器有關。
7.8 有了malloc/free為什麼還要new/delete ?
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也一樣。
7.9 記憶體耗盡怎麼辦?
如果在申請動態記憶體時找不到足夠大的記憶體塊,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試圖耗盡作業系統的記憶體
7.10 malloc/free 的使用要點
函式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連續操作兩次就會導致程式執行錯誤。
7.11 new/delete 的使用要點
運算子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個物件。
7.12 一些心得體會
我認識不少技術不錯的C++/C程式設計師,很少有人能拍拍胸脯說通曉指標與記憶體管理(包括我自己)。我最初學習C語言時特別怕指標,導致我開發第一個應用軟體(約1萬行C程式碼)時沒有使用一個指標,全用陣列來頂替指標,實在蠢笨得過分。躲避指標不是辦法,後來我改寫了這個軟體,程式碼量縮小到原先的一半。
我的經驗教訓是:
(1)越是怕指標,就越要使用指標。不會正確使用指標,肯定算不上是合格的程式設計師。
(2)必須養成“使用偵錯程式逐步跟蹤程式”的習慣,只有這樣才能發現問題的本質。
第8章 C++函式的高階特性
對比於C語言的函式,C++增加了過載(overloaded)、內聯(inline)、const和virtual四種新機制。其中過載和內聯機制既可用於全域性函式也可用於類的成員函式,const與virtual機制僅用於類的成員函式。
過載和內聯肯定有其好處才會被C++語言採納,但是不可以當成免費的午餐而濫用。本章將探究過載和內聯的優點與侷限性,說明什麼情況下應該採用、不該採用以及要警惕錯用。
8.1 函式過載的概念
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 隱式型別轉換導致過載函式產生二義性
8.2 成員函式的過載、覆蓋與隱藏
成員函式的過載、覆蓋(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); }
};
8.3 引數的預設值
有一些引數的值在每次函式呼叫時都相同,書寫這樣的語句會使人厭煩。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 運算子過載
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 函式內聯
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不應該出現在函式的宣告中)。
8.6 一些心得體會
C++ 語言中的過載、內聯、預設引數、隱式轉換等機制展現了很多優點,但是這些優點的背後都隱藏著一些隱患。正如人們的飲食,少食和暴食都不可取,應當恰到好處。我們要辨證地看待C++的新機制,應該恰如其分地使用它們。雖然這會使我們程式設計時多費一些心思,少了一些痛快,但這才是程式設計的藝術。
第9章 類的建構函式、解構函式與賦值函式
建構函式、解構函式與賦值函式是每個類最基本的函式。它們太普通以致讓人容易麻痺大意,其實這些貌似簡單的函式就象沒有頂蓋的下水道那樣危險。
每個類只有一個解構函式和一個賦值函式,但可以有多個建構函式(包含一個拷貝建構函式,其它的稱為普通建構函式)。對於任意一個類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; // 用於儲存字串
};
9.1 建構函式與解構函式的起源
作為比C更先進的語言,C++提供了更好的機制來增強程式的安全性。C++編譯器具有嚴格的型別安全檢查功能,它幾乎能找出程式中所有的語法問題,這的確幫了程式設計師的大忙。但是程式通過了編譯檢查並不表示錯誤已經不存在了,在“錯誤”的大家庭裡,“語法錯誤”的地位只能算是小弟弟。級別高的錯誤通常隱藏得很深,就象狡猾的罪犯,想逮住他可不容易。
根據經驗,不少難以察覺的程式錯誤是由於變數沒有被正確初始化或清除造成的,而初始化和清除工作很容易被人遺忘。Stroustrup在設計C++語言時充分考慮了這個問題並很好地予以解決:把物件的初始化工作放在建構函式中,把清除工作放在解構函式中。當物件被建立時,建構函式被自動執行。當物件消亡時,解構函式被自動執行。這下就不用擔心忘了物件的初始化和清除工作。
建構函式與解構函式的名字不能隨便起,必須讓編譯器認得出才可以被自動執行。Stroustrup的命名方法既簡單又合理:讓建構函式、解構函式與類同名,由於解構函式的目的與建構函式的相反,就加字首‘~’以示區別。
除了名字外,建構函式與解構函式的另一個特別之處是沒有返回值型別,這與返回值型別為void的函式不同。建構函式與解構函式的使命非常明確,就象出生與死亡,光溜溜地來光溜溜地去。如果它們有返回值型別,那麼編譯器將不知所措。為了防止節外生枝,乾脆規定沒有返回值型別。(以上典故參考了文獻[Eekel, p55-p56])
9.2 建構函式的初始化表
建構函式有個特殊的初始化方式叫“初始化表示式表”(簡稱初始化表)。初始化表位於函式參數列之後,卻在函式體 {} 之前。這說明該表裡的初始化工作發生在函式體內的任何程式碼被執行之前。
建構函式初始化表的使用規則:
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) 資料成員在函式體內被初始化
9.3 構造和析構的次序
構造從類層次的最根處開始,在每一層中,首先呼叫基類的建構函式,然後呼叫成員物件的建構函式。析構則嚴格按照與構造相反的次序執行,該次序是唯一的,否則編譯器將無法自動執行析構過程。
一個有趣的現象是,成員物件初始化的次序完全不受它們在初始化表中次序的影響,只由成員物件在類中宣告的次序決定。這是因為類的宣告是唯一的,而類的建構函式可以有多個,因此會有多個不同次序的初始化表。如果成員物件按照初始化表的次序進行構造,這將導致解構函式無法得到唯一的逆序。[Eckel, p260-261]
9.4 示例:類String的建構函式與解構函式
// 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;
}
9.5 不要輕視拷貝建構函式與賦值函式
由於並非所有的物件都會使用拷貝建構函式和賦值函式,程式設計師可能對這兩個函式有些輕視。請先記住以下的警告,在閱讀正文時就會多心:
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) 以區別於第四個語句。
9.6 示例:類String的拷貝建構函式與賦值函式
// 拷貝建構函式
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返回的將是垃圾。
9.7 偷懶的辦法處理拷貝建構函式與賦值函式
如果我們實在不想編寫拷貝建構函式和賦值函式,又不允許別人使用編譯器生成的預設函式,怎麼辦?
偷懶的辦法是:只需將拷貝建構函式和賦值函式宣告為私有函式,不用編寫程式碼。
例如:
class A
{ …
private:
A(const A &a); // 私有的拷貝建構函式
A & operate =(const A &a); // 私有的賦值函式
};
如果有人試圖編寫如下程式:
A b(a); // 呼叫了私有的拷貝建構函式
b = a; // 呼叫了私有的賦值函式
編譯器將指出錯誤,因為外界不可以操作A的私有函式。
9.8 如何在派生類中實現類的基本函式
基類的建構函式、解構函式、賦值函式都不能被派生類繼承。如果類之間存在繼承關係,在編寫上述基本函式時應注意以下事項:
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;
}
9.9 一些心得體會
有些C++程式設計書籍稱建構函式、解構函式和賦值函式是類的“Big-Three”,它們的確是任何類最重要的函式,不容輕視。
也許你認為本章的內容已經夠多了,學會了就能平安無事,我不能作這個保證。如果你希望吃透“Big-Three”,請好好閱讀參考文獻[Cline] [Meyers] [Murry]。
第10章 類的繼承與組合
物件(Object)是類(Class)的一個例項(Instance)。如果將物件比作房子,那麼類就是房子的設計圖紙。所以物件導向設計的重點是類的設計,而不是物件的設計。
對於C++程式而言,設計孤立的類是比較容易的,難的是正確設計基類及其派生類。本章僅僅論述“繼承”(Inheritance)和“組合”(Composition)的概念。
注意,當前物件導向技術的應用熱點是COM和CORBA,這些內容超出了C++教材的範疇,請閱讀COM和CORBA相關論著。
10.1 繼承
如果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的功能和屬性。
10.2 組合
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派生而成
一隻公雞使勁地追打一隻剛下了蛋的母雞,你知道為什麼嗎?
因為母雞下了鴨蛋。
很多程式設計師經不起“繼承”的誘惑而犯下設計錯誤。“執行正確”的程式不見得是高質量的程式,此處就是一個例證。
第11章 其它程式設計經驗
11.1 使用const提高函式的健壯性
看到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關鍵字只能放在函式宣告的尾部,大概是因為其它地方都已經被佔用了。
11.2 提高程式的效率
程式的時間效率是指執行速度,空間效率是指程式佔用記憶體或者外存的狀況。
全域性效率是指站在整個系統的角度上考慮的效率,區域性效率是指站在模組或函式角度上考慮的效率。
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 一些有益的建議
² 【建議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
附錄A :C++/C程式碼審查表
檔案結構 | ||
重要性 | 審查項 | 結論 |
| 標頭檔案和定義檔案的名稱是否合理? |
|
| 標頭檔案和定義檔案的目錄結構是否合理? |
|
| 版權和版本宣告是否完整? |
|
重要 | 標頭檔案是否使用了 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)沒有正確地關閉檔案嗎? |
|
附錄B :C++/C試題
本試題僅用於考查C++/C程式設計師的基本程式設計技能。內容限於C++/C常用語法,不涉及資料結構、演算法以及深奧的語法。考試成績能反映出考生的程式設計質量以及對C++/C的理解程度,但不能反映考生的智力和軟體開發能力。
筆試時間90分鐘。請考生認真答題,切勿輕視。
一、請填寫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 語句:
|
二、以下為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 ) =
|
三、簡答題(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(); } |
優點:
缺點:
| 優點:
缺點:
|
四、有關記憶體的思考題(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函式會有什麼樣的結果? 答:
|
五、編寫strcpy函式(10分)
已知strcpy函式的原型是
char *strcpy(char *strDest, const char *strSrc);
其中strDest是目的字串,strSrc是源字串。
(1)不呼叫C++/C的字串庫函式,請編寫函式 strcpy
(2)strcpy能把strSrc的內容複製到strDest,為什麼還要char * 型別的返回值?
六、編寫類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個函式。
附錄C :C++/C試題的答案與評分標準
一、請填寫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 (!) |
二、以下為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分)
|
三、簡答題(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次邏輯判斷,並且打斷了迴圈“流水線”作業,使得編譯器不能對迴圈進行優化處理,降低了效率。 | 優點:迴圈的效率高
缺點:程式不簡潔
|
四、有關記憶體的思考題(每小題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)語句不起作用。
|
五、編寫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”) );
六、編寫類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;
}
相關文章
- 高質量C/C++程式設計指南總結(八)—— C++高階特性C++程式設計
- 高質量C/C++程式設計指南總結(二)—— 檔案版式C++程式設計
- 高質量C/C++程式設計指南總結(三)—— 命名規則C++程式設計
- C++高階程式設計pdfC++程式設計
- Google C++ 程式設計風格指南:命名約定GoC++程式設計
- C++核心程式設計C++程式設計
- C++ 程式設計入門指南:深入瞭解 C++ 語言及其應用領域C++程式設計
- C++提高程式設計C++程式設計
- C++ 提高程式設計C++程式設計
- C++程式設計實現C++程式設計
- C++高階應用_設計模式C++設計模式
- C++ 物件導向高階設計C++物件
- C++入門程式設計----C++運算子(8)C++程式設計
- Windows 程式設計簡介從C/C++到Windows程式設計Windows程式設計C++
- iOS 編寫高質量Objective-C程式碼iOSObjectC程式
- C++提高程式設計-模板C++程式設計
- C++提高程式設計-STLC++程式設計
- C++核心程式設計筆記C++程式設計筆記
- c++簡單程式設計-3C++程式設計
- QML之C++混合程式設計C++程式設計
- 物件導向程式設計C++物件程式設計C++
- C++ & Intel MKL 混合程式設計C++Intel程式設計
- 設計一個高質量的 API 介面API
- 大二上 C++高階程式設計筆記(1) 棧和c++對c的補充 20240908C++程式設計筆記
- 如何編寫高質量的C#程式碼(一)C#
- iOS編寫高質量Objective-C程式碼(六)iOSObjectC程式
- iOS 編寫高質量Objective-C程式碼(七)iOSObjectC程式
- iOS 編寫高質量Objective-C程式碼(八)iOSObjectC程式
- iOS 編寫高質量Objective-C程式碼(六)iOSObjectC程式
- iOS 編寫高質量Objective-C程式碼(五)iOSObjectC程式
- iOS 編寫高質量Objective-C程式碼(一)iOSObjectC程式
- iOS 編寫高質量Objective-C程式碼(二)iOSObjectC程式
- iOS 編寫高質量Objective-C程式碼(四)iOSObjectC程式
- iOS編寫高質量Objective-C程式碼(四)iOSObjectC程式
- iOS編寫高質量Objective-C程式碼(二)iOSObjectC程式
- iOS 編寫高質量Objective-C程式碼(三)iOSObjectC程式
- C++:與C混合程式設計 CMake undefined reference toC++程式設計Undefined
- 【混合程式設計】C/C++呼叫Fortran的DLL程式設計C++