高質量c c++編程

第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-2if、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
{
  public:
void  Look(void); 
};
class Nose
{
  public:
void  Smell(void);
};
class Mouth
{
  public:
void  Eat(void);    
};
class Ear
{
  public:
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等工具進行代碼審查。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章