本文原址:http://m.shnenglu.com/tiandejian/archive/2007/04/11/ECPP_03.html
第3項: 盡可能使用 const
const令人贊嘆之處就是:你可以通過它來指定一個語義上的約束(一個特定的不能夠更改的對象)這一約束由編譯器來保證。通過一個const,你可以告訴編譯器和其他程序員,你的程序中有一個數值需要保持恒定不變。不管何時,當你需要這樣一個數時,你都應該這樣做,這樣你便可以讓編譯器來協助你確保這一約束不被破壞。
const 關鍵字的用途十分廣泛。在類的外部,你可以定義全局的或者名字空間域的常量,也可以通過添加 static 關鍵字來定義文件、函數、或者程序塊域的對象。在類的內部,你可以使用它來定義靜態的或者非靜態的數據成員。對于指針,你可以制定一個指針是否是 const 的,其所指的數據是否是 const 的,或者兩者都是 const ,或者兩者都不是。
char greeting[] = "Hello";
char *p = greeting; // 非 const 指針,非 const 數據
const char *p = greeting; // 非 const 指針, const 數據
char * const p = greeting; // const 指針,非 const 數據
const char * const p = greeting; // const 指針, const 數據
這樣的語法看上去反復無常,實際上并不是這樣。如果 const 關鍵字出現在星號的左邊,那么指針所指向的就是一個常量;如果 const 出現在星號的右邊,那么指針本身就是一個常量;如果 const 同時出現在星號的兩邊,那么兩者就都是常量。
當所指向的為常量時,一些程序員喜歡把 const 放在類型之前;其他一些人則喜歡放在類型后邊,但要在星號的前邊。這兩種做法沒有什么本質的區別,所以下邊給出的兩個函數聲明的參數表實際上是相同的:
void f1(const Widget *pw); // f1 傳入一個指向 Widget 對象常量的指針
void f2(Widget const *pw); // f2 也一樣
由于這兩種形式在實際代碼中都會遇到,所以你都要適應。
STL 迭代器是依照指針模型創建的, 所以說一個 iterator 更加像一個指向 T* 的指針。把一個 iterator 聲明為 const 的更像是聲明一個 const 的指針(也就是聲明一個指向 T* const 的指針): iterator 不允許指向不同類型的內容,但是其所指向的內容可以被修改。如果你希望一個迭代器指向某些不能被修改的內容(也就是指向 const T* 的指針),此時你需要一個 const_iterator :
std::vector<int> vec;
...
const std::vector<int>::iterator iter = vec.begin();
// iter 就像一個 T* const
*iter = 10; // 正確,可以改變 iter 所指向的內容
++iter; // 出錯! Iter 是一個 const
std::vector<int>::const_iterator cIter = vec.begin();
// cIter 就像一個 const T*
*cIter = 10; // 出錯! *cIter 是一個 const
++cIter; // 正確,可以改變 cIter
const 在函數聲明方面還有一些強大的用途。在一個函數聲明的內部, const 可以應用在返回值、單個參數,對于成員函數,可以將其本身聲明為 const 的。
讓函數返回一個常量通常可以減少意外發生的可能,而且不用放棄考慮安全和效率問題。好比有理數乘法函數( operator* )的聲明,更多信息請參見第 24 項。
class Rational { ... };
const Rational operator*(const Rational& lhs, const Rational& rhs);
很多程序員在初次到這樣的代碼時都不會正眼看一下。為什么 operator* 要返回一個 c onst 對象呢?這是因為如果不是這樣,客戶端將會遇到一些不愉快的狀況,比如:
Rational a, b, c;
...
(a * b) = c; // 調用 operator= 能返回一個 a*b !
我不知道為什么一些程序員會企圖為兩個數 的乘積賦值,但是我確實知道好多程序員的初衷并非如此。他們也許僅僅在錄入的時候出了個小差錯(他們的本意也許是一個布爾型的表達式):
if (a * b = c) ... // 噢 … 本來是想進行一次比較!
顯而易見,如果 a 和 b 是內建數據類型,那么這樣的代碼就是非法的。避免與內建數據類型不必要的沖突,這是一個優秀的用戶自定義類型的設計標準之一(另請參見第 18 項),而允許為兩數乘積賦值這讓人看上去就很不必要。聲明 operator* 函數時如果讓其返回一個 const 型數據則可以避免這一沖突,這便是要這樣做的原因所在。
const 的參數沒有什么特別新鮮的——它們與局部 const 對象的行為基本一致,你在必要的時候要盡可能使用它們。除非你需要更改某個參數或者局部對象,其余的所有情況最好都聲明為 const 。這僅僅需要你多打六個字母,但是它可以使你從惱人的錯誤(比如我們剛才見到的“我本想打‘ == ’但是卻打了‘ = ’”)中解放出來。
const 成員函數
對成員函數使用 const 的目的是指明這些成員函數可以被 const 對象調用。這一類成員函數是很重要的,首先,它們使得類的接口更加易于理解。很有必要了解哪些函數可以修改而哪些不可以。其次,這些函數可以與 const 對象協同工作。這對于高效編碼是十分重要的一方面,這是由于(將在第 20 項中展開解釋)提高 C++ 程序性能的一條最基本的途徑就是:傳遞對象的 const 引用。使用這一技 術需要一個前提:這就是首先要有 const 成員函數存在,并且它們用于處理之前生成的 const 對象。
如果若干成員函數之間的區別僅僅為“是否是 const 的”,那么它們也可以被重載。很多人都忽略了這一點,但是這是 C++ 重要特征之一。請觀察下面的代碼,這是一個文字塊的類:
class TextBlock {
public:
...
const char& operator[](std::size_t position) const
// operator[] 用于返回相應位置的字符
{ return text[position]; } // 返回一個 const 對象
char& operator[](std::size_t position)
// operator[] 用于返回相應位置的字符
{ return text[position]; } // 返回一個非 const 對象
private:
std::string text;
};
TextBlock 的 operator[] 可以這樣使用:
TextBlock tb("Hello");
std::cout << tb[0]; // 調用非 const 的 TextBlock::operator[]
const TextBlock ctb("World");
std::cout << ctb[0]; // 調用 const 的 TextBlock::operator[]
順便說一下,在真實的程序中, const 對象在大多數情況下都以“通過指針傳遞”或“引用一個 const ”的形式出現。 上面的 ctb 的例子純粹是人為的,而下面的例子在真實狀況中常會出現:
void print(const TextBlock& ctb) // 在這個函數中 ctb 是 const 的
{
std::cout << ctb[0]; // 調用 const 的 TextBlock::operator[]
...
}
通過對 operator[] 的重載以及為每個版本提供不同類型的返回值,你便可以以不同的方式處理 const 的或者非 const 的 TextBlock :
std::cout << tb[0]; // 正確:讀入一個非 const 的 TextBlock
tb [0] = 'x'; // 正確:改寫一個非 const 的 TextBlock
std::cout << ctb[0]; // 正確:讀入一個 const 的 TextBlock
ctb [0] = 'x'; // 錯誤 ! 不能改寫 const 的 TextBlock
請注意,這一錯誤只與所調用的 operator[] 的返回值的類型有關,如果僅僅調用 operator[] 本身則不會出現任何問題。錯誤出現在:企圖為一個 const char& 賦值,而 const char& 則是 operator[] 的 const 版本的返回值類型。
同時還要注意的是,非 const 的 operator[] 的返回值類型是一個 char 的引用,而不是 char 本身。如果 operator[] 真的簡單的返回一個 char ,那么下面的語句將不能正確編譯:
這是因為,企圖修改一個返回內建數據類型的函數的返回值根本都是非法的。即使假設這樣做合法,而 C++ 是 通過傳值返回對象的,所修改的僅僅是由 tb.text[0] 復制出的一份副本,而不是 tb.text[0] 本身,你不會得到預期的效果。
讓我們暫停一小會兒,來考慮一下這里邊的哲學問題。把一個成員函數聲明為 const 的有什么涵義呢?這里有兩個流行的說法:按位恒定(也可叫做物理恒定)和邏輯恒定。
按位 恒定陣營堅信:當且僅當一個成員函數對于所有對象的數據成員( static 數據成員除外)都不做出改動時,才需要將這一成員函數聲明為 const 的,換句話說,將成員函數聲明為 const 的條件是:成員函數不對對象內部做任何的改動。按位恒定的好處之一就是,它使得錯誤檢查便得更輕松:編譯器僅需要查找對數據成員的賦值。實際上,按位恒定就是 C++ 對于恒定的定義,如果一個 const 的成員函數調用了某個對象,那么即使該對象擁有非靜態數據成員,其所有數據成員也都是不可修改的。
不幸的是,大多數不完全是 const 的成員函數也可以通過按位恒定的檢驗。在特定的情況下,如果一個成員函數頻繁的修改一個指針所指的位置,那么我們說它就不是一個 const 的成員函數。但是只要這個指針存在于一個對象中,這個函數就是按位恒定的,這時候編譯器不會報錯。這樣會導致編成的行為不符合常規習慣。比如說,我們手頭有一個類似于 TextBlock 的類,其中保存著 char* 類型的數據而不是 string ,因為這段代碼有可能要與一些 C 語言的 API 交互,但是 C 語言中沒有 string 對象一說。
class CTextBlock {
public:
...
char& operator[](std::size_t position) const
// operator[] 不恰當的 (但是符合按位恒定規則)定義方法
{ return pText[position]; }
private:
char *pText;
};
盡管 operator[] 返回一個對對象內部數據的引用,這個類仍(不恰當地)將其聲明為 const 的 成員函數(第 28 項將深入討論這個問題)。先忽略這個問題,請注意 operator[] 的實現中并沒有以任何形式修改 pText 。于是編譯器便會欣然接受這樣的做法,畢竟,所有的編譯器所檢查的是“代碼是否符合按位恒定規則”。但是請觀察,在編譯器的縱容下,還會有什么樣的事情發生:
const CTextBlock cctb("Hello");// 聲明對象常量
char *pc = &cctb[0]; // 調用 const 的 operator[]
// 從而得到一個指向 cctb 中數據的指針
*pc = 'J'; // cctb 現在的值為 "Jello"
當你創建了一個包含具體值的對象常量后,你僅僅通過對其調用 const 的成員函數,就可以改變它的值!這顯然是有問題的。
輯恒定應運而生。堅持這一宗旨的人們爭論道,一個 const 的成員函數可能對其調用的對象內部做出改動,但是僅僅以客戶端無法察覺的方式進行。比如說,你的 CTextBlock 類可能需要保存文字塊的長度,以便在需要的時候調用:
class CTextBlock {
public:
...
std::size_t length() const;
private:
char *pText;
std::size_t textLength; // 最后一次計算出的文字塊長度
bool lengthIsValid; // 當前長度是否可用
};
std::size_t CTextBlock::length() const
{
if (!lengthIsValid) {
textLength = std::strlen(pText); // 錯誤!不能在 const 成員函數中
lengthIsValid = true; // 對 textLength 和 lengthIsValid 賦值
}
return textLength;
}
以上 length 的實現絕不是按位恒定的。這是因為 textLength 和 lengthIsValid 都可以改動。盡管看上去它應該對于 CTextBlock 對象常量可用,但是編譯器不答應。編譯器始終堅持遵守按位恒定。那么該怎么辦呢?
解決方法很簡單:利用 C++ 中 與 const 相關的靈活性,使用可變的( mutable )數據成員。 mutable 可以使非靜態數據成員不受按位恒定規則的約束:
class CTextBlock {
public:
...
std::size_t length() const;
private:
char *pText;
mutable std::size_t textLength;// 這些數據成員在任何情況下均可修改
mutable bool lengthIsValid; // 在 const 成員函數中也可以
};
std::size_t CTextBlock::length() const
{
if (!lengthIsValid) {
textLength = std::strlen(pText); // 現在可以修改了
lengthIsValid = true; // 同上
}
return textLength;
}
避免 const 與非 const 成員函數之間的重復
mutable 對于“我不了解按位恒定”的情況不失為一個良好的解決方案,但是它對于所有的 const 難題并不能做到一勞永逸。舉例說, TextBlock (以及 CTextBlock )中的 operator[] 不僅僅返回一個對恰當字符的引用,同時還要進行邊界檢查、記錄訪問信息,甚至還要進行數據完整性檢測。如果將所有這些統統放在 const 或非 const 函數(我們現在會得到過于冗長的隱式內聯函數,不過不要驚慌,在第 30 項中這個問題會得到解決)中,看看我們會得到什么樣的龐然大物:
class TextBlock {
public:
...
const char& operator[](std::size_t position) const
{
... // 邊界檢查
... // 記錄數據訪問信息
... // 確認數據完整性
return text[position];
}
char& operator[](std::size_t position)
{
... // 邊界檢查
... // 記錄數據訪問信息
... // 確認數據完整性
return text[position];
}
private:
std::string text;
};
噢!天哪,這讓人頭疼:重復代碼,以及隨之而來的編譯時間增長、維護成本增加、代碼膨脹、等等……當然,像邊界檢查這一類代碼是可以移走的,它們可以單獨放在一個成員函數(當然是私有的)中,然后讓這兩個版本的 operator[] 來調用它,但是你的代碼仍然有重復的函數調用,以及重復的 return 語句。
對于 operator[] 你真正需要的是:一次實現,兩次使用。也就是說,你需要一個版本的 operator[] 來調用另一個。這樣便可以通過轉型來消去函數的恒定性。
通常情況下轉型是一個壞主意,后邊我將專門用一項來告訴你為什么不要使用轉型(第 21 項),但是代碼重復也不會讓人感到有多輕松。在這種情況下, const 版的 operator[] 與非 const 版的 operator[] 所做的事情完全相同,不同的僅僅是它的返回值是 const 的。通過轉型來消去返回值的恒定性是安全的,這是因為任何人調用這一非 const 的 operator[] 首先必須擁有一個非 const 的對象,否則它就不能調用非 const 函數。所以盡管需要一次轉型,在 const 的 operator[] 中調用非 const 版本,可以安全地避免代碼重復。下面是實例代碼,讀完后邊的文字解說你會更明了。
class TextBlock {
public:
...
const char& operator[](std::size_t position) const // 同上
{
...
...
...
return text[position];
}
char& operator[](std::size_t position) // 現在僅調用 const 的 op[]
{
return
const_cast<char&>( // 通過對 op[] 的返回值進行轉型,消去 const ;
static_cast<const TextBlock&>(*this)// 為 *this 的類型添加 const ;
[position]; // 調用 const 版本的 op[]
);
}
...
};
就像你所看到的,上面的代碼進行了兩次轉型,而不是一次。我們要讓非 const 的 operator[] 去調用 const 版本的,但是如果在非 const 的 operator[] 的內部,我們只調用 operator[] 而不標明 const ,那么函數將對自己進行遞歸調用。那將是成千上萬次的毫無意義的操作。為了避免無窮遞歸的出現,我們必須要指明我們要調用的是 const 版本的 operator[] ,但是手頭并沒有直接的辦法。我們可以用 *this 從 TextBlock& 轉型到 const TextBlock& 來取代。是的,我們使用了一次轉型添加了一個 const !這樣我們就進行了兩次轉型:一次為 *this 添加了 const (于是對于 operator[] 的調用將會正確地選擇 const 版本),第二次轉型消去了 const operator[] 返回值中的 const 。
添加 const 的那次轉型是為了保證轉換工作的安全性(從一個非 const 對象轉換為一個 const 的),這項工作的關鍵字是 static_cast 。消去 const 的工作只可以通過 const_cast 來完成,所以在這里我們實際上并沒有其他的選擇。(從技術上講,我們有。 C 語 言風格的轉型在這里也能工作,但是,就像我在第 27 項中所講的,這一類轉型在很多情況下都不是好的選擇。如果你對于 static_cast 和 const_cast 還不熟悉,第 27 項中有詳細的介紹。)
在眾多的示例中,我們最終選擇了一個運算符來進行演示,因此上面的語法顯得有些古怪。這些代碼可能不會贏得任何選美比賽,但是通過以 const 版本的形式實現非 const 版本的 operator[] ,可以避免代碼重復,這正是我們所期望的。為達到這一目標而寫下看似笨拙的代碼,這樣做是否值得全看你的選擇,但是,以 const 版本的形式來實現非 const 的成員函數——了解這一技術肯定是值得的。
更值得你了解的是按反方向完成上面的工作——通過讓 const 版本的函數調用非 const 版本來避免代碼重復——一定不要這樣做。請記住,一個 const 成員函數保證其對象永遠不會更改其邏輯狀態,但是一個非 const 的成員函數并沒有這一類的保證。如果你在一個 const 函數中調用了一個非 const 函數,曾保證不會被改動的對象就有被修改的風險。這就是為什么說讓一個 const 函數調用一個非 const 函數是錯誤的:對象有可能被修改。實際上,為了使代碼能夠得到編譯,你還需要使用一個 const_cast 來消去 *this 的 const 屬性,顯然這是不必要的麻煩。上一段中相反的調用次序才是安全的:非 const 成員函數可以對一個對象做任何想做的事情,因此調用一個 const 成員函數不會帶來任何風險。這就是為什么 static_cast 在沒有與 const 相關的危險的情況下可以正常工作的原因。
就像本項最開始所說的, const 是一個令人贊嘆的東西。對于指針和迭代器,以及指針、迭代器和引用所涉及的對象,函數的參數和返回值,局部變量,成員函數來說, const 都是一個強大的伙伴。只要可能就可以使用它。你會對你所做的事情感到高興的。
需要記住的
l 將一些東西聲明為 const 的可以幫助編譯器及時發現用法上的錯誤。 const 針對對象作用于所有的作用域,針對函數參數和返回值、成員函數作用于整體。
l 編譯器嚴格遵守按位恒定規則,但是你應該在需要時應用邏輯恒定。
l 當 const 和非 const 成員函數的實現在本質上相同時,可以通過使用一個非 const 版本來調用 const 版本來避免代碼重復。