對比於C語言的函數,C++增加了重載(overloaded)、內聯(inline)、const和virtual四種新機制。其中重載和內聯機制既可用於全局函數也可用於類的成員函數,const與virtual機制僅用於類的成員函數。
重載和內聯肯定有其好處纔會被C++語言採納,但是不可以當成免費的午餐而濫用。本章將探究重載和內聯的優點與侷限性,說明什麼情況下應該採用、不該採用以及要警惕錯用。
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隱式類型轉換導致重載函數產生二義性
成員函數的重載、覆蓋(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); }
};
有一些參數的值在每次函數調用時都相同,書寫這樣的語句會使人厭煩。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.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.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不應該出現在函數的聲明中)。
C++ 語言中的重載、內聯、缺省參數、隱式轉換等機制展現了很多優點,但是這些優點的背後都隱藏着一些隱患。正如人們的飲食,少食和暴食都不可取,應當恰到好處。我們要辨證地看待C++的新機制,應該恰如其分地使用它們。雖然這會使我們編程時多費一些心思,少了一些痛快,但這纔是編程的藝術。
構造函數、析構函數與賦值函數是每個類最基本的函數。它們太普通以致讓人容易麻痹大意,其實這些貌似簡單的函數就象沒有頂蓋的下水道那樣危險。
每個類只有一個析構函數和一個賦值函數,但可以有多個構造函數(包含一個拷貝構造函數,其它的稱爲普通構造函數)。對於任意一個類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; // 用於保存字符串
};
作爲比C更先進的語言,C++提供了更好的機制來增強程序的安全性。C++編譯器具有嚴格的類型安全檢查功能,它幾乎能找出程序中所有的語法問題,這的確幫了程序員的大忙。但是程序通過了編譯檢查並不表示錯誤已經不存在了,在“錯誤”的大家庭裏,“語法錯誤”的地位只能算是小弟弟。級別高的錯誤通常隱藏得很深,就象狡猾的罪犯,想逮住他可不容易。
根據經驗,不少難以察覺的程序錯誤是由於變量沒有被正確初始化或清除造成的,而初始化和清除工作很容易被人遺忘。Stroustrup在設計C++語言時充分考慮了這個問題並很好地予以解決:把對象的初始化工作放在構造函數中,把清除工作放在析構函數中。當對象被創建時,構造函數被自動執行。當對象消亡時,析構函數被自動執行。這下就不用擔心忘了對象的初始化和清除工作。
構造函數與析構函數的名字不能隨便起,必須讓編譯器認得出纔可以被自動執行。Stroustrup的命名方法既簡單又合理:讓構造函數、析構函數與類同名,由於析構函數的目的與構造函數的相反,就加前綴‘~’以示區別。
除了名字外,構造函數與析構函數的另一個特別之處是沒有返回值類型,這與返回值類型爲void的函數不同。構造函數與析構函數的使命非常明確,就象出生與死亡,光溜溜地來光溜溜地去。如果它們有返回值類型,那麼編譯器將不知所措。爲了防止節外生枝,乾脆規定沒有返回值類型。(以上典故參考了文獻[Eekel, p55-p56])
構造函數有個特殊的初始化方式叫“初始化表達式表”(簡稱初始化表)。初始化表位於函數參數表之後,卻在函數體 {} 之前。這說明該表裏的初始化工作發生在函數體內的任何代碼被執行之前。
構造函數初始化表的使用規則:
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) 數據成員在函數體內被初始化
構造從類層次的最根處開始,在每一層中,首先調用基類的構造函數,然後調用成員對象的構造函數。析構則嚴格按照與構造相反的次序執行,該次序是唯一的,否則編譯器將無法自動執行析構過程。
一個有趣的現象是,成員對象初始化的次序完全不受它們在初始化表中次序的影響,只由成員對象在類中聲明的次序決定。這是因爲類的聲明是唯一的,而類的構造函數可以有多個,因此會有多個不同次序的初始化表。如果成員對象按照初始化表的次序進行構造,這將導致析構函數無法得到唯一的逆序。[Eckel, p260-261]
// 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;
}
由於並非所有的對象都會使用拷貝構造函數和賦值函數,程序員可能對這兩個函數有些輕視。請先記住以下的警告,在閱讀正文時就會多心:
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返回的將是垃圾。
如果我們實在不想編寫拷貝構造函數和賦值函數,又不允許別人使用編譯器生成的缺省函數,怎麼辦?
偷懶的辦法是:只需將拷貝構造函數和賦值函數聲明爲私有函數,不用編寫代碼。
例如:
class A
{ …
private:
A(const A &a); // 私有的拷貝構造函數
A & operate =(const A &a); // 私有的賦值函數
};
如果有人試圖編寫如下程序:
A b(a); // 調用了私有的拷貝構造函數
b = a; // 調用了私有的賦值函數
編譯器將指出錯誤,因爲外界不可以操作A的私有函數。
基類的構造函數、析構函數、賦值函數都不能被派生類繼承。如果類之間存在繼承關係,在編寫上述基本函數時應注意以下事項:
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;
}
有些C++程序設計書籍稱構造函數、析構函數和賦值函數是類的“Big-Three”,它們的確是任何類最重要的函數,不容輕視。
也許你認爲本章的內容已經夠多了,學會了就能平安無事,我不能作這個保證。如果你希望吃透“Big-Three”,請好好閱讀參考文獻[Cline] [Meyers] [Murry]。
對象(Object)是類(Class)的一個實例(Instance)。如果將對象比作房子,那麼類就是房子的設計圖紙。所以面向對象設計的重點是類的設計,而不是對象的設計。
對於C++程序而言,設計孤立的類是比較容易的,難的是正確設計基類及其派生類。本章僅僅論述“繼承”(Inheritance)和“組合”(Composition)的概念。
注意,當前面向對象技術的應用熱點是COM和CORBA,這些內容超出了C++教材的範疇,請閱讀COM和CORBA相關論著。
如果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的功能和屬性。
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派生而成
一隻公雞使勁地追打一隻剛下了蛋的母雞,你知道爲什麼嗎?
因爲母雞下了鴨蛋。
很多程序員經不起“繼承”的誘惑而犯下設計錯誤。“運行正確”的程序不見得是高質量的程序,此處就是一個例證。
看到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關鍵字只能放在函數聲明的尾部,大概是因爲其它地方都已經被佔用了。
程序的時間效率是指運行速度,空間效率是指程序佔用內存或者外存的狀況。
全局效率是指站在整個系統的角度上考慮的效率,局部效率是指站在模塊或函數角度上考慮的效率。
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-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等工具進行代碼審查。