C++編程指南續(4-5)
五、常量
常量是一種標識符,它的值在運行期間恆定不變。C語言用 #define來定義常量(稱為宏常量)。C++ 語言除了 #define外還可以用const來定義常量(稱為const常量)。
5.1 為什麼需要常量
如果不使用常量,直接在程序中填寫數字或字符串,將會有什麼麻煩?
(1) 程序的可讀性(可理解性)變差。程序員自己會忘記那些數字或字符串是什麼意思,用戶則更加不知它們從何處來、表示什麼。
(2) 在程序的很多地方輸入同樣的數字或字符串,難保不發生書寫錯誤。
(3) 如果要修改數字或字符串,則會在很多地方改動,既麻煩又容易出錯。
【規則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常量進行調試,但是不能對宏常量進行調試。
【規則5–2-1】在C++ 程序中只使用const常量而不使用宏常量,即const常量完全取代宏常量。
5.3 常量定義規則
【規則5–3-1】需要對外公開的常量放在頭文件中,不需要對外公開的常量放在定義文件的頭部。為便於管理,可以把不同模塊的常量集中存放在一個公共的頭文件中。
【規則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)。
六、 函數設計
函數是C++/C程序的基本功能單元,其重要性不言而喻。函數設計的細微缺點很容易導致該函數被錯用,所以光使函數的功能正確是不夠的。本章重點論述函數的接口設計和內部實現的一些規則。
函數接口的兩個要素是參數和返回值。C語言中,函數的參數和返回值的傳遞方式有兩種:值傳遞(pass by value)和指針傳遞(pass by pointer)。C++ 語言中多了引用傳遞(pass by reference)。由於引用傳遞的性質象指針傳遞,而使用方式卻象值傳遞,初學者常常迷惑不解,容易引起混亂,請先閱讀6.6節「引用與指針的比較」。
6.1 參數的規則
【規則6–1-1】參數的書寫要完整,不要貪圖省事只寫參數的類型而省略參數名字。如果函數沒有參數,則用void填充。
例如:
void SetValue(int width, int height); // 良好的風格
void SetValue(int, int); // 不良的風格
float GetValue(void); // 良好的風格
float GetValue(); // 不良的風格
【規則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」); // 參數順序顛倒
【規則6–1–3】如果參數是指針,且僅作輸入用,則應在類型前加const,以防止該指針在函數體內被意外修改。
例如:
void StringCopy(char *strDestination,const char *strSource);
【規則6–1–4】如果輸入參數以值傳遞的方式傳遞對象,則宜改用「const &」方式來傳遞,這樣可以省去臨時對象的構造和析構過程,從而提高效率。
【建議6–1–1】避免函數有太多的參數,參數個數盡量控制在5個以內。如果參數太多,在使用時容易將參數類型或順序搞錯。
【建議6–1–2】盡量不要使用類型和數目不確定的參數。
C標準庫函數printf是採用不確定參數的典型代表,其原型為:
int printf(const chat *format[, argument]…);
這種風格的函數在編譯時喪失了嚴格的類型安全檢查。
6.2 返回值的規則
【規則6–2-1】不要省略返回值的類型。
C語言中,凡不加類型說明的函數,一律自動按整型處理。這樣做不會有什麼好處,卻容易被誤解為void類型。
C++語言有很嚴格的類型安全檢查,不允許上述情況發生。由於C++程序可以調用C函數,為了避免混亂,規定任何C++/ C函數都必須有類型。如果函數沒有返回值,那麼應聲明為void類型。
【規則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誤導了使用者。
【規則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 函數內部實現的規則
不同功能的函數其內部實現各不相同,看起來似乎無法就「內部實現」達成一致的觀點。但根據經驗,我們可以在函數體的「入口處」和「出口處」從嚴把關,從而提高函數的質量。
【規則6–3-1】在函數體的「入口處」,對參數的有效性進行檢查。
很多程序錯誤是由非法參數引起的,我們應該充分理解並正確使用「斷言」(assert)來防止此類錯誤。詳見6.5節「使用斷言」。
【規則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]
【規則6–5-1】使用斷言捕捉不應該發生的非法情況。不要混淆非法情況與錯誤情況之間的區別,後者是必然存在的並且是一定要作出處理的。
【規則6–5–2】在函數的入口處,使用斷言檢查參數的有效性(合法性)。
【建議6–5–1】在編寫函數時,要進行反覆的考查,並且自問:「我打算做哪些假定?」一旦確定了的假定,就要使用斷言對假定進行檢查。
【建議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
對比上述三個示例程序,會發現「引用傳遞」的性質象「指針傳遞」,而書寫方式象「值傳遞」。實際上「引用」可以做的任何事情「指針」也都能夠做,為什麼還要「引用」這東西?
答案是「用適當的工具做恰如其分的工作」。
指針能夠毫無約束地操作內存中的如何東西,儘管指針功能強大,但是非常危險。就象一把刀,它可以用來砍樹、裁紙、修指甲、理髮等等,誰敢這樣用?
如果的確只需要借用一下某個對象的「別名」,那麼就用「引用」,而不要用「指針」,以免發生意外。比如說,某人需要一份證明,本來在文件上蓋上公章的印子就行了,如果把取公章的鑰匙交給他,那麼他就獲得了不該有的權利。
改變自己,從現在做起———–久館