高質量C++/C編程指南

1 文件結構

每個C++/C程序通常分為兩個文件。一個文件用于保存程序的聲明(declaration),稱為頭文件。另一個文件用于保存程序的實現(implementation),稱為定義(definition)文件。C++/C程序的頭文件以“.h”為后綴,C程序的定義文件以“.c”為后綴,C++程序的定義文件通常以“.cpp”為后綴。

頭文件的結構與作用

頭文件主要有兩個作用:

  • 通過頭文件來調用庫功能。在很多場合,源代碼不便(或不準)向用戶公布,只要向用戶提供頭文件和二進制的庫即可。用戶只需要按照頭文件中的接口聲明來調用庫功能,而不必關心接口怎么實現的。編譯器會從庫中提取相應的代碼。
  • 頭文件能加強類型安全檢查。如果某個接口被實現或被使用時,其方式與頭文件中的聲明不一致,編譯器就會指出錯誤,這一簡單的規則能大大減輕程序員調試、改錯的負擔。

頭文件由三部分組成:頭文件開頭處的版權和版本聲明,預處理塊,函數和類結構聲明等。

  • 為了防止頭文件被重復引用,應當用ifndef/define/endif結構產生預處理塊。
  • 用 #include <filename.h> 格式來引用標準庫的頭文件(編譯器將從標準庫目錄開始搜索)。
  • 用 #include “filename.h” 格式來引用非標準庫的頭文件(編譯器將從用戶的工作目錄開始搜索)。
  • 頭文件中只存放“聲明”而不存放“定義”。在C++ 語法中,類的成員函數可以在聲明的同時被定義,并且自動成為內聯函數。這雖然會帶來書寫上的方便,但卻造成了風格不一致,弊大于利。建議將成員函數的定義與聲明分開,不論該函數體有多么小。
  • 不提倡使用全局變量,盡量不要在頭文件中出現象extern int value 這類聲明。

假設頭文件名稱為 graphics.h,頭文件的結構參見示例1.1

/*
* Copyright (c) 2001,上海貝爾有限公司網絡應用事業部
* All rights reserved.
* 
* 文件名稱:filename.h
* 文件標識:見配置管理計劃書
* 摘    要:簡要描述本文件的內容
* 
* 當前版本:1.1
* 作    者:輸入作者(或修改者)名字
* 完成日期:2001年7月20日
*
* 取代版本:1.0 
* 原作者  :輸入原作者(或修改者)名字
* 完成日期:2001年5月10日
*/

#ifndef GRAPHICS_H  // 防止graphics.h被重復引用
#define GRAPHICS_H
 
#include <math.h>       // 引用標準庫的頭文件
…
#include “myheader.h”   // 引用非標準庫的頭文件
…
void Function1(…);  // 全局函數聲明
…
class Box               // 類結構聲明
{
…
};
#endif

定義文件的結構

定義文件有三部分內容:定義文件開頭處的版權和版本聲明,對一些頭文件的引用,程序的實現體(包括數據和代碼)。

假設定義文件的名稱為 graphics.cpp,定義文件的結構參見示例1.2

// 版權和版本聲明見示例1-1,此處省略。
 
#include “graphics.h”   // 引用頭文件
…
 
// 全局函數的實現體
void Function1(…)
{
…
}
 
// 類成員函數的實現體
void Box::Draw(…)
{
…
}

目錄結構

如果一個軟件的頭文件數目比較多(如超過十個),通常應將頭文件和定義文件分別保存于不同的目錄,以便于維護。例如可將頭文件保存于include目錄,將定義文件保存于source目錄(可以是多級目錄)。
如果某些頭文件是私有的,它不會被用戶的程序直接引用,則沒有必要公開其“聲明”。為了加強信息隱藏,這些私有的頭文件可以和定義文件存放于同一個目錄。


2 程序的版式

版式雖然不會影響程序的功能,但會影響可讀性。程序的版式追求清晰、美觀,是程序風格的重要構成因素。

  • 盡可能在定義變量的同時初始化該變量。如果變量的引用處和其定義處相隔比較遠,變量的初始化很容易被忘記。如果引用了未被初始化的變量,可能會導致程序錯誤。本建議可以減少隱患。
  • 代碼行內的空格。關鍵字之后要留空格,象const、virtual、inline、case 等關鍵字之后至少要留一個空格,以突出關鍵字;函數名之后不要留空格,緊跟左括號‘(’,以與關鍵字區別;賦值操作符、比較操作符、算術操作符、邏輯操作符、位域操作符,如“=”、“+=” “>=”、“<=”、“+”、“*”、“%”、“&&”、“||”、“<<”,“^”等二元操作符的前后應當加空格;一元操作符如“!”、“~”、“++”、“--”、“&”(地址運算符)等前后不加空格。
  • 類的版式。建議采用“以行為為中心”的書寫方式,即將public類型的函數寫在前面,而將private類型的數據寫在后面,重點關注的是類應該提供什么樣的接口(或服務)。

3 命名規則

  • 標識符應當直觀且可以拼讀,可望文知意。標識符最好采用英文單詞或其組合,便于記憶和閱讀。切忌使用漢語拼音來命名。程序中的英文單詞一般不會太復雜,用詞應當準確。例如不要把CurrentValue寫成NowValue。
  • 標識符的長度應當符合“min-length && max-information”原則。單字符的名字也是有用的,常見的如i,j,k,m,n,x,y,z等,它們通常可用作函數內的局部變量。
  • 命名規則盡量與所采用的操作系統或開發工具的風格保持一致。例如Windows應用程序的標識符通常采用“大小寫”混排的方式,如AddChild。而Unix應用程序的標識符通常采用“小寫加下劃線”的方式,如add_child。別把這兩類風格混在一起用。
  • 變量的名字應當使用“名詞”或者“形容詞+名詞”。float value;``float oldValue;
  • 全局函數的名字應當使用“動詞”或者“動詞+名詞”(動賓詞組)。類的成員函數應當只使用“動詞”,被省略掉的名詞就是對象本身。
DrawBox();      // 全局函數
box->Draw();        // 類的成員函數
  • 盡量避免名字中出現數字編號,如Value1,Value2等,除非邏輯上的確需要編號。

簡單的Windows應用程序命名規則

  • 類名和函數名用大寫字母開頭的單詞組合而成。
class Node;                 // 類名
class LeafNode;             // 類名
void  Draw(void);           // 函數名
void  SetValue(int value);  // 函數名
  • 變量和參數用小寫字母開頭的單詞組合而成。
  • 常量全用大寫的字母,用下劃線分割單詞。const int MAX_LENGTH = 100;
  • 靜態變量加前綴s_(表示static)。static int s_initValue;
  • 如果不得已需要全局變量,則使全局變量加前綴g_(表示global)。
  • 類的數據成員加前綴m_(表示member),這樣可以避免數據成員與成員函數的參數同名。
void Object::SetValue(int width, int height)
{
    m_width = width;
    m_height = height;
}
  • 為了防止某一軟件庫中的一些標識符和其它軟件庫中的沖突,可以為各種標識符加上能反映軟件性質的前綴。例如三維圖形標準OpenGL的所有庫函數均以gl開頭,所有常量(或宏定義)均以GL開頭。

4 表達式和基本語句

運算符的優先級

C++/C語言的運算符有數十個,運算符的優先級與結合律如圖所示。注意一元運算符 + - * 的優先級高于對應的二元運算符。如果代碼行中的運算符比較多,用括號確定表達式的操作順序,避免使用默認的優先級。

if語句

  • 布爾變量與零值比較:不可將布爾變量直接與TRUE、FALSE或者1、0進行比較。根據布爾類型的語義,零值為“假”(記為FALSE),任何非零值都是“真”(記為TRUE)。TRUE的值究竟是什么并沒有統一的標準。例如Visual C++ 將TRUE定義為1,而Visual Basic則將TRUE定義為-1。假設布爾變量名字為flag,它與零值比較的標準if語句如下:
if (flag)   // 表示flag為真
if (!flag)  // 表示flag為假
  • 整型變量與零值比較:應當將整型變量用“==”或“!=”直接與0比較。假設整型變量的名字為value,它與零值比較的標準if語句如下:
if (value == 0)  
if (value != 0)
  • 浮點變量與零值比較:不可將浮點變量用“==”或“!=”與任何數字比較。千萬要留意,無論是float還是double類型的變量,都有精度限制。所以一定要避免將浮點變量用“==”或“!=”與數字比較,應該設法轉化成“>=”或“<=”形式。
if ((x>=-EPSINON) && (x<=EPSINON))  //EPSINON是允許的誤差(即精度)。
  • 指針變量與零值比較:應當將指針變量用“==”或“!=”與NULL比較。指針變量的零值是“空”(記為NULL)。盡管NULL的值與0相同,但是兩者意義不同。假設指針變量的名字為p,它與零值比較的標準if語句如下:
if (p == NULL)  // p與NULL顯式比較,強調p是指針變量
if (p != NULL)  
  • 有時候我們可能會看到 if (NULL == p) 這樣古怪的格式。不是程序寫錯了,是程序員為了防止將 if (p == NULL) 誤寫成 if (p = NULL),而有意把p和NULL顛倒。編譯器認為 if (p = NULL) 是合法的,但是會指出 if (NULL = p)是錯誤的,因為NULL不能被賦值。

循環語句

C++/C循環語句中,for語句使用頻率最高,while語句其次,do語句很少用。本節重點論述循環體的效率。提高循環體效率的基本辦法是降低循環體的復雜性。

  • 在多重循環中,如果有可能,應當將最長的循環放在最內層,最短的循環放在最外層,以減少CPU跨切循環層的次數。
  • 不可在for 循環體內修改循環變量,防止for 循環失去控制。

switch語句

switch是多分支選擇語句,而if語句只有兩個分支可供選擇。雖然可以用嵌套的if語句來實現多分支選擇,但那樣的程序冗長難讀。這是switch語句存在的理由。switch語句的基本格式是:

switch (variable)
{
    case value1 :   … 
        break;
    case value2 :   … 
        break;
        …
    default :   … 
        break;
}
  • 每個case語句的結尾不要忘了加break,否則將導致多個分支重疊(除非有意使多個分支重疊)。
  • 不要忘記最后那個default分支。即使程序真的不需要default處理,也應該保留語句 default : break; 這樣做并非多此一舉,而是為了防止別人誤以為你忘了default處理。

5 常量

常量是一種標識符,它的值在運行期間恒定不變。C++ 語言可以用const來定義常量,也可以用 #define來定義常量。但是前者比后者有更多的優點,建議用const常量完全取代宏常量。

  • const常量有數據類型,而宏常量沒有數據類型。編譯器可以對前者進行類型安全檢查。而對后者只進行字符替換,沒有類型安全檢查,并且在字符替換可能會產生意料不到的錯誤(邊際效應)。
  • 有些集成化的調試工具可以對const常量進行調試,但是不能對宏常量進行調試。

常量定義規則:需要對外公開的常量放在頭文件中,不需要對外公開的常量放在定義文件的頭部。為便于管理,可以把不同模塊的常量集中存放在一個公共的頭文件中。如果某一常量與其它常量密切相關,應在定義中包含這種關系,而不應給出一些孤立的值。

類中的常量

有時我們希望某些常量只在類中有效。由于#define定義的宏常量是全局的,不能達到目的,于是想當然地覺得應該用const修飾數據成員來實現。const數據成員的確是存在的,但其含義卻不是我們所期望的。const數據成員只在某個對象生存期內是常量,而對于整個類而言卻是可變的,因為類可以創建多個對象,不同的對象其const數據成員的值可以不同。

const數據成員的初始化只能在類構造函數的初始化表中進行,不能在類聲明中初始化。(這里是否可以考慮將其定義為靜態類型?)

怎樣才能建立在整個類中都恒定的常量呢?別指望const數據成員了,應該用類中的枚舉常量來實現。枚舉常量不會占用對象的存儲空間,它們在編譯時被全部求值。枚舉常量的缺點是:它的隱含數據類型是整數,其最大值有限,且不能表示浮點數(如PI=3.14159)。

class A
{…
    enum { SIZE1 = 100, SIZE2 = 200}; // 枚舉常量
    int array1[SIZE1];  
    int array2[SIZE2];
};

6 函數設計

函數接口的兩個要素是參數和返回值。C語言中,函數的參數和返回值的傳遞方式有兩種:值傳遞(pass by value)和指針傳遞(pass by pointer)。C++ 語言中多了引用傳遞(pass by reference)。

  • 如果參數是指針,且僅作輸入用,則應在類型前加const,以防止該指針在函數體內被意外修改。
  • 如果輸入參數以值傳遞的方式傳遞對象,則宜改用“const &”方式來傳遞,這樣可以省去臨時對象的構造和析構過程,從而提高效率。
  • 避免函數有太多的參數,參數個數盡量控制在5個以內。如果參數太多,在使用時容易將參數類型或順序搞錯。
  • 不要將正常值和錯誤標志混在一起返回。正常值用輸出參數獲得,而錯誤標志用return語句返回。
  • 有時候函數原本不需要返回值,但為了增加靈活性如支持鏈式表達,可以附加返回值。例如字符串拷貝函數strcpy的原型:
char *strcpy(char *strDest,const char *strSrc);

strcpy函數將strSrc拷貝至輸出參數strDest中,同時函數的返回值又是strDest。這樣做并非多此一舉,可以獲得如下靈活性:

char str[20];
int  length = strlen( strcpy(str, “Hello World”) );
  • 如果函數的返回值是一個對象,有些場合用“引用傳遞”替換“值傳遞”可以提高效率。而有些場合只能用“值傳遞”而不能用“引用傳遞”,否則會出錯。

函數內部實現的規則

  • 在函數體的“入口處”,對參數的有效性進行檢查。很多程序錯誤是由非法參數引起的,我們應該充分理解并正確使用“斷言”(assert)來防止此類錯誤。
  • 在函數體的“出口處”,對return語句的正確性和效率進行檢查。如果函數有返回值,那么函數的“出口處”是return語句。我們不要輕視return語句。如果return語句寫得不好,函數要么出錯,要么效率低下。
  • return語句不可返回指向“棧內存”的“指針”或者“引用”,因為該內存在函數體結束時被自動銷毀。
  • 要搞清楚返回的究竟是“值”、“指針”還是“引用”。

使用斷言

程序一般分為Debug版本和Release版本,Debug版本用于內部調試,Release版本發行給用戶使用。斷言assert是僅在Debug版本起作用的宏,它用于檢查“不應該”發生的情況。在運行過程中,如果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;
}

assert不是一個倉促拼湊起來的宏。為了不在程序的Debug版本和Release版本引起差別,assert不應該產生任何副作用。所以assert不是函數,而是宏。程序員可以把assert看成一個在任何系統狀態下都可以安全使用的無害測試手段。如果程序在assert處終止了,并不是說含有該assert的函數有錯誤,而是調用者出了差錯,assert可以幫助我們找到發生錯誤的原因。

  • 使用斷言捕捉不應該發生的非法情況。不要混淆非法情況與錯誤情況之間的區別,后者是必然存在的并且是一定要作出處理的。
  • 在函數的入口處,使用斷言檢查參數的有效性(合法性)。
  • 在編寫函數時,要進行反復的考查,并且自問:“我打算做哪些假定?”一旦確定了的假定,就要使用斷言對假定進行檢查。

引用與指針的比較

n相當于m的別名,對n的任何操作就是對m的操作。引用被創建的同時必須被初始化(指針則可以在任何時候被初始化)。不能有NULL引用,引用必須與合法的存儲單元關聯(指針則可以是NULL)。一旦引用被初始化,就不能改變引用的關系(指針則可以隨時改變所指的對象)。

引用的主要功能是傳遞函數的參數和返回值。C++語言中,函數的參數和返回值的傳遞方式有三種:值傳遞、指針傳遞和引用傳遞。指針能夠毫無約束地操作內存中的如何東西,盡管指針功能強大,但是非常危險。


7 內存管理

內存分配方式

內存分配方式有三種:

  • 從靜態存儲區域分配。內存在程序編譯的時候就已經分配好,這塊內存在程序的整個運行期間都存在。例如全局變量,static變量。
  • 從棧上創建。在執行函數時,函數內局部變量的存儲單元都可以在棧上創建,函數執行結束時這些存儲單元自動被釋放。棧內存分配運算內置于處理器的指令集中,效率很高,但是分配的內存容量有限。
  • 從堆上創建。亦稱動態內存分配。程序在運行的時候用malloc或new申請任意多少的內存,程序員自己負責在何時用free或delete釋放內存。動態內存的生存期由我們決定,使用非常靈活,但問題也最多。

常見的內存錯誤及其策略

發生內存錯誤是件非常麻煩的事情。編譯器不能自動發現這些錯誤,通常是在程序運行時才能捕捉到。而這些錯誤大多沒有明顯的癥狀,時隱時現,增加了改錯的難度。常見的內存錯誤及其對策如下:

  • 內存分配未成功,卻使用了它。常用解決辦法是,在使用內存之前檢查指針是否為NULL。如果指針p是函數的參數,那么在函數的入口處用assert(p!=NULL)進行檢查。如果是用malloc或new來申請內存,應該用if(p==NULL) 或if(p!=NULL)進行防錯處理。
  • 內存分配雖然成功,但是尚未初始化就引用它。犯這種錯誤主要有兩個起因:一是沒有初始化的觀念;二是誤以為內存的缺省初值全為零,導致引用初值錯誤(例如數組)。內存的缺省初值究竟是什么并沒有統一的標準,盡管有些時候為零值,我們寧可信其無不可信其有。所以無論用何種方式創建數組,都別忘了賦初值,即便是賦零值也不可省略。
  • 內存分配成功并且已經初始化,但操作越過了內存的邊界。例如在使用數組時經常發生下標“多1”或者“少1”的操作。特別是在for循環語句中,循環次數很容易搞錯,導致數組操作越界。
  • 忘記了釋放內存,造成內存泄露。含有這種錯誤的函數每被調用一次就丟失一塊內存。剛開始時系統的內存充足,你看不到錯誤。終有一次程序突然死掉,系統出現提示:內存耗盡。動態內存的申請與釋放必須配對,程序中malloc與free的使用次數一定要相同,否則肯定有錯誤(new/delete同理)。
  • 釋放了內存卻繼續使用它。函數的return語句寫錯了,注意不要返回指向“棧內存”的“指針”或者“引用”,因為該內存在函數體結束時被自動銷毀;使用free或delete釋放了內存后,沒有將指針設置為NULL。導致產生“野指針”。

遵守以下規則:

  • 用malloc或new申請內存之后,應該立即檢查指針值是否為NULL。防止使用指針值為NULL的內存。
  • 不要忘記為數組和動態內存賦初值。防止將未被初始化的內存作為右值使用。
  • 避免數組或指針的下標越界,特別要當心發生“多1”或者“少1”操作。
  • 動態內存的申請與釋放必須配對,防止內存泄漏。
  • 用free或delete釋放了內存之后,立即將指針設置為NULL,防止產生“野指針”。

指針與數組的對比

C/C++程序中,指針和數組在不少地方可以相互替換著用,讓人產生一種錯覺,以為兩者是等價的。數組要么在靜態存儲區被創建(如全局數組),要么在棧上被創建。數組名對應著(而不是指向)一塊內存,其地址與容量在生命期內保持不變,只有數組的內容可以改變。指針可以隨時指向任意類型的內存塊,它的特征是“可變”,所以我們常用指針來操作動態內存。指針遠比數組靈活,但也更危險。

不能對數組名進行直接復制與比較。若想把數組a的內容復制給數組b,不能用語句 b = a ,否則將產生編譯錯誤。應該用標準庫函數strcpy進行復制。同理,比較b和a的內容是否相同,不能用if(b==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)
…

用運算符sizeof可以計算出數組的容量(字節數),sizeof(a)的值是12(注意別忘了’\0’)。指針p指向a,但是sizeof(p)的值卻是4。這是因為sizeof(p)得到的是一個指針變量的字節數,相當于sizeof(char*),而不是p所指的內存容量。C++/C語言沒有辦法知道指針所指的內存容量,除非在申請內存時記住它。

注意當數組作為函數的參數進行傳遞時,該數組自動退化為同類型的指針。

char a[] = "hello world";
char *p  = a;
cout<< sizeof(a) << endl;   // 12字節
cout<< sizeof(p) << endl;   // 4字節
void Func(char a[100])
{
    cout<< sizeof(a) << endl;   // 4字節而不是100字節
}

指針參數是如何傳遞內存的?

如果函數的參數是一個指針,不要指望用該指針去申請動態內存。下例中main函數中的語句GetMemory(str, 200)并沒有使str獲得期望的內存,str依舊是NULL。這個問題出在函數GetMemory中。編譯器總是要為函數的每個參數制作臨時副本,指針參數p的副本是 _p,編譯器使 _p = p。如果函數體內的程序修改了_p的內容,就導致參數p的內容作相應的修改。這就是指針可以用作輸出參數的原因。在本例中,_p申請了新的內存,只是把_p所指的內存地址改變了,但是p絲毫未變。所以函數GetMemory并不能輸出任何東西。事實上,每執行一次GetMemory就會泄露一塊內存,因為沒有用free釋放內存。

void GetMemory(char *p, int num)
{
    p = (char *)malloc(sizeof(char) * num);
}
int main(void)
{
    char *str = NULL;
    GetMemory(str, 100);    // str 仍然為 NULL 
    strcpy(str, "hello");   // 運行錯誤
}

如果非得要用指針參數去申請內存,那么應該改用“指向指針的指針”。

void GetMemory2(char **p, int num)
{
    *p = (char *)malloc(sizeof(char) * num);
}
int main(void)
{
    char *str = NULL;
    GetMemory2(&str, 100);  // 注意參數是 &str,而不是str
    strcpy(str, "hello");   
    cout<< str << endl;
    free(str);  
}

由于“指向指針的指針”這個概念不容易理解,我們可以用函數返回值來傳遞動態內存。這種方法更加簡單。

char *GetMemory3(int num)
{
    char *p = (char *)malloc(sizeof(char) * num);
    return p;
}
int main(void)
{
    char *str = NULL;
    str = GetMemory3(100);  
    strcpy(str, "hello");
    cout<< str << endl;
    free(str);  
}

用函數返回值來傳遞動態內存這種方法雖然好用,但是常常有人把return語句用錯了。這里強調不要用return語句返回指向“棧內存”的指針,因為該內存在函數結束時自動消亡。

杜絕“野指針”

“野指針”不是NULL指針,是指向“垃圾”內存的指針。人們一般不會錯用NULL指針,因為用if語句很容易判斷。但是“野指針”是很危險的,if語句對它不起作用。“野指針”的成因主要有兩種:

  • 指針變量沒有被初始化。任何指針變量剛被創建時不會自動成為NULL指針,它的缺省值是隨機的,它會亂指一氣。所以,指針變量在創建的同時應當被初始化,要么將指針設置為NULL,要么讓它指向合法的內存。例如
char *p = NULL;
char *str = (char *) malloc(100);
  • 指針p被free或者delete之后,沒有置為NULL,讓人誤以為p是個合法的指針。
  • 指針操作超越了變量的作用范圍。這種情況讓人防不勝防,示例程序如下:
class A 
{   
public:
    void Func(void){ cout << “Func of class A” << endl; }
};
    void Test(void)
{
    A  *p;
    {
        A  a;
        p = &a; // 注意 a 的生命期
    }
    p->Func();      // p是“野指針”
}

函數Test在執行語句p->Func()時,對象a已經消失,而p是指向a的,所以p就成了“野指針”。但奇怪的是我運行這個程序時居然沒有出錯,這可能與編譯器有關。

malloc/free和new/delete

malloc與free是C++/C語言的標準庫函數,new/delete是C++的運算符。它們都可用于申請動態內存和釋放內存。

對于非內部數據類型的對象而言,光用maloc/free無法滿足動態對象的要求。對象在創建的同時要自動執行構造函數,對象在消亡之前要自動執行析構函數。由于malloc/free是庫函數而不是運算符,不在編譯器控制權限之內,不能夠把執行構造函數和析構函數的任務強加于malloc/free。因此C++語言需要一個能完成動態內存分配和初始化工作的運算符new,以及一個能完成清理與釋放內存工作的運算符delete。

如果用free釋放“new創建的動態對象”,那么該對象因無法執行析構函數而可能導致程序出錯。如果用delete釋放“malloc申請的動態內存”,理論上講程序不會出錯,但是該程序的可讀性很差。所以new/delete必須配對使用,malloc/free也一樣。

函數malloc的原型為void * malloc(size_t size),用malloc申請一塊長度為length的整數類型的內存,程序如下:

int  *p = (int *) malloc(sizeof(int) * length);

malloc返回值的類型是void *,所以在調用malloc時要顯式地進行類型轉換,將void * 轉換成所需要的指針類型。malloc函數本身并不識別要申請的內存是什么類型,它只關心內存的總字節數,可以使用sizeof運算符求變量所占字節數。

函數free的原型為void free( void * memblock )。如果p是NULL指針,那么free對p無論操作多少次都不會出問題。如果p不是NULL指針,那么free對p連續操作兩次就會導致程序運行錯誤。

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個動態對象

在用delete釋放對象數組時,留意不要丟了符號‘[]’。

delete []objects;   // 正確的用法
delete objects; // 錯誤的用法

后者相當于delete objects[0],漏掉了另外99個對象。

8 C++函數的高級特性

對比于C語言的函數,C++增加了重載(overloaded)、內聯(inline)、const和virtual四種新機制。其中重載和內聯機制既可用于全局函數也可用于類的成員函數,const與virtual機制僅用于類的成員函數。

函數重載的概念

在C++程序中,可以將語義、功能相似的幾個函數用同一個名字表示,即函數重載。這樣便于記憶,提高了函數的易用性。

只能靠參數而不能靠返回值類型的不同來區分重載函數。編譯器根據參數為每個重載函數產生不同的內部標識符。如果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。

注意并不是兩個函數的名字相同就能構成重載。全局函數和類的成員函數同名不算重載,因為函數的作用域不同。


9 類的構造函數、析構函數與賦值函數

每個類只有一個析構函數和一個賦值函數,但可以有多個構造函數(包含一個拷貝構造函數,其它的稱為普通構造函數)。對于任意一個類,如果不想編寫上述函數,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;        // 用于保存字符串 
};

構造函數與析構函數

不少難以察覺的程序錯誤是由于變量沒有被正確初始化或清除造成的,而初始化和清除工作很容易被人遺忘。Stroustrup 在設計 C++語言時充分考慮了這個問題并很好地予以解決:把對象的初始化工作放在構造函數中,把清除工作放在析構函數中。當對象被創建時,構造函數被自動執行。當對象消亡時,析構函數被自動執行。這下就不用擔心忘了對象的初始化和清除工作。讓構造函數、析構函數與類同名,由于析構函數的目
的與構造函數的相反,就加前綴‘~’以示區別。

構造函數有個特殊的初始化方式叫“初始化表達式表”(簡稱初始化表)。初始化表位于函數參數表之后,卻在函數體 {} 之前。這說明該表里的初始化工作發生在函數體內的任何代碼被執行之前。構造函數初始化表的使用規則:

  • 如果類存在繼承關系,派生類必須在其初始化表里調用基類的構造函數。
  • 類的 const 常量只能在初始化表里被初始化,因為它不能在函數體內用賦值的方式來初始化。
  • 類的數據成員的初始化可以采用初始化表或函數體內賦值兩種方式,這兩種方式的效
    率不完全相同。函數體內賦值其實是先進行默認初始化再進行賦值。非內部數據類型的成員對象應當采用第一種方式初始化,以獲取更高的效率。
// 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) 
{
    // 由于 m_data 是內部數據類型,也可以寫成 delete m_data;
    delete [] m_data;
}

構造與析構的次序

構造從類層次的最根處開始,在每一層中,首先調用基類的構造函數,然后調用成員對象的構造函數。析構則嚴格按照與構造相反的次序執行,該次序是唯一的,否則編譯器將無法自動執行析構過程。

一個有趣的現象是,成員對象初始化的次序完全不受它們在初始化表中次序的影響,只由成員對象在類中聲明的次序決定。這是因為類的聲明是唯一的,而類的構造函數可以有多個,因此會有多個不同次序的初始化表。如果成員對象按照初始化表的次序進行構造,這將導致析構函數無法得到唯一的逆序。

拷貝構造函數與賦值函數

本章開頭講過,如果不主動編寫拷貝構造函數和賦值函數,編譯器將以“位拷貝”的方式自動生成缺省的函數。倘若類中含有指針變量,那么這兩個缺省的函數就隱含了錯誤。以類 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 被釋放了兩次。

拷貝構造函數和賦值函數非常容易混淆,常導致錯寫、錯用。拷貝構造函數是在對象被創建時調用的,而賦值函數只能被已經存在了的對象調用。以下程序中,第三個語句和第四個語句很相似,你分得清楚哪個調用了拷貝構造函數,哪個調用了賦值函數嗎?

String  a(“hello”); 
String  b(“world”); 
String  c = a;  // 調用了拷貝構造函數,最好寫成 c(a); 
c = b;  // 調用了賦值函數 
// 拷貝構造函數 
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; 
}  

如果我們實在不想編寫拷貝構造函數和賦值函數,又不允許別人使用編譯器生成的缺省函數,只需將拷貝構造函數和賦值函數聲明為私有函數,不用編寫代碼。

在派生類中實現類的基本函數

基類的構造函數、析構函數、賦值函數都不能被派生類繼承。如果類之間存在繼承關系,在編寫上述基本函數時應注意以下事項:

  • 派生類的構造函數應在其初始化表里調用基類的構造函數。
  • 基類與派生類的析構函數應該為虛(即加 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

  • 在編寫派生類的賦值函數時,注意不要忘記對基類的數據成員重新賦值。
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; 
}

10 類的繼承與組合

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容