C++函數的高級特性
對比于 C 語言的函數,C++增加了重載(overloaded)、內聯(inline)、const 和 virtual四種新機制。其中重載和內聯機制既可用于全局函數也可用于類的成員函數,const 與virtual 機制僅用于類的成員函數。
重載和內聯肯定有其好處才會被 C++語言采納,但是不可以當成免費的午餐而濫用。
本章將探究重載和內聯的優點與局限性,說明什么情況下應該采用、不該采用以及要警惕錯用。
函數重載的概念
重載的起源
自然語言中,一個詞可以有許多不同的含義,即該詞被重載了。人們可以通過上下文來判斷該詞到底是哪種含義。“詞的重載”可以使語言更加簡練。例如“吃飯”的含義十分廣泛,人們沒有必要每次非得說清楚具體吃什么不可。別迂腐得象孔已己,說茴香豆的茴字有四種寫法。
在 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 看來,隱藏規則似乎很愚蠢。但是隱藏規則至少有兩個存在的理由:
? 寫語句 pd->f(10)的人可能真的想調用 Derived::f(char *)函數,只是他誤將參數
寫錯了。有了隱藏規則,編譯器就可以明確指出錯誤,這未必不是好事。否則,編
譯器會靜悄悄地將錯就錯,程序員將很難發現這個錯誤,流下禍根。
? 假如類 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++語言
采用參數的缺省值使書寫變得簡潔(在編譯時,缺省值由編譯器自動插入)。
參數缺省值的使用規則:
z 【規則 8-3-1】參數缺省值只能出現在函數的聲明中,而不能出現在定義體中。
例如:
void Foo(int x=0, int y=0); // 正確,缺省值出現在函數的聲明中
void Foo(int x=0, int y=0) // 錯誤,缺省值出現在函數的定義體中
{
…
}
為什么會這樣?我想是有兩個原因:一是函數的實現(定義)本來就與參數是否有缺省值無關,所以沒有必要讓缺省值出現在函數的定義體中。二是參數的缺省值可能會改動,顯然修改函數的聲明比修改函數的定義要方便。
z 【規則 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 不應該出現在函數的聲明中)。