const令人贊嘆之處就是:你可以通過它來指定一個語義上的約束——一個特定的不能夠更改的對象——這一約束由編譯器來保證。通過一個const,你可以告訴編譯器和其他程序員,你的程序中有一個數值需要保持恒定不變。不管何時,當你需要這樣一個數時,你都應確保對這一點做出聲明,因為這樣你便可以讓編譯器來協助你確保這一約束不被破壞。
const關鍵字的用途十分廣泛。在類的外部,你可以利用它定義全局的或者名字空間域的常量(參見條目2),也可以通過添加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*要返回一個const對象呢?這是因為如果不是這樣,客戶端將會遇到一些不愉快的狀況,比如:
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
{ return text[position]; } // operator[] :用于const對象
char& operator[](std::size_t position)
{ return text[position]; } // operator[] :用于非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可以這樣操作*this的原因:這里不存在const相關的危險。
就像本條目一開始所說的,const是一個令人贊嘆的東西。對于指針和迭代器,以及對于指針、迭代器和引用所涉及的對象,對于函數的參數和返回值,對于局部變量,以及對于成員函數來說,const都是一個強大的伙伴。盡可能去利用它。你一定不會后悔。
時刻牢記
l 將一些東西聲明為const可以幫助編譯器及時發現用法上的錯誤。const可以用于各個領域,包括任意作用域的對象、函數參數和返回值、成員函數。
l 編譯器嚴格遵守按位恒定規則,但是你應該在需要時應用邏輯恒定。
l 當const和非const成員函數的實現在本質上相同時,可以通過使用一個非const版本來調用const版本來避免代碼重復。