《高質量程序設計指南--C/C++語言》學習筆記

《高質量程序設計指南--C/C++語言》學習筆記

高質量軟件開發之道

一般地,軟件設計應該將“設備相關程序”與“設備無關程序”分開,將“功能模塊”與“用戶界面”分開,這樣可以提高可移植性。

儘可能複用你所能複用的東西。

修改錯誤代碼時的注意事項:

  • 發現錯誤時不要急於修改,先思考一下修改此代碼會不會引發其他問題。
  • 考慮是否還有同類型的其他錯誤。
  • 不論原有程序是否絕對正確,只要對此程序做過改動(哪怕是微不足道的),都要進行迴歸測試。

對於以源代碼形式提供的庫,必須使用當前的編譯器對其重新編譯;如果是二進制級的庫,除非它的開發商保證該庫的實現與IDE的缺省庫是二進制兼容的,否則不能使用。

開發環境泛指支持軟件開發的一切工具,例如操作系統、代碼編輯器、編譯器、連接器、調試器等等。**集成開發環境(IDE)**則是把編輯器、編譯器、連接器及調試器等各種工具集成到了一個工作空間中。如果沒有IDE,就得手動編輯編譯連接的命令行或者makefile,手工編輯它們的參數設置。

設計上應該追求簡單低耦合

程序設計入門

內部名稱

在C語言中,所有函數不是局部於編譯單元(文件作用域)的static函數,就是具有extern連接類型和global作用域的全局函數,因此除了兩個分別位於不同編譯單元的static函數可以同名外,全局函數是不能同名的;全局變量也是同樣的道理。其原因是C語言採用了一種極其簡單的函數名稱區分規則:僅在所有函數名的前面添加前綴"_",從唯一識別函數的作用上來說,與不添加前綴沒有什麼不同。

但是C++語言允許用戶在不同的作用域中定義同名的函數、類型和變量等,這些作用域不僅僅限於編譯單元,還包括classstructunionnamespace等;甚至在同一個作用域中也可以定義同名的函數,即重載函數。爲了避免連接二義性,會對這些函數進行重命名,在C++中,稱爲**”Name-Mangling"(名字修飾或名字改編)**。例如在它們的前面分別添加所屬各級作用域的名稱(classnamespace等)及重載函數的經過編碼的參數信息(參數類型和個數等)作爲前綴或後綴。另外,C++標註的不同實現會採取不同的Name-Mangling方案(標準沒有強制規定)。

連接規範

在使用不同編程語言進行聯合軟件開發的時候,需要統一全局函數、全局變量、全局常量、數據類型等的鏈接規範,特別是在不同模塊之間共享的接口定義部分。因爲連接規範關係到編譯器採用什麼樣的Name-Mangling方案來重命名這些標識符的名稱,而如果同一個標識符在不同的編譯單元或模塊中具有不一致的連接規範,就會產生不一致的內部名稱,這肯定會導致程序連接失敗。

同樣道理,在開發程序庫的時候,明確連接規範也是必須要遵循的一條規則。通用的連接規範則屬C連接規範:extern C

變量及其初始化

初始化和賦值的不同:前者發生在對象(變量)創建的同時,而後者是在對象創建後進行的。

注意:在一個編譯單元中定義的全局變量的初始值不要依賴定義於另一個編譯單元中的全局變量的初始值。這是因爲:雖然編譯器和連接器可以決定同一個編譯單元中定義的全局變量的初始化順序保持與它們定義的先後順序一致,但是卻無法決定當兩個編譯單元連接在一起時哪一個的全局變量的初始化先於另一個編譯單元的全局變量的初始化。也就是說,這一次編譯連接和下一次編譯連接很可能使不同編譯單元之間的全局變量的初始化順序發生改變。例如下面的做法是不當的:

//file.c
int g_x = 100;
//file2.c
extern int g_x;
double g_d = g_x + 10;

如果g_x初始化被排在g_d的前面,那麼g_d就會被初始化爲110;但是如果反過來,那麼g_d的初始值就無法預料了。

浮點變量與零值比較

計算機表示浮點數(float和double類型)都有一個精度限制。對於超出了精度限制的浮點數,計算機會把它們的精度之外的小數部分截斷。因此本來不相等的兩個浮點數在計算機中可能就變成相等的了。

如果兩個同符號浮點數之差的絕對值小於或等於一個可接受的誤差(即精度),就認爲它們是相等的,否則就是不相等的。精度根據具體應用要求而定,不要直接用==!=對兩個浮點數進行比較,雖然C/C++語言支持直接對浮點數進行==!=的比較操作,但是由於它們採用的精度往往比我們實際應用中要求的精度高,所以可能導致不符合實際需求的結果甚至錯誤。

#define EPSILON 1e-6 //精度
if(abs(x-y) <= EPSILON) //x等於y
if(abs(x-y) > EPSILON) //x不等於y

if(abs(x)<=EPSILON) //x等於)
if(abs(x) > EPSILON) //x不等於0

C++/C常量

常用的常量可以分爲:字面常量、符號常量、契約性常量、布爾常量和枚舉常量等。

由於字面常量只能引用,不能修改,所以語言實現一般把它保存在程序的符號表裏面而不是一般的數據區中。

存在兩種符號常量:用#define定義的宏常量和用const定義的常量。由於#define是預編譯僞指令,它定義的宏常量在進入編譯階段前就已經被替換爲所代表的字面常量了,因此宏常量在本質上是字面常量。

在標準C語言中,const符號常量默認是外連接的(分配存儲),也就是說你不能在兩個(或兩個以上)編譯單元總同時定義一個同名的const符號常量(重複定義錯誤),或者把一個const符號常量定義放在一個頭文件中而在多個編譯單元中同時包含該頭文件。但是在標準C++中,const符號常量默認是內連接的,因此可以定義在頭文件中。當在不同的編譯單元中同時包含該頭文件時,編譯器認爲它們是不同的符號常量,因此每個編譯單元獨立編譯時會分別爲它們分配存儲空間,而在連接時進行常量合併。

正確定義符號常量

在C++需要對外公開的常量放在頭文件中,不需要對外公開的常量放在定義文件的頭部。爲便於管理,可以把不同模塊的常量集中存放在一個公用的頭文件中。

儘量使用const而不是#define來定義符號常量,包括字符串常量。

類中的常量

非靜態const數據成員是屬於每一個對象的成員,只在某個對象的生存期限內是常量,而對於整個類來說它是可變的,除非是static const

不能在類聲明中初始化非靜態const數據成員。例如下面的代碼是錯誤的,因爲在類的對象被創建前,編譯器無法知道SIZE的值是多少。

class A
{
	...
	const int SIZE = 100; //錯誤:企圖在類聲明中初始化非靜態const數據成員,應該通過參數化列表的方式進行初始化
	int array[SIZE]; //錯誤:未知的SIZE
};

想要建立在整個類中都恆定的常量,需要通過類中的枚舉常量或者static const來完成。

class A
{
	...
	enum
	{
		SIZE1 = 100, //枚舉常量
		SIZE2 = 200;
	};
	
	int array1[SIZE1];
	int array2[SIZE2];
};
class A
{
public:
	static const int SIZE1 = 100; //靜態常量成員
	static const int SIZE2 = 200;

private:	
	int array1[SIZE1];
	int array2[SIZE2];
};

實際應用中如何定義常量

在C程序中const符號常量定義的默認連接類型是extern的,即外連接(extern linkage),就像全局變量一樣。因此,如果要在頭文件中定義,必須使用static關鍵字,這樣每一個包含該頭文件的編譯單元就會分別擁有該常量的一份獨立定義實體(如同直接在每一個源文件中分別定義一次),否則會導致“redefinition"的編譯器診斷信息;如果在源文件中定義,除非明確改變它的連接類型爲static(實際上是存儲類型爲static,連接類型爲內連接)的,否則其他編譯單元就可以通過extern聲明來訪問它。

但是在C++程序中const符號常量定義的默認連接類型卻是static的,即內連接,就像class的定義一樣,這就是在頭文件中定義而不需要static關鍵字的原因。

在C程序中定義多個編譯單元或模塊公用的常量

方法一:
在某個公用頭文件中將符號常量定義爲static並初始化,例如:

//CommonDef.h
static const int MAX_LENGTH = 1024;

然後每一個使用它的編譯單元#include該頭文件即可;

方法二:
在某個公用的頭文件中將符號常量聲明爲extern的,例如:

//CommonDef.h
extern const int MAX_LENGTH;

並且在某個源文件中定義一次:

const int MAX_LENGTH = 1024;

然後每個使用它的編譯單元#include該頭文件即可.

方法三
如果是整型常量,在某個公用頭文件中定義enum類型,然後每一個使用它的編譯單元#include該頭文件即可。

在C程序中定義僅供一個編譯單元使用的常量

直接於該編譯單元(源文件)開頭位置將符號常量定義爲static並初始化,例如:

//foo.c
static const int MAX_LENGTH = 1024;

在C++程序中定義多個編譯單元或模塊公用的常量

方法一
在某個公用的頭文件中直接在某個名字空間中或者全局名字空間中定義符號常量並初始化(有無static無所謂),例如:

//CommonDef.h
const int MAX_LENGTH = 1024;

然後每一個使用它的編譯單元#include該頭文件即可。

方法二

在某個公用的頭文件中並且在某個名字空間中或者全局名字空間中將符號常量聲明爲extern的,例如:

//CommonDef.h
extern const int MAX_LENGTH;

並且在某個源文件中定義一次並初始化:

const int MAX_LENGTH = 1024;

然後每個使用它的編譯單元#include該頭文件即可。

方法三
如果是整形常量,在某個公用頭文件中定義enum類型,然後每一個使用它的編譯單元#include該頭文件即可。

方法四
定義爲某一個公用類的static const數據成員並初始化,或者定義爲類內的枚舉類型,例如:

//Utility.h
class Utility{
public:
	static const int MAX_LENGTH;
	enum{
		TIME_OUT = 10;
	};
};
//Utility.cpp
const int Utility::MAX_LENGTH = 1024;

每一個使用它的編譯單元#include該類的定義即可。

方法二和方法四的優點:

  • 節省存儲,每一個編譯單元訪問的都是這個唯一的定義
  • 修改初值後只需重新編譯定義所在編譯單元即可,影響面很小

方法二和方法四的缺點:

  • 如果要改變初值,需修改源文件

方法一和方法三的優點:

  • 維護方便

方法一和方法三的缺點:

  • 如果修改常量初值,則將影響多個編譯單元,所有受影響的編譯單元必須重新編譯;
  • 每一個符號常量在每一個包含它們的編譯單元內都存在一份獨立的拷貝內容,每個編譯單元訪問的就是各自的拷貝內容,因此浪費存儲空間

在C++程序中定義僅供一個編譯單元使用的常量

直接於該編譯單元(源文件)開頭位置將符號常量定義爲常量並初始化(有無static無所謂),例如:

//foo.cpp
const int MAX_LENGTH = 1024;

C++/C函數設計基礎

不論是函數的原型還是定義,都要明確寫出每個參數的類型和名字,不要貪圖省事只寫參數的類型而忽略參數名字。如果函數沒有參數,那麼使用void而不要空着,這是因爲標準C把空的參數列表解釋爲可以接收任何類型和個數的參數;而標準C++則把空的參數列表解釋爲不可以接收任何參數。在移植C++/C程序時尤其要注意這方面的不同。

不要將正常值和錯誤標誌混在一起返回。建議正常值用輸出參數獲得,而錯誤標誌用return語句返回。

函數的功能要單一,即一個函數只完成一件事情,不要設計多用途的函數。函數規模儘量控制在50行代碼以內。

不僅要檢查輸入參數的有效性(例如通過assert),還要檢查通過其他途徑進入函數體內的變量的有效性,例如全局變量、文件句柄等。

連接類型

連接類型分爲外連接內連接無連接 3種。連接類型表明了一個標識符的可見性,容易與作用域混淆。

如果一個標識符能夠在其他編譯單元中或者在定義它的編譯單元中的其他範圍內被調用,那麼它就是外連接的。外連接的標識符需要分配運行時的存儲空間。

void f(bool flag) {...} //函數定義是外連接的
int g_int; //全局變量g_int是外連接的
extern const int MAX_LENGTH = 1024; //MAX_LENGTH變成外連接的
namespace NS_H
{
	long count; //NS_H::count是外連接的
	bool g(); //NS_H::g是外連接的,但原型是內連接的
}

如果一個標識符能在定義它的編譯單元中的其他範圍內被調用,但是不能在其他編譯單元中被調用,那麼它就是內連接的。

static void f2() {...} //f2爲內連接的
union  //匿名聯合的成員是內連接的
{
	long count;
	char *p;
}

class Me{...}; //Me是內連接的

const int MAX_LENGTH = 1024; //常量是內連接的
typedef long Integer; //typedef爲內連接的

一個僅能在聲明它的範圍內被調用的名字是無連接的。

void f()
{
	int a; //a是無連接的
	class B{...}; //局部類是無連接的,具有程序塊作用域
}

在這裏插入圖片描述
在這裏插入圖片描述

使用斷言

C++/C的宏assert(expression):當表達式爲假時,調用庫函數abort()終止程序。程序一般分爲DebugRelease版本,assert只在Debug版本內有效。

在函數的入口處,建議使用斷言來檢查參數的有效性(合法性)。

請給assert語句加註釋,告訴人們assert語句究竟要幹什麼。

使用斷言的目的是捕捉在運行時不應該發生的非法情況。不要混淆非法情況與錯誤情況之間的區別,後者是程序運行過程中自然存在的並且是一定要主動做出處理的。例如動態內存申請失敗不是非法情況,而是錯誤情況。應該使用if語句捕捉錯誤情況並給出錯誤處理代碼,而不應該適用assert(否則在Release版本失效)。

C++/C指針、數組和字符串

不管指針變量是全局的還是局部的,靜態的還是非靜態的,應當在聲明它的同時初始化它,要麼賦予它一個有效的地址,要麼賦予它NULL

數組名字本身就是一個指針,是一個指針常量,例如對於int a[10]a等價於int *const a,因此不能試圖修改數組名的值。數組名的值就是數組第一個元素的內存首地址,即a == &a[0]

雖然數組自己知道它有多少個元素,但是由於可以使用整形變量及其表達式作爲數組的下標,而變量的值在編譯時是無法確定的,所以語言無法執行靜態檢查。

數組與指針間存在如下的等價關係:
(1) 一維數組等價於元素的指針,例如:

int a[10]  <=> int *const a;

(2) 二維數組等價於指向一維數組的指針,例如:

int b[3][4] <=> int (* const b)[4]

(3) 三維數組等價於指向二維數組的指針,例如:

int c[3][4][5]  <=> int (* const c)[4][5] 

對於多維數組,C++/C並不像一維數組那樣可以簡單地轉換爲同類型指針,而是轉換爲與其等價的數組指針。例如int a[m][n]就轉換爲int (*a)[n],就是說a是一個指向一維數組的指針,而該一維數組具有n個元素,a是指向原來數組的第一行的指針,a+1就是指向第二行的指針,依次類推。下面的幾種表達方式是等價的:

  • a[i][j]
  • *(a[i] + j)
  • (*(a+i))[j]
  • *(*(a+i)+j)

注意,上述的4個表達式中的a是指向一維數組的指針,而不是單純的指向int類型元素的指針,因此(*(a+i)+j)的值實際上是((a+i*sizeof(int)*n)+j*sizeof(int))

數組傳遞在C++/C中默認就是地址傳遞。如果你想要按值來傳遞數組,可以將數組封裝起來,例如放到structclass裏面作爲一個成員,因爲結構和類對象默認都是按值傳遞的。

動態創建和刪除多維數組:你不能簡單的使用一個元素類型的指針來接收動態創建的多維數組的返回地址,這是因爲一個多維數組在語義上並不等價於一個指向其元素類型的指針,相反它等價於一個“指向數組的指針”。

char *p1 = new char[5][3]; //錯誤! 語義不等價
int *p2 = new int[4][6]; //錯誤! 語義不等價

char (*p3)[4]  = new char[5][4];//正確,退化第一維,語義等價
char (*p4)[5][7] = new char[20][5][7]; //正確,退化第一維,語義等價

delete[]p3; //刪除p3
delete[]p4; //刪除p4

字符數組、字符指針和字符串

字符數組就是元素爲字符變量的數組,而字符串則是以\0(ASCII碼值爲0x00)爲結束字符的字符數組。可見,字符數組不一定是字符串

由於字符串的連續性,編譯器沒必要通過它的長度信息來提取整個字符串,僅通過一個指向其開頭字符的字符指針就能實現對整個字符串的引用。

如果用一個字符串字面常量來初始化一個字符數組,數組的長度至少要比字符串字面常量的長度大1,因爲還要保存結束符\0

char array[] = "hello";
//數組array的元素爲{'h','e','l','l','o', '\0'}
char arrChar_1[] = {'a', 'b', '\0', 'd', 'e'};
char arrChar_2[] = "hello";
char *p  = "hello";

cout << sizeof(arrChar_1) << endl; //5, 表示該數組佔5個字節
cout << strlen(arrChar_1) << endl; //2, 表示字符串長度爲2
cout << sizeof(arrChar_2) << endl; //6, 表示該數組佔6個字節
cout << strlen(arrChar_2) << endl; //5, 表示字符串長度爲5
cout << sizeof(p) << endl; //4,表示指針p佔4個字節
cout << strlen(p) << endl; //5,表示字符串長度爲5

字符串的拷貝請使用庫函數strcpystrncpy而不是用=(使用=就成了字符指針的賦值)。同理不要使用==!=>=等符號直接比較兩個字符串,應使用strcmpstrncpm等庫函數。

對字符串進行拷貝時,要保證函數結束後目標字符串的結尾有\0結束標誌。某些字符串函數並不會自動在目標字符串結尾追加\0,例如strncpystrncat,除非你指定的n值比源串的長度大1,stcpystrcat會把源串的結束符一併拷貝到目標串中。

函數指針

可以通過函數指針數組實現同類型函數的批量調用。在C++動態決議的虛擬機制中使用的vtable就是一個用來保存虛成員函數地址的函數指針數組。

double _cdecl (* fp[5])(double) = { sqrt, fabs, cos, sin, exp};
for(int k=0; k<5; k++)
{
	cout << "Result:" << fp[k](10.25) << endl; 
}
class CTest{
public:
	void f(void) {cout << "CTest::f()" <<endl;} //普通成員函數
	static void g(void) {cout << "CTest::g()"<< endl;} //靜態成員函數
	virtual void h(void) {cout << "CTest::h()" << endl;} //虛成員函數

	//...

};

void main()
{
	typedef void (*GFPtr)(void); //定義一個全局函數指針類型
	GFPtr fp = CTest::g; //取靜態成員函數地址的方法和取一個全局函數的地址相似
	fp(); //通過函數指針調用類靜態成員函數

	typedef void (CTest::*MemFuncPtr)(void); //聲明類成員函數指針類型
	MemFuncPtr mfp_1 = &CTest::f; //聲明成員函數指針變量並初始化
	MemFuncPtr mfp_2 = &CTest::h; //注意獲取成員函數地址的方法

	CTest theObj;
	(theObj.*mfp_1)(); //使用對象和成員函數指針調用成員函數
	(theObj.*mfp_2)();

	CTest *pTest = &theObj;
	(pTest->*mfp_1)(); //使用對象指針和成員函數指針調用成員函數
	(pTest->*mfp_2)();
}

輸出如下:
在這裏插入圖片描述

C++/C高級數據類型

成員對齊

對於複合類型(一般指結構或類)的對象,如果它的起始地址能夠滿足其中**要求最嚴格(或最高)**的那個數據成員的自然對齊要求,那麼它就是自然對齊的。如果那個數據成員又是一個複合類型的對象,則依次類推,直到最後都是基本類型的數據成員。

自然對齊要求最嚴格:例如double變量的地址要能被8整除,int變量的地址只需要能被4整除,bool變量的地址只需要能被1整除。在C++/C的基本數據類型中,如果不考慮enum可能的最大值所需的內存字節數,double就是對齊要求最嚴格的類型,其次是intfloat,然後是shortboolchar

typedef unsigned char BYTE;
enum Color {RED = 0x01, BLUE, GREEN, YELLOW, BLACK};

struct Sedan //私家車
{
	bool m_hasSkylight; //是否有天窗
	Color m_color; //顏色
	bool m_isAutoShift; //是否是自動擋
	double m_price; //價格
	BYTE m_seatNum; //座位數量
};

對於上面的Sedan類,顯然double成員m_price的對其要求更嚴格,因此Sedan對象的地址應該能被8整除。其他成員的其實地址也需要滿足各自的自然對齊要求。
在這裏插入圖片描述
下面是一些例子:

struct X
{
	char m_ch;
	char *m_pStr;
};

sizeof(X) = 8,按4字節對齊

struct Y
{
	char m_ch;
	int m_count;
};

sizeof(Y) = 8,按4字節對齊

struct Z
{
	bool m_ok;
	char m_name[6];
};

sizeof(Z) = 7,按1字節對齊

struct R
{
	char m_ch;
	double m_width;
	char m_name[6];
};

sizeof(R) = 24,按8字節對齊

struct T
{
	int m_no;
	R m_r;
};

sizeof(T) = 32,按8字節對齊

struct U
{
	bool m_ok;
	T m_t;
};

sizeof(T) = 40,按8字節對齊

在這裏插入圖片描述
爲了節省內存空間,顯然要設法減少對象中的空洞,寧願讓末尾留下空洞也不要讓中間留下空洞,儘量使所有成員連續存放,並且減少末尾的填充字節。方法很簡單:按照從大到小的順序從前到後聲明每一個數據成員,並且儘量使用較小的成員對齊方式

對齊方式的指定還關係到模塊之間接口的語義一致性和對象的二進制兼容性,不一致的對齊方式極有可能導致程序運行時產生錯誤的結果甚至崩潰。能夠100%保證一致的方法就是直接在代碼中使用編譯器提供的方法指定每一個接口數據類型的對齊方式,而不是依賴於命令行參數設置或者其他途徑。

Union

Union提供了一種使不同類型數據成員之間共享存儲空間的方法,同時可以實現不同類型數據成員之間的自動類型轉換。Union在同一時間只能存儲一個成員的值(即只有一個數據是活躍的)。Union的大小取決於其中字節數最多的成員。在定義Union時可以指定初始值,但是隻能指定一個初始值,而且該初始值的類型必須和Union的第一個成員的類型匹配。

枚舉 Enum

C++/C枚舉類型允許我們定義特定用途的一組符號常量,它表明這種類型的變量可以取值的範圍。當你定義一個枚舉類型的時候,如果不特別指定其中標識符的值,則第一個標識符的值將爲0,後面的標識符將比前面的標識符依次大1;如果你指定了其中某一個標識符的值,那麼它後面的標識符自動在前面的標識符值的基礎上依次加1,除非你也同時指定了它們的值。

在標準C中,枚舉類型的內存大小等於sizeof(int)。但是在標準C++中,枚舉類型的底層表示並非必須是一個int–它可能更大或者更小(與該枚舉類型的實際取值範圍有關)。

枚舉類型可以是匿名的。匿名的枚舉類型就相當於直接定義的const符號常量,可以作爲全局枚舉,也可以放在任何類定義和名字空間中

C++/C編譯預處理

C++/C的編譯預處理器對預編譯僞指令進行處理後生成中間文件作爲編譯器的輸入,因此所有的預編譯指令都不會進入編譯階段。預編譯指令一般以#開頭。

文件包含

#include <頭文件名稱>一般用來包含開發環境提供的庫頭文件,它指示編譯預處理器在開發環境設定的搜索路徑中查找所需要的頭文件。#include "頭文件名稱"一般用來包含自己編寫的頭文件,它指示編譯器首先在當前工作目錄下搜索頭文件,如果找不到的話再到開發環境設定的路徑中去找。

使用該僞指令前時,頭文件前面可以加相對路徑或決定路徑(此處的\並不解釋爲轉義字符)。例如:

#include ".\myinclude\abc.h"
#include "C:\myproject\test1\source\include\abc.inl"

頭文件包含的合理順序

無論是在頭文件還是源文件中,在文件開始部分包含其他的頭文件時需要遵循一定的順序。如果包含順序不當,有可能出現包含順序依賴問題,甚至引起編譯時錯誤。推薦的順序如下:
在頭文件中
(1) 包含當前工程中所需要的自定義頭文件(順序自定)
(2) 包含第三方程序庫的頭文件
(3) 包含標準頭文件

在源文件中:
(1) 包含該源文件對應的頭文件(如果存在)
(2) 包含當前工程中所需要的自定義頭文件
(3) 包含第三方程序庫的頭文件
(4) 包含標準頭文件

宏定義

宏定義具有文件作用域,不論宏定義出現在文件中的哪個地方,例如函數體內、類型定義內部、名字空間內部等,在它後面的任何地方都可以引用宏。

宏定義不是C++/C語句,因此不需要使用語句結束符;

不要使用宏來定義新類型名,應該使用typedef,否則容易造成錯誤。

給宏添加註釋時請使用塊註釋(/* */),而不要使用行註釋。因爲有些編譯器可能會把宏後面的行註釋理解爲宏體的一部分。

儘量使用const取代宏來定義符號常量。

對於較長的使用頻率較高的重複代碼片段,建議使用函數或模板而不要使用帶參數的宏定義;而對於較短的重複代碼片段,可以使用帶參數的宏定義,這不僅是出於類型安全的考慮,而且也是優化與折衷的體現。

條件編譯

使用條件編譯可以控制預處理器選擇不同的代碼段作爲編譯器的輸入,從而使得源程序在不同的編譯條件下產生不同的目標代碼。條件編譯爲程序的移植和調試帶來了極大方便,可以用它來暫時或永久地阻止一段代碼的編譯。條件編譯指令主要包括#if#ifdef#ifndef#elif#else#endifdefine。每一個條件編譯塊都必須以#if開始,以#endif結束,#if必須與它下面的某一個#endif配對;define必須結合#if或者#elif使用,而不能單獨使用。條件編譯塊可以出現在程序代碼的任何地方。

通常我們想放棄編譯一段代碼時,會使用塊註釋。但是如果這段代碼本身就有塊註釋時,那麼雙重註釋很麻煩。可以通過下面的條件編譯僞指令來屏蔽這段代碼。如果要這段代碼生效,只需要把0改爲任何一個非0的值(例如1)記得。

#if 0
.../*...*/ //希望禁止編譯的代碼段
.../*...*/ //希望禁止編譯的代碼段
#endif
#define FLAG_DOS 2
#define FLAG_UNIX 1
#define FLAG_WIN 0

#define OS 1

#if OS == FLAG_DOS
	cout << "DOS platform" << endl;
#elif OS == FLAG_UNIX
	cout << "UNIX platform" << endl;
#elif OS == FLAG_WIN
	cout << "Windows platform" << endl;
#else 
    cout << "Unknow platform" << endl;
#endif

預編譯僞指令#ifdef XYZ等價於#if define(XYZ),此處XYZ稱爲調試宏。如果前面曾經用#define定義過宏XYZ,那麼#ifdef XYZ表示條件爲真,否則條件爲假。

#define XYZ
...
#ifdef XYZ
DoSomething();
#endif

預編譯僞指令#ifndef XYZ等價於#if !define(XYZ)

#ifndef GRAPHICS_H //防止graphics.h被重複利用
#define GRAPHICS_H 

#include "myheader.h"
#include <math.h>
...

#endif

#error

編譯僞指令#error用於輸出與平臺、環境等有關的信息。

#if !define(WIN32)
 #error ERROR: Only Win32 platform supported!
#endif

#ifndef _cplusplus
 #error MFC requires C++ compilation (use a .cpp suffix)
#endif 

當預處理器發現應用程序中沒有定義宏WIN32或者_cplusplus時,把#error後面的字符序列輸出到屏幕後即終止,程序不會進入編譯階段。

#pragam

編譯僞指令#pragam用於執行語言實現所定義的動作,具體參考所使用的編譯器幫助文檔。

#pragam pack(push, 8) /* 對象成員對齊字節數 */
#pragam pack(pop)

#pragam warning(disable:4069) /*不要產生第C4069號編譯錯誤*/
#pragam comment(lib, "kernel32.lib")

預定義符號常量

C++繼承了ANSI C的預定義符號常量,預處理器在處理代碼時將它們替換爲確定的字面常量。這些符號不能用#define重新定義,也不能用#undef取消。

符號常量 解釋
_LINE_ 引用該符號的語句的代碼行號
_FILE_ 引用該符號的語句的源文件名稱
_DATE_ 引用該符號的語句所在源文件被編譯的日期(字符串)
_TIME_ 引用該符號的語句所在源文件被編譯的時間(字符串)
_TIMESTAMP_ 引用該符號的語句所在源文件被編譯的日期和時間(字符串)
_STDC_ 標準C語言環境都會定義該宏以標識當前環境

上表中的預定義符號常量可以被直接引用,常用來輸出調試信息和定位異常發生的文件及代碼行。

double * const pDouble = new(nothrow) double[10000];
if(pDouble == NULL){
	cerr << "allocate memory failed on line" << (_LINE_-2)
	    << "in file " << _FILE_ << endl;
}

C++/C文件結構和程序版式

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

程序文件的目錄結構

在這裏插入圖片描述
可以參照上圖的目錄結構來組織文件:
(1) Include目錄存放應用程序的頭文件(.h),還可以再細分子目錄。
(2) Source目錄存放應用程序的源文件(.c.cpp),還可以再細分子目錄。
(3) Shared目錄存放一些共享的文件。
(4) Resource目錄存放應用程序所用的各種資源文件,包括圖片、視頻、圖標、光標、對話框等,可以繼續細分子目錄。
(5) Debug目錄存放應用程序調試版本生成的中間文件。
(6) Release目錄存放應用程序發行版本生成的中間文件。
(7) Bin目錄存放程序員自己創建的lib文件和dll文件。

注意:分清楚編譯時相對路徑和運行時相對路徑的不同,這在編寫操作DLL文件、INI文件及數據文件等外部文件的代碼時很重要,因爲它們的”參照物“不同。例如#include "..\include\abc.h是相對於當前工程所在目錄的路徑,或者是相對於當前文件所在目錄的路徑,在編譯選項的設置中也有這樣的路徑。而OpenFile("..\abc.ini");則是相對於運行時可執行文件所在目錄的路徑,或者是相對於你爲當前程序設置的工作目錄的路徑。

文件的結構

頭文件的用途和結構

頭文件用途:

  • 通過頭文件來調用庫功能,在很多場合,源代碼不便向用戶公佈,只要向用戶提供頭文件和二進制的庫即可。
  • 頭文件能加強類型安全檢查。
  • 頭文件可以提高程序的可讀性(清晰性)。

頭文件中的元素比較多,一般有如下元素:

  • 頭文件註釋(包括文件說明、功能描述、版權聲明等)(必須有)
  • 內部包含衛哨開始(#ifndef XXX / #define XXX)(必須有)
  • #include其他頭文件(如果需要)
  • 外部變量和全局函數聲明(如果需要)
  • 常量和宏定義(如果需要)
  • 類型前置聲明和定義(如果需要)
  • 全局函數原型和內聯函數的定義(如果需要)
  • 內部包含衛哨結束:#endif //XXX(必須有)
  • 文件版本及修訂說明

如果程序中需要內聯函數,那麼內聯函數的定義應當放在頭文件中,因爲內聯函數調用語句最終被拓展開來而不是採用真正的函數調用機制。

源文件結構

源文件的結構一般如下:

  • 源文件註釋(包括文件說明、功能描述、版權聲明等)(必須有)
  • 預處理指令(如果需要)
  • 常量和宏定義(如果需要)
  • 外部變量聲明和全局變量定義及初始化(如果需要)
  • 成員函數和全局函數的定義(如果需要)
  • 文件修改記錄

C++/C應用程序命名規則

標識符的名字應當直觀且可以拼讀,可望文知意,不必進行”解碼“。

不要僅靠大小寫來區分相似標識符。

不要使程序中出現局部變量和全局變量同名的現象。

變量的名字應該使用”名詞“或者”形容詞加名詞“的格式來命名。例如:

float value;
float oldValue;
float newValue;

全局函數的名字應當是使用"動詞”或者“動詞加名詞”。

DrawBox(); //全局函數
box.Draw();//類的成員函數

建議:類型名和函數名均以大寫字母開頭的單詞組合而成。

class Node;
void Draw(void);

建議:變量名和參數名採用第一個單詞首字母小寫而後面的單詞首字母大寫的單詞組合

bool flag;
int drawMode;

建議:符號常量和宏名用全大寫的單詞組合而成,並在單詞之間用單下劃線分割,注意首尾最好不要使用下劃線。

const int MAX_LENGTH =100;

建議:給靜態變量加前綴s_(表示static

void Init()
{
	static int s_initValue;//靜態變量
	...
}

建議:如果不得已需要全局變量,這時全局變量加前綴g_(表示global

int g_howManyPeople;
int g_howMuchMoney;

建議:類的數據成員加前綴m_(表示member),這樣可以避免數據成員與成員函數的參數同名

void Object::SetValue(int width, int height)
{
	m_width = width;
	m_height = height;
}

建議:爲了防止某一軟件庫中的一些標識符和其他軟件庫中的衝突,可以統一爲各種標識符加上能反應軟件性質的前綴。更好的辦法是使用名字空間。

C++面向對象程序設計方法概述

不要在數組中直接存放多態對象,而是換之以基類指針或者基類的智能指針。

對象的內存映像

**構成對象本身的只有數據,任何成員函數都不隸屬於任何一個對象,非靜態成員函數與對象的關係就是綁定,綁定的中介就是this指針。**成員函數爲該類所有對象共享,不僅是出於簡化語言設計、節省存儲的目的,而且是爲了使同類對象具有一致的行爲。雖然同類對象的行爲一致,但是操作不同對象的數據成員,就會使各個對象具有不同的狀態。

class Shape{
public:
	Shape(): m_color(0) {}
	virtual ~Shape() {}
	float GetColor() const {return m_color;}
	void SetColor(float color) {m_color = color;}
	virtual void Draw() = 0;

private:
	float m_color;
};

class Rectangle: public Shape{
public:
	......
private:
	......
};

在這裏插入圖片描述

  • 派生類繼承基類的非靜態數據成員,並作爲自己對象的專用數據成員。
  • 派生類繼承基類的非靜態成員函數並可以像自己的成員函數一樣訪問。
  • 爲每一個多態類創建一個虛函數指針數組vtable,該類的所有虛函數(繼承自基類的或者新增的)的地址都保存在這張表中。
  • 多態類的每一個對象(如果有)中安插一個指針成員vptr,其類型爲指向函數指針的指針,它總是指向所屬類的vtable,也就是說:vptr當前所在的對象是什麼類型的,那麼它就指向這個類型的vtablevptr是C++對象的隱含數據成員之一(實際上它被安插在多態類的定義中);
  • 如果基類已經插入了vptr,則派生類將繼承和重用該vptr
  • 如果派生類是從多個基類繼承或者有多個繼承分支(從所有根類開始算起),而其中若干個繼承分支上出現了多態類,則派生類將從這些分支中的每個分支上繼承一個vptr,編譯器也將爲它生成多個vtable,有幾個vptr就生成幾個vtable(每個vptr分別指向其中一個),分別與它的多態基類對應。
  • vptr在派生類對象中的相對位置不會隨着繼承層次的逐漸加深而改變,並且現在的編譯器一般都將vptr放在所有數據成員的最前面;
  • 爲了支持RTTI,爲每一個多態類創建一個type_info對象,並把其地址保存在vtable中的固定位置(一般爲第一個位置)(這一條取決於具體編譯器的實現技術,標準並未規定)。

對象的初始化、拷貝和析構

不要在構造函數內做與初始化對象無關的工作,不要在析構函數內做與銷燬一個對象無關的工作。也就是說,構造函數和析構函數應該做能夠滿足正確初始化和銷燬一個對象的最少工作量,否則會降低效率,甚至會讓人誤解。比如對於一個用於消息發送和接收的類來說,不應該在構造函數內打開一個socket連接,同樣不應該在析構函數內斷開一個socket連接,應該將打開和斷開socket連接放到另外的成員函數內來完成。

初始化就是在對象創建的同時使用初值直接填充對象的內存單元,因此不會有數據類型轉換等中間過程,也就不會產生臨時對象。而賦值則是在對象創建好之後任何時候都可以調用的而且可以多次調用的函數,由於它調用的是=運算符,因此可能需要進行類型轉換,即會產生臨時對象。

構造函數的作用是:當對象的內存分配好後把它從原始狀態變爲良好的可用的狀態。

最好爲每個類顯式的定義構造函數和析構函數,即使它們暫時空着,尤其是當類含有指針成員或者引用成員的時候。

當使用成員初始化列表來初始化數據成員時,這些成員真正的初始化順序並不一定與你在初始化列表中爲它們安排的順序一致,編譯器總是按照它們在類中聲明的次序來初始化的。因此最好是按照它們聲明的順序來書寫成員初始化列表

不能同時定義一個無參數的構造函數和一個參數全部有默認值的構造函數,否則會造成二義性。

一般來說,重載的構造函數的行爲都差不多,因此必然存在重複代碼片段。當我們爲類定義多個構造函數時,設法把其中相同任務的代碼片段抽取出來並定義一個非public的成員函數,然後在每一個構造函數中適當的地方調用它。

注意不要將檢查自賦值的if語句

if(this != &other) //地址相等才認爲是一個對象

錯寫成

if(*this != other) //值相等不能作爲自賦值的判斷依據

在編寫派生類的賦值函數時,注意不要忘記對基類的數據成員重新賦值,這可通過調用基類的賦值函數來實現。

class Base{
public:
	//...
	Base& operator=(const Base& other);

private:
	int m_i, m_j, m_k;
};

class Derived: public Base{
public:
	//...
	Derived& operator=(const Derived& other);

private:
	int m_x, m_y, m_z;
};

Derived& Derived::operator=(const Derived& other){
	//(1) 自賦值檢查
	if(this != &other){
		// (2) 對基類的數據成員重新賦值
		Base::operator=(other); //因爲不能直接操作基類的私有數據成員

		//(3) 對派生類的數據成員賦值
		m_x = other.m_x;
		m_y = other.m_y;
		m_z = other.m_z;
	}
	//(4) 返回本對象的引用
	return *this;
}

C++函數的高級特性

函數重載

只能靠參數列表而不能僅靠返回值類型的不同來區分重載函數。編譯器根據參數列表爲每個重載函數產生不同的內部標識符。

不同的編譯器會產生不同風格的內部標識符,這就是不同廠商的編譯器和連接器不能兼容的一個主要原因。如果C++程序要調用已經被編譯的C函數,由於編譯後的名字不同,C++程序不能直接調用編譯後的C函數。C++提供了一個C連接交換指示符extern "C"來解決這個問題。

#ifdef __cplusplus
extern "C"{
#endif
void __cdecl foo(int x, int y);
//其他C函數
#ifdef __cplusplus
}
#endif
#ifdef __cplusplus
extern "C"{
#endif
#include "myheader.h"
//其他C頭文件
#ifdef __cplusplus
}
#endif

C++編譯器開發商已經對C標準庫的頭文件做了extern "C"處理,所以我們可以直接用#include引用這些頭文件。

擺脫隱藏

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); //錯誤:Base::f(int)被隱藏了
}

如果pd->f(10)確實想調用Base::f(int),那麼有兩種辦法:其一就是使用using聲明;其二就是通過調用轉移。

class Derived: public Base{
public:
	using Base::f; //使用`using`聲明
	void f(char *str);
};
class Derived: public Base{
public:
	void f(char *str);
	void f(int x) {Base::f(x);} //調用傳遞
};

參數的默認值

參數的默認值放在函數的聲明中,而不要放在定義體中。

如果函數有多個參數,參數只能從後向前默認,否則將導致函數調用語句怪模怪樣。

運算符重載

如果運算符被重載爲全局函數,那麼只有一個參數的運算符叫做一元運算符,有兩個參數的運算符叫做二元運算符。

如果運算符被重載爲類的成員函數,那麼一元運算符沒有參數(但是++和–的後置版本除外),二元運算符只有一個右側參數,因爲對象自己成了左側參數。

運算符 規則
所有的一元運算符 建議重載爲非靜態成員函數
=()[]->* 只能重載爲非靜態成員函數
+=-=/=*=&=|=~=%=>>=<<= 建議重載爲非靜態成員函數
所有其他運算符 建議重載爲全局函數

當爲一個類型重載++--的前置版本時,不需要參數;當爲一個類型重載++--的後置版本時,需要一個int類型的參數作爲標誌(即啞元,非具名參數)。

儘量選擇前置版本來使用,可以減少臨時對象的創建。

內聯函數

C++的函數內聯機制既具備宏代碼的效率,又增加了安全性,而且可以自由操作類的數據成員。所以C++程序中應該儘量使用內聯函數來取代宏代碼。

內聯函數的另一個優點是:函數被內聯後,編譯器就可以通過上下文相關的優化技術對結果代碼執行更深入的優化。

**注意:關鍵字inline必須與函數定義體放在一起才能使函數真正內聯,僅把inline放在函數聲明的前面不起任何作用。**即inline是一種用於實現的關鍵字,而不是用於聲明的關鍵字。

定義在類聲明中的成員函數將自動內聯。

以下情況不適合使用內聯:

  • 函數體內代碼較長,使用內聯會導致代碼膨脹。
  • 函數體內出現循環或者其他複雜的控制結構,那麼執行函數體內的代碼的時間將比函數調用的開銷大得多,內聯意義不大。

類型轉換函數

類型轉換的本質是創建新的目標對象,並以源對象的值來初始化,所以源對象沒有絲毫改變。不要把類型轉換理解爲”將源對象的類型轉換爲目標類型“。

在C++程序中儘量不要再使用C風格的類型轉換,除非源對象和目標類型都是基本類型的對象或指針,否則很不安全。

const成員函數

任何不會修改數據成員的成員函數都應該聲明爲const類型。如果在編寫const成員函數時不慎寫下了試圖修改數據成員的代碼,或者調用了其他非const成員函數,編譯器將指出錯誤。

static成員函數不能定義爲const的,因爲static成員函數只是全局函數的一個形式上的封裝,而全局函數不存在const一說;何況static成員函數不能訪問類的非靜態成員(沒有this指針),修改非靜態數據成員又從何說起呢?

C++異常處理機制和RTTI

C++異常處理

C++保證:如果一個異常在拋出點沒有得到處理,那麼它將一直被拋向上層調用者,直至main()函數,直至找到一個類型匹配的異常處理器,否則調用terminate()結束程序。

異常處理機制的本質:在真正導致錯誤的語句即將執行之前,並且異常發生的條件已經具備時,使用我們自定義的軟件異常(異常對象)來替代它,從而阻止它。因此,當異常拋出時,真正的錯誤實際上並未發生。

class DevideByZero {};

double Devide(double a, double b)
{
	if(abs(a) < std::numeric_limits<double>::epsilon())
		throw DevideByZero();//提前檢測異常發生條件並拋出自定義異常
	return a/b;
}

void test()
{
	double x = 100, y = 20.5;
	try{
		cout << Devide(x,y) << endl; //可能拋出異常DevideByZero
	}
	catch(DevideByZero&){
		cerr << "Devided by zero!" << endl;
	}
}

異常類型和異常對象

任何一種類型都可以當作異常類型,因此任何一個對象都可以當作異常對象,包括基本數據類型的變量、常量、任何類型的指針、引用、結構等,甚至空結構或空類的對象。這是因爲異常僅僅通過類型而不是通過值來匹配的。

class DevideByZero {
public:
	DevideByZero(const char *p);
	const char* description();
	//...
private:
	char *desp;
};

double Devide(double a, double b)
{
	if(abs(a) < std::numeric_limits<double>::epsilon())
		throw DevideByZero("The divisor is 0.");//提前檢測異常發生條件並拋出自定義異常
	return a/b;
}

void test()
{
	double x = 100, y = 20.5;
	try{
		cout << Devide(x,y) << endl; //可能拋出異常DevideByZero
	}
	catch(DevideByZero& ex){
		cerr << ex.description() << endl;
	}
}

異常拋出點可能深埋在底層軟件模塊內,而異常捕獲點常常在高層組件中。

在一個函數內儘量不要出現多個並列的try塊,也不要使用嵌套的try塊,否則不僅會導致程序結構複雜化,增加運行時的開銷,而且容易出現邏輯錯誤。

每一個try塊後必須至少跟一個catch塊。當異常拋出時,C++異常處理機制將從碰到的第一個catch塊開始匹配,直到找到一個類型符合的catch塊爲止,緊接着執行該catch塊內的代碼。當異常處理完畢後,將跳過後面一系列catch塊,接着執行後面的正常代碼。

由於異常處理機制採用類型匹配而不是值判斷,因此catch塊的參數可以沒有參數名稱,只需要參數類型,除非確實要使用那個異常對象。

異常的類型匹配規則

C++規定,當一個異常對象和catch子句的參數類型複合下列條件時,匹配成功:

  • 如果catch子句參數的類型就是異常對象的類型或其引用;
  • 如果catch子句參數類型時異常對象所屬類型的public基類或其引用。
  • 如果catch子句參數類型爲public基類指針,而異常對象爲派生類指針。
  • catch子句參數類型爲void *,而異常對象爲任何類型指針。
  • catch子句爲catch-all,即catch(...)

異常說明及其衝突

在使用了C++異常處理機制的環境中,應當使用函數異常說明,以告訴函數的調用者該函數可能拋出哪些類型的異常,以便用戶能夠編寫合適的異常處理器。

函數異常說明示例如下:

double Devide(double x, double y) throw(DevidedByZero); //(1)只可能拋出一種異常
bool func(const char *) throw(T1, T2, T3) //(2) 可能拋出3種異常
void g() throw() //(2) 不拋出任何異常
void k(); //(4)可能拋出任何異常,也可能不拋出任何異常

當異常拋出時局部對象如何釋放

當異常拋出時,異常處理機制保證:所有從trythrow語句之間構造起來的局部對象的析構函數將被調用(以與構造相反的順序),然後清退堆棧(就像函數正常退出那樣)。

如何使用好異常處理技術

如果不使用異常處理機制就能夠安全而高效地消除錯誤,那麼就不要使用異常處理。

catch塊的參數應當採用引用傳遞而不是值傳遞。原因之一:異常對象可能會在調用鏈中上溯好幾個層次才能遇到匹配的處理塊,顯然引用傳遞比值傳遞的效率高得多;原因二:這樣可以利用異常對象的多態性,因爲異常處理類型可能是多態類,你可以拋出一個異常對象的地址,那麼catch塊中的參數就應該是異常類型的指針。

在異常組合中,要合理安排異常處理的層次:一定要把派生類的異常捕獲放在基類異常捕獲的前面,否則派生類異常匹配永遠也不會執行到。

如果實在無法判斷到底會有什麼異常拋出,那就使用”一網打盡“策略:catch(void *)catch(...)。但是要記住:catch(void *)catch(...)必須放在異常組合的最後面,並且catch(void *)放在catch(...)的前面。

C++的標準異常

頭文件 異常類型
<exception> exception, bad_exception
<new> bad_alloc
<typeinfo> bad_cast, bad_typeid
<stdexcept> logic_error, runtime_error, domain_error, invalid_argument, length_error, out_of_range, range_error,overflow_error,underflow_error

RTTI

RTTI(Run-time Type Identification)

RTTI和虛函數並非一回事!實際上虛函數的動態綁定並沒有使用對象的type_info信息。

有了RTTI之後,就能夠在運行時查詢一個多態指針或引用指向的具體對象的類型了。爲了能夠在運行時獲得對象的類型信息type_info,C++增加了兩個運算符:typeiddynamic_cast<>

typeid運算符

typeid運算符和sizeof一樣是C++語言直接支持的,它以一個對象或者類型名作爲參數,返回一個匹配的const type_info對象,表明該對象的確切類型。

如果試圖用typeid來檢索NULL指針所指對象的類型信息

typeid(*p); //p==NULL

將拋出std::bad_typeid異常。

dynamic_cast<>運算符

可以看出,typeid()不具備可拓展性,因爲它返回一個對象的確切類型而不是基類型。一個派生類對象在語義上也應該是其基類型的對象(如果是public繼承),然而typeid()不具備這種能力。

dynamic_cast<dest_type>(src);

其中,dest_type就是轉換的目標類型,而src則是被轉換的目標(注意dynamic_cast<>可以用來轉換指針和引用,但是不能轉換對象)。如果運行時srcdest_type確實存在is-a關係,則轉換可進行;否則轉換失敗。

當目標類型是某種類型的指針(包括void*)時,如果轉換成功則返回目標類型的指針,否則返回NULL;當目標類型爲某種類型的引用時,如果成功則返回目標類型的引用,否則拋出std::bad_cast異常。

dynamic_cast<>只能用於多態類型對象(擁有虛函數或虛擬繼承),否則將導致編譯時錯誤。

dynamic_cast<>可以實現兩個方向的轉換:upcastdowncast

  • upcast:把派生類型的指針、引用轉換爲基類型的指針或引用(實際上這可以隱式的進行,不必顯式地轉換)。
  • downcast:把基類型的指針或引用轉換稱爲派生類型的指針或引用。如果這個基類型的指針或引用確實指向一個這種派生類的對象,那麼轉換就會成功;否則轉換就會失敗。

內存管理

有了malloc/free爲什麼還要new/delete

由於malloc()/free()是庫函數而不是運算符,不在編譯器控制權限之內,不能把調用構造函數和析構函數得任務強加給它們。因此,C++語言需要一個能夠完成動態內存分配和初始化工作的運算符new,以及一個能夠完成清理和釋放內存工作的運算符delete

class Obj{
public:
	Obj() {cout << "constructor" << endl;}
	~Obj() {cout << "destroy" << endl;}
	void Initialize(void) {cout << "initialize" << endl;}
	void Destroy(void) {cout << "destroy" << endl;}	
};

void usermallocfree(void)
{
	Obj *a = (Obj*)malloc(sizeof(Obj)); //申請動態內存
	a->Initialize(); //初始化
	//...
	a->Destroy(); //清除工作
	free(a); //釋放內存
}

void UseNewDelete(void)
{
	Obj *a = new Obj; //申請動態內存並調用構造函數來初始化
	//...
	delete a; //調用析構函數並且釋放內存
}

new有3種使用方式

plain newnothrow newplacement new

plain new/delete

字面意思,就是普通的new,也就是我們最常用的那種new,沒有任何附加成分。它們在<NEW>中是這樣定義的:

void * operator new(std::size_t) throw(std::bad_alloc);
void operator delete(void *) throw();

標準C++修改了new的語義,plain new在失敗後拋出標準異常std::bad_alloc而不是返回NULL

char *GetMemory(unsigned long size)
{
	char *p = new char[size];
	return p;
}

void main(void)
{
	try{
		char *p = GetMemory(1000000); //可能拋出bad_alloc異常
		//...
		delete p;
	}
	catch(const std::bad_alloc& ex){
		cout << ex.what() << endl;
	}
}

nothrow new/delete

顧名思義,nothrow new就是不拋出異常的運算符new形式,在失敗時返回NULL

void func(unsigned long length)
{
	unsigned char *p = new(nothrow) unsigned char[length];
	if(p==NULL) cout << "allocate failed!" << endl;
	//...
	delete []p;
}

palcement new/delete

placement意爲“放置”,這種new形式允許在一塊已經分配成功的內存上重新構造對象或者對象數組。顯然placement new不用擔心內存分配失敗,因爲它根本就不會分配內存,它所做的唯一一件事情就是調用對象的構造函數。語法如下:

type-name *q = new(p) type-name;

其中p就是已經分配成功的內存區的首地址,它被轉換爲目標類型的指針q,因此pq相等。

#include <new>
#include <iostream>

void main(void)
{
	using namespace std;
	char *p = new(nothrow) char[4]; //nothrow new
	if(p == NULL){
		cout << "allocate failed!" << endl;
		exit(-1);
	}
	//...
	long *q = new(p) long(1000); //placement new
	//...
	delete []p;//釋放內存 
}

placement new的主要用途就是:反覆使用一塊較大的動態分配成功的內存來構造不同類型的對象或者它們的數組。

char *p = new(nothrow) char[100]; //nothrow new
if(p == NULL){
	cout << "allocate failed!" << endl;
	exit(-1)
}

//...
long *q1 = new(p) long(88); //placement new; 不必擔心失敗
//...
int *q2 = new(p) int[100/sizeof(int)]; //placement new 數組
delete []p;

由於使用placement new構造起來的對象或其數組的大小並不一定等於原來分配的內存大小,因此在結束使用時需要注意防止內存泄漏,對於複合類型需要顯式調用析構函數。

char *p = new(nothrow) char[sizeof(ADT)+2]; //nothrow new
if(p == NULL){
	cout << "allocate failed!" << endl;
	exit(-1)
}

ADT *q = new(p) ADT; //placement new
//...
//delete q; //錯誤!不能在此處調用delete q;
q->ADT::~ADT(); //顯式調用析構函數
delete []p;
new/delete類型 plain nothrow placement
對象 new type-name;delete p; new(nothrow) type-name; delete p; new(p) type-name; delete p;
對象數組 new type-name[x]; delete []p; new(nothrow) type-name[x]; delete []p; new(p) typename[x]; delete[] p;

學習和使用STL

STL主要包括如下組件:I/O流、string類、容器類(Container)、迭代器(Iterator)、存儲分配器(Allocator)、適配器(Adapter)、函數對象(Functor)、泛型算法(Algorithm)、數值運算、國際化和本地化支持,以及標準異常類等。

在這裏插入圖片描述

頭文件 內容
<vector> 元素類型爲T的向量,包括了特化vector<bool>
<list> 元素類型爲T的雙向鏈表
<deque> 元素類型爲T的雙端隊列
<queue> 元素類型爲T的普通隊列,包括了priority_queue
<stack> 元素類型爲T的堆棧
<map> 元素類型爲T的映射
<set> 元素類型爲T的集合
<bitset> 布爾值的集合(實際上不是真正意義上的集合)
<hash_set> 元素類型爲Thash映射
<hash_set> 元素類型爲Thash集合

注意:stackqueuepriority_queue在概念上和接口上都不支持隨機訪問和遍歷,這是由它們的語義決定的,而不是由底層存儲方式決定的,因此沒有迭代器(所以它們才被叫做容器適配器而不是歸類爲容器類)。

泛型算法定義在頭文件<algorithm><utility>中。

迭代器定義在頭文件<iterator>中。

STL有一些專門爲數學運算設計的類和算法,定義在下表所示頭文件中:
|頭文件|內容|
|<complex>| 複數及其相關操作|
|<valarray>|數值向量及其相關操作|
|<numerics>|通用數學運算|
|<limits>|常用數值類型的極限值和精度等|

容器設計原理

由於紅黑樹(平衡二叉搜索樹的一種)在元素定位上的優異性能(O(log2N)),STL通常用它來實現關聯式容器。

在這裏插入圖片描述

迭代器

迭代器是爲了降低容器和泛型算法之間的耦合性而設計的,泛型算法的參數不是容器,而是迭代器。

迭代器屏蔽了底層存儲空間的不連續性,在上層使容器元素維持一種“邏輯連續”的假象。

指針代表真正的內存地址,即對象在內存中的存儲位置;而迭代器則代表元素在容器中的相對位置(當遍歷容器的時候,關聯式容器的元素也就具有了“相對位置”)。

泛型算法可以根據不同類別的迭代器所具有的不同能力來實現不同性能的版本,使得能力大的迭代器用於這些算法時具有更高的效率。

適配器

適配器往往是利用一種已有的比較通用的數據結構(通過組合而非繼承)來實現更加具體的、更加貼近實際應用的數據結構。

容器適配器stackqueuepriority_queue

迭代器適配器

  • 插入式迭代器包括back_insert_iteratorfront_insert_iteratorinsert_iterator。對一個back_insert_iterator執行賦值操作(operator=)就相當於對其綁定的容器執行push_back()操作。對一個front_insert_iterator執行賦值操作(operator=)就相當於對其綁定的容器執行push_front()操作。而對一個insert_iterator執行賦值操作(operator=)就相當於對其綁定的容器執行insert()操作。
  • 輸出流迭代器(ostream_iterator)則通過綁定一個ostream對象來完成批量輸出功能,即內部維護一個ostream對象,並將賦值操作(operator =)轉換爲該ostream對象的運算符operator <<的調用。
  • 輸入流迭代器(istream_iterator)則通過綁定一個istream對象來完成批量輸入功能,並將前進操作(++)轉換爲 istream 對象的運算符operator >>的調用。
  • 反向迭代器(Reverse Iterator)用於將一個指定的迭代器的迭代行爲反轉(前進變爲後退,後退變前進)。容器具有的rbegin()rend()方法返回的就是這種類型的迭代器。
list<int> li;
for(int k = 0; k < 10; k++){
	li.push_back(k);
}
copy(li.first(), li.end(), ostream_iterator<int>(cout, " "));

泛型算法

STL定義了一套豐富的泛型算法,可施行於容器或其他序列上,它們不依賴具體容器的類型和元素的數據類型。

迭代器就像算法和容器的中間人。作爲算法,它並不關心所操作的數據對象在容器中的什麼位置,也不必知道容器的類型甚至數據對象的類型,它所要做的工作就是“改變迭代器,並按照用戶指定的方式(即函數對象或謂詞,可以沒有),逐個地對迭代器指向的對象進行定製地操作。

STL提供的泛型算法主要有如下幾種:

  • 查找算法:如find()search()binary_search()find_if()等。
  • 排序算法:如sort()merge()等。
  • 數學計算:如accumulate()inner_product()partial_sum()等。
  • 集合運算:如set_union()set_intersection()includes()等。
  • 容器管理:如copy()replace()transform()remove()for_each()等。
  • 統計運算:如max()min()count()max_element()等。
  • 堆管理:如make_heap()push_heap()pop_heap()sort_heap()
  • 比較運算:如equal()等。

很多泛型算法都假定容器的元素類型定義了operator =()operator ==()operator !=()operator <()operator >()等函數,因此你有義務爲你的容器元素定義它們,否則泛型算法將採用元素類型的默認語義或者報錯。

STL提供了最常用的算法,但是有時候可能需要編寫自己的算法,應該儘可能與STL框架無縫結合。例如下面是基於STL框架實現的”折半“查找算法。

template<typename RandomAccessIterator, typename T>
RandomAccessIterator binary_search(RandomAccessIterator first, 
								   RandomAccessIterator last,
								   const T& value)
{
	RandomAccessIterator mid, not_found = last;
	while(first != last){
		mid = first + (last - first)/2;
		if(!(value<*mid) && !(*mid<value))
			return mid;
		if(value < *mid)
			last = mid;
		else
			first = mid + 1;
	}
	return not_found;
}

算法內部對迭代器類別(Category)的識別是通過Iterator Traits技術從傳入的迭代器對象中萃取出來的。

當容器的方法和泛型算法都可以完成一項工作時,選擇容器本身的方法。

STL使用心得

當元素的有序比搜索速度更重要時,應選用setmultisetmapmultimap。否則選用hash_sethash_maphash_multimap

往容器中插入元素時,若元素在容器中的順序無關緊要,請儘量加在最後面。若經常需要在序列容器的開頭和中間增加或刪除元素時,應該選用list

對關聯式容器而言,儘量不要使用C風格的字符串(即字符指針)作爲鍵值。如果非用不可,應顯式的定義字符串比較運算符,即operator<operator==operator<=等。

經典C/C++試題

1、計算sizeof表達式和strlen表達式的值。

char s1[] = ""; 
char s2[] = "Hello World"; 
char *p = s2; 
char *q = NULL; 
void *r = malloc(100); 

在這裏插入圖片描述

char s1[10] = {'m', 'o', 'b', 'i', 'l'};
char s2[20] = {'A', 'N', 'S', 'I', '\0', 'C', '+', '+'};
char s3[6] = {'I', 'S', 'O', 'C', '+', '+'};

cout << "strlen(s1) = " << strlen(s1) << endl; //5
cout << "strlen(s2) = " << strlen(s2) << endl; //4
cout << "strlen(s3) = " << strlen(s3) << endl; //不確定

上面的strlen是統計到\0截至,對於s1,數組後面的位置元素自動初始化爲\0。對於s3,輸出的strlen(s3)與存儲位置後面何時出現\0有關。

void Func(char str[100])
{
	cout << "sizeof(str) = " << sizeof(str) << endl; //4
}

2、寫出boolfloat、指針變量與“零值”比較的if語句。

if(flag)
if(!flag)

//精度要求根據應用要求而定
const float EPSILON = 1e-6;
if( (x >= -EPSILON) && (x <= EPSILON))

if(p == nullptr)
if(p != nullptr)

3、在C++程序中調用C編譯器編譯後的函數,爲什麼要加extern C

C++語言支持函數重載,C語言支持函數重載。函數被C++編譯器編譯和被C編譯器編譯後生成的內部名字是不同的。C++提供了C連接交換指定符號extern C來解決名字匹配問題(即二進制兼容問題)。

4、下面兩個輸出語句的結果是否相同?

double d = 100.25;
int x = d;
int *pInt = (int *)&d;

cout << "x = " << x << endl;
cout << "*pInt = " << *pInt << endl;

兩個輸出結果不相同。第一個結果爲100,xd的整數部分;第二個結果不是100,*pInt等於d的前4個字節的數值,而不是d的整數部分。

5、已知strcpy的原型爲char *strcpy(char *strDest, char *strSrc);,請編寫函數strcpy,並解釋爲何要返回char *

{
	assert((strDest != nullptr) && (strSrc != nullptr));
	char *address = strDest;
	while((*strDest++ = *strSrc++) != '\0')
		NULL;
	return address;
}

返回char *的返回值是爲了實現鏈式表達式。

int length = strlen(strcpy(strDest, "hello world"));

6、編寫String類的構造函數,析構函數和賦值函數

String::String(const char *str){
	if(str == nullptr){
		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(){
	delete[] m_data;
}

String::String(const String &other){
	int length = strlen(other.m_data);
	m_data = new char[length+1];
	strcpy(m_data, other.m_data);
}

String & String::operator =(const String& other){
	//檢查自賦值
	if(this != &other){
		char *temp = new char[strlen(other.m_data)+1];
		strcpy(temp, other.m_data);
		//釋放原有資源
		delete[] m_data;
		m_data = temp;
	}

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