C++關鍵字的思考
本章內容:
1 關鍵字的相關理解
1.1 const關鍵字
1.2 static關鍵字
1.3 非局部變量的初始化順序
1.4 非局部變量的銷毀順序
1 關鍵字的相關理解
- 在C++中,關鍵字const和static非常讓人困惑。這兩個關鍵字有很多的含義,每種用法都很微妙,下面內容將講解各種具體的用法。
1.1 const關鍵字
-
const
是constant
的縮寫,指保持不變的量。編譯器會執行這一要求,任何嘗試改變常量的行為都會當做錯誤處理。此外,當啟用了優化時,編譯器可以利用此信息生成更好的代碼。關鍵字const
有兩種相關的用法,可以用這個關鍵字標記變量或者參數,也可以用其標記方法。本小節將討論這兩種含義。
1.1.1 const變量和參數
-
可以使用
const
來“保護”變量不被修改。這個關鍵字的一個重要用法是替換#define
來定義常量。這是const
最直接的應用。例如,可以這樣聲明常量PI
:const double PI = 3.141592653589792;
可以將任何變量標記為
const
,包括全局變量和類成員數據。-
還可以使用
const
指定函數或者方法的參數保持不變。例如,下面的函數接受一個const
參數。在函數體內不能修改整數param
。如果試圖修改這個變量,編譯器將會生成一個錯誤。void func(const int param) { // 不允許修改param... }
下面將討論兩種特殊的
const
變量或者參數:const
指針和const
引用。
(1) const指針
-
當變量通過指針包含一層或者多層間接取值時,
const
的應用變得十分微妙。考慮如下代碼:int* pIP;//(1) pIP = new int[10];//(2) pIP[4] = 5;//(3)
假定要將
const
應用到pIP
。暫時不考慮這么做有沒有作用,只考慮這樣做意味著什么。是想阻止修改pIP
變量本身,還是阻止修改pIP
所指向的值?-
為了阻止修改
pIP
所指向的值(第三行),可在pIP
的聲明中這樣添加關鍵字const
:const int* pIP;(1) pIP = new int[10];//(2) pIP[4] = 5;//(3) 編譯出錯!
現在無法修改
pIP
所指向的值。-
下面是在語義上等價的另一種方法:
int const* pIP;(1) pIP = new int[10];//(2) pIP[4] = 5;//(3) 編譯出錯!
將
const
放在int
前面還是后面并不影響其功能。-
如果要將
pIP
本身標記為const
(而不是pIP
所指向的值),可以這樣做:int* const pIP = nullptr;//(1) pIP = new int[10];//(2) 編譯出錯! pIP[4] = 5;//(3) 錯誤:不能為空指針賦值!
-
現在
pIP
本身無法修改,編譯器要求在聲明pIP
時就執行初始化,可以用前面的代碼中的nullptr
,也可以是用新分配的內存,如下所示:int* const pIP = new int[10]; pIP[4] = 5;
-
還可以將指針和所指的值全部標記為
const
,如下所示:const int* const pIP = nullptr;
-
下面是另一種等價的語法:
int const* const pIP = nullptr;
-
盡管這些語法看上去有點混淆,但規則實際上很簡單:
const
關鍵字應用于直接位于它左側的任何內容,再次思考這一行:int const* const pIP = nullptr;
從左到右,第一個
const
直接位于int
的右邊,因此const
應用到pIP
所指的int
。從而指定無法修改pIP
所指向的值。第二個const
直接位于*的右邊,因此,它應用于指向int
的指針,也就是pIP
變量。因此,無法修改pIP
(指針)本身。-
這一條規則由于一個例外而變得令人費解:第一個
const
可以出現在變量前面,如下所示:const int* const pIP = nullptr;
這個“異常的”語法比其他語法更加常見。
-
可以將這個規則引用到任意層次的間接取值,例如:
const int* const* const* const pIP = nullptr;
注意:另一種易于記憶的,用于指出復雜變量聲明的規則:從右向左讀,考慮示例int* const pIP;從右向左讀這條語句,就可以知道pIP
是一個指向int
的const
指針。另一方面,int const* pIP讀作pIP
是一個指向const int
的指針。
(2) const引用
const
應用于引用通常比它應用于指針更簡單:首先,引用默認為const
,無法改變引用所指的對象。因此,無需顯示地將引用標記為const
。其次,無法創建一個引用的引用,所以引用通常只有一層間接取值。獲取多層間接取值的唯一方法是創建指針的引用。-
因此,C++程序員提到“
const
引用”時,含義如下所示:int z; const int& zRef = z; zRef = 4; // 編譯出錯!
由于將
const
引用到int
,因此無法對zRef
賦值,如前所示。記住,const int& zRef
等價于int const& zRef
。然而需注意,將zRef
標記為const
對z
沒有影響。仍然可以修改z
的值。具體做法是直接改變z
,而不是通過引用。-
const
引用通常用作參數,這非常有用。如果為了提高效率,想按引用傳遞某個值,但不想修改這個值,可將其標記為const
引用。例如:void doSomething(const BigClass& arg) { // do something here... }
(2) const方法
-
常量對象的值不能改變。如果使用常量對象,常量對象的引用和指向常量對象的指針,編譯器將不允許調用對象的任何方法,除非這些方法承諾不改變任何數據成員。為了確保方法不改變數據成員,可以使用
const
關鍵字來標記方法本身。下面的SpreadsheetCell
類包含了用const
標記的不改變任何數據成員的方法。Class SpreadsheetCell { public: double getValue() const; const string& getString() const; };
-
const
規范是方法原型的一部分,必須放在方法的定義中:double SpreadsheetCell::getValue() const { return mValue; } const string& SpreadsheetCell::getString() const { return mString; }
將方法標記為
const
,就是與客戶代碼立下契約,承諾不會在方法內改變對象內部的值。如果將實際上修改了數據成員的方法聲明為const
,編譯器將會報錯。不能將靜態方法聲明為const
,因為這個是多余的,靜態方法沒有類的實例,因此不能改變類內部數據成員的值。const
工作原理:是將方法內的數據成員都標記為const
引用,因此如果試圖修改數據成員,編譯器會報錯。-
非
const
對象可以調用const
方法和非const
方法。然而,const
對象只能調用const
方法,下面是一些示例:SpreadsheetCell myCell(5); cout << myCell.getValue() << endl; // OK myCell.setString("6"); // OK const SpreadsheetCell& anotherCell = myCell; cout << anotherCell.getValue() << endl; // OK anotherCell.setString("6"); // 編譯錯誤!
應該養成習慣,將不修改對象的所有方法聲明為
const
,這樣就可以在程序中引用const
對象。注意const
對象也會被銷毀,它們的析構函數也會被調用,因此不應該將析構函數標記為const
。
mutable數據成員
-
有時編寫的方法“邏輯上”是
const
,但是碰巧改變了對象的數據成員。這個改動對于用戶可見的數據沒有任何影響,但在技術上確實做了改動,因此編譯器不允許將這個方法聲明為const
。例如,假設電子表格應用程序要獲取數據的讀取頻率。完成這個任務的基本辦法是在SpreadsheetCell
類中加入一個計數器,計算getValue()
和getString()
調用的次數。遺憾的是,這樣做使編譯器認為這些方法是非const
的,這并非你的本意。解決方法是將計數器變量設置為mutable
,告訴編譯器在const()
方法中允許改變這個值。下面是新的SpreadsheetCell
類的定義:class SpreadsheetCell { private: double mValue; string mString; mutable int mNumAccesses = 0; };
-
下面是
getValue()
和getString()
的定義:double SpreadsheetCell::getValue() const { mNumAccesses++; return mValue; } const string& SpreadsheetCell::getString() const { mNumAccesses++; return mString; }
1.1.2 constexpr關鍵字
-
C++一直存在常量表達式的概念,在某些情況下需要常量表達式。例如,當定義數組時,數組的大小就必須是一個常量表達式。由于這一限制,下面的代碼在C++中是無效的:
const int getArraySize() { return 32; } int main(void) { int myArray[getArraySize()]; // 在C++中無效 return 0; }
-
可以使用
constexpr
關鍵字重新定義getArraySize()
函數,把它變成常量表達式。常量表達式在編譯時計算。(constexpr
是C++11
中的內容)constexpr int getArraySize() { return 32; } int main(void) { int myArray[getArraySize()]; // OK return 0; }
-
甚至可以這樣寫:
int myArray[getArraySize() + 1]; // OK
將函數聲明為constexpr
對函數的行為施加了一些限制,因為編譯器必須在編譯期間對constexpr
函數求值,函數也不允許有任何副作用。下面是幾個限制:
- 函數體是一個
return
語句,它不包含goto
語句或try catch
塊,也不能拋出異常,但是可以調用其他constexpr
函數。 - 函數的返回類型應該是字面量類型,返回值不能是
void
。 - 如果
constexpr
函數是類的一個成員,這個函數不能是虛函數。 - 函數所有的參數都應該是字面量類型。
- 在編譯單元(translation unit)中定義了
constexpr
函數之后,才能調用這個函數,因為編譯器需要知道完整的定義。 - 不允許使用
dynamic_cast
。 - 不允許使用
new
和delete
。
通過定義constexpr
構造函數,可以創建用戶自定義類型的常量表達式變量。constexpr
構造函數應該滿足以下要求:
- 構造函數的所有參數都應該是字面量類型。
- 構造函數體不應該是
function-try-block
。 - 構造函數體應該滿足于
constexpr
函數體相同的要求。 - 所有數據成員都應該用常量表達式初始化。
例如,下面的Rect
類定義了一個滿足上述要求的constexpr
構造函數,此外還定義了一個constexpr getArea()
方法,執行一些計算。
class Rect
{
public:
constexpr Rect(int width, int height) : mWidth(width), mHeight(height) {}
constexpr int getArea() const
{
return mWidth * mHeight;
}
private:
int mWidth;
int mHeight;
};
-
使用這個類聲明
constexpr
對象相當直接:constexpr Rect r(8, 2); int myArray[r.getArea()]; // OK
1.2 static關鍵字
- 在C++中
static
有多種用法,這些用法之間沒有太多關系。“重載”這個關鍵字的部分原因是避免在語言中引入新的關鍵字。
1.1.1 靜態數據成員和方法
- 可以聲明類的靜態數據成員和方法。靜態數據成員與非靜態數據成員不同,它不是對象的一部分。相反,這個數據成員只有一份副本,這個副本存在于類的任何對象之外。
- 靜態方法與此類型,存在于類層次(而不是對象層次)。靜態方法不會在某個特定對象環境中執行。
(1) 靜態數據成員
有時讓類的所有對象都包含某個變量的副本是沒必要的,或者無法完成特定的任務。數據成員有可能只對類有意義,而每個對象都擁有其副本是不合適的。例如,每個電子表格或許需要一個唯一的數字ID,這需要一個從0開始的計數器,每個對象都可以從這個計數器得到自身的ID。電子表格的計數器確實屬于
Spreadsheet
類,但沒必要使每個Spreadsheet
對象都包含這個計數器的副本,因為必須讓所有的計數器都保持同步。-
C++用靜態(static)數據成員解決了這個問題。靜態數據成員是屬于類而不是對象的數據成員,可將靜態數據成員當做類的全局變量。下面是
Spreadsheet
類的定義,其中包含了新的靜態數據成員計數器:class Spreadsheet { private: static int sCounter; }
-
不僅要在類定義中列出
static
類成員,還需要在源文件中為其分配內存,通常是定義類方法的那個源文件。在此還可以初始化靜態成員,但注意與普通變量和數據成員不同,在默認情況下它會被初始化為0。static
指針會初始化為nullptr
。下面為sCounter
分配空間并初始化的代碼:int Spreadsheet::sCounter;
-
這行代碼在函數或者方法外部,與聲明全局變量類似,只是使用了作用域解析
Spreadsheet::
指出這是Spreadsheet
類的一部分。i. 在類方法內訪問靜態數據成員
-
在類方法內部,可以像使用普通數據成員那樣使用靜態數據成員。例如,為
Spreadsheet
類創建一個mId
成員,并在Spreadsheet
構造函數中用sCounter
成員初始化它。下面是包含了mId
成員的Spreadsheet
類定義:class Spreadsheet { public: int getId() const; private: static int sCounter; int mId; };
-
下面是
Spreadsheet
構造函數的實現,在此賦予初始ID值:Spreadsheet::Spreadsheet(int inWidth, int inHeight) : mWidth(inWidth), mHeight(inHeight) { mId = sCounter++; mCells = new SpreadsheetCell* [mWidth]; for (int i=0; i<nWidth; i++) { mCells[i] = new SpreadsheetCell[mHeight]; } }
-
可以看出,構造函數可以訪問sCounter,就像這是一個普通成員。在復制構造函數中,也要對ID賦值:
Spreadsheet::Spreadsheet(const Spreadsheet& src) { mId = sCounter++; copyFrom(&src); }
-
在賦值運算符中不應該復制ID。一旦給某個對象指定了ID,就不應該再改變。建議把mId設置為
const
數據成員。ii. 在方法外訪問靜態數據成員
-
訪問控制限定符適用于靜態數據成員:
sCounter
是private
,因此不能在類方法之外訪問。如果sCounter是公有的,就可以在類方法外訪問,具體方法是用::
作用域解析運算符指出這個變量是Spreadsheet
類的一部分:int c = Spreadsheet::sCounter;
然而,建議不要使用公有數據成員,應該提供公有
get/set()
方法來授予訪問權限。如果要訪問靜態的數據成員,應該實現靜態的get/set()
方法。
(2) 靜態方法
-
與數據成員類似,方法有時會應用于全部類對象而不是單個對象,此時可以像靜態數據成員那樣編寫靜態方法。以
SpreadsheetCell
中的兩個輔助方法為例:stringToDouble()
和doubleToString()
。這些方法沒有訪問特定對象的信息,因此可以是靜態的。下面的類定義將這些方法設置為靜態:class SpreadsheetCell { private: static string doubleToString(double val); static double StringTodouble(string& str); };
這兩個方法的實現與前面的實現相同,在方法定義前不需要重復static關鍵字。然而,注意靜態方法不屬于特定對象,因此沒有
this
指針,當用某個特定對象調用靜態方法時,靜態方法不會訪問這個對象的非靜態數據成員。實際上,靜態方法就像一個普通函數,唯一的區別在于這個方法可以訪問類的private
和protected
靜態數據成員。如果同一個類型的其他對象對于靜態方法可見(例如傳遞了對象的指針或引用),靜態方法也可以訪問其他對象的private
和protected
非靜態數據成員。類中任何方法都可以像調用普通函數那樣調用靜態方法,因此
SpreadsheetCell
中所有方法的實現都沒有改變。如果要在類的外面調用靜態方法,需要用類名和作用域解析運算符來限定方法的名稱(就像靜態數據成員那樣),靜態方法的訪問控制與普通方法一樣。-
將
stringToDouble()
和doubleToString()
設置為public
,這樣類外面的代碼也可以使用它們。此時,可以在任意位置這樣調用這兩個方法:string str = SpreadsheetCell::doubleToString(5.0);
1.1.2 靜態鏈接(staitc Linkage)
-
在解釋用于鏈接的
static
關鍵字之前,首先要理解C++中鏈接的概念。C++每個源文件都是單獨編譯的,編譯得到的目標文件會彼此鏈接。C++源文件中的每個名稱,包括函數和全局變量,都有一個內部或者外部的鏈接。外部鏈接意味著這個名稱在其他源文件中也有效,內部鏈接(也稱作靜態鏈接)意味著在其他源文件中無效。默認情況下:函數和全局變量都擁有外部鏈接。然而,可在聲明前面使用關鍵字static
指定內部(或者靜態)鏈接。例如,假定有兩個源文件FirstFile.cpp
和AnotherFile.cpp
,下面是FirstFile.cpp
:void f(); int main(void) { f(); return 0; }
-
注意這個文件提供了
f()
的原型,但沒有給出定義。下面是AnotherFile.cpp
:#include <iostream> using namespace std; void f(); void f() { cout << "f()\n"; }
這個文件同時提供了
f()
的原型和定義。注意在兩個不同文件中編寫的相同函數的原型是合法的。如果將原型放在頭文件中,并在每個源文件中都使用#include
包含這個頭文件,預處理器就會自動在每個源文件中給出函數原型。使用頭文件的原因是便于維護(并保持同步)原型的一個副本。這兩個源文件都可以編譯成功,程序鏈接也沒有問題:因為
f()
具有外部鏈接,main()
可從另一個文件調用這個函數。-
現在假定在
AnotherFile.cpp
中將static
應用到f()
原型。注意不需要在f()
的定義前面重復使用static
關鍵字。只要在函數名稱的第一個實例前面使用這個關鍵字,就不需要重復它:#include <iostream> using namespace std; static void f(); void f() { cout << "f()\n"; }
現在每個源文件都可以成功編譯,但是鏈接時將失敗,因為
f()
具有內部(靜態)鏈接,FirstFile.cpp
無法使用這個函數。如果在源文件中定義了靜態方法但是沒有使用它,有些編譯器會給出警告(指出這些方法不應該是靜態的,因為其他文件可能會用到它們)。-
將
static
用于內部鏈接的另一種方式是使用匿名名稱空間(anonymous namespace)。可將變量或者函數封裝到一個沒有名字的名稱空間,而不是使用static
,如下所示:#include <iostram> using namespace std; namespace { void f(); void f() { cout << "f()\n"; } }
在同一源文件中,可在聲明匿名名稱空間之后的任何位置訪問名稱空間中的項,但不能在其他源文件中訪問。這語義與
static
關鍵字相同。
extern關鍵字
extern
關鍵字將它后面的名稱指定為外部鏈接。在某些情況下面可以使用這種方法。例如,const
和typedefe
在默認情況下面是內部鏈接,可以使用extern
使其變為外部鏈接。-
然而,
extern
有一點復雜。當指定某個名稱為extern
時,編譯器將這條語句當做聲明,而不是定義。對于變量而言,這意味著編譯器不會為這個變量分配空間。必須為這個變量提供單獨的、不適用extern
關鍵字的定義行,例如:extern int x; int x = 4;
-
也可以在
extern
行初始化x
,這一行既是聲明又是定義:extern int x = 1;
-
這種情形下的
extern
并不是非常有用,因為x
默認具有外部鏈接。當另一個源文件FirstFile.cpp
使用x
時,才會真正用到extern
:#include <iostream> using namespace std; extern int x; int main(void) { cout << x << endl; }
在此
FirstFile.cpp
使用了extern
聲明,因此可以使用x
。編譯器需要一個x
的聲明,才能在main()
函數中使用這個變量。然而,如果聲明x
時未使用extern
關鍵字,編譯器認為這是個定義,就會為x
分配空間,導致鏈接步驟失敗(因為有兩個全局作用域的x
變量)。使用extern
,就可以在多個源代碼中全局訪問這個變量。然而,建議不要使用全局變量。全局變量會令人迷惑,并且容易出錯,在大型程序中尤其如此。為了獲取類似功能,可使用類的靜態數據成員和方法。
1.1.3 函數中的靜態變量
-
C++中
static
關鍵字的最終目的是創建離開和進入作用域時都可以保留值得局部變量。函數中的靜態變量就像是一個只能在函數內部訪問的全局變量。靜態變量最常用的用法是“記住”某個函數是否執行了特定的初始化操作。例如,下面的代碼就使用了這一技術:void performTask() { static bool initialized = false; if (!initialized) { cout << "Initializing\n"; // Perform Initialization. initialized = true; } // Perform the desired task. }
然而靜態變量容易令人迷惑,在構建代碼時通常有更好的方法,以避免使用靜態變量。在此情況下,可編寫一個類,用構造函數執行所需的初始化。
1.3 非局部變量的初始化順序
-
上面討論了靜態數據成員和全局變量的相關內容,以下討論下這些變量的初始化順序。程序中所有的全局變量和類的靜態數據成員都會在
main()
開始之前初始化。給定源文件中的變量以在源文件中出現的順序初始化。例如,在下面的文件中,Demo::x
一定會在y
之前初始化:class Demo { public: static int x; }; int Demo::x = 4; int y = 5;
然而,C++沒有提供規范,說明在不同源文件中初始化非局部變量的順序。如果在某個源文件中有一個全局變量
x
,在另一個文件中有一個全局變量y
,無法知道哪個變量先初始化。通常,不需要關注這一規范的缺失,但是如果某個全局變量或者靜態變量依賴于另一個變量,就可能引發問題。對象的初始化意味著調用構造函數,全局對象的構造函數可能會訪問另一個全局對象,并假定另一個對象已經構建。如果這兩個全局對象在不同的源文件中聲明,就不能指望一個對象在另一個對象之前構建,也無法控制他們的初始化順序。不同的編譯器可能有不同的初始化順序,即使同一編譯器的不同版本也可能如此,甚至項目中添加另一個文件也會影響初始化順序。警告:不同源文件中的非局部變量的初始化順序是不確定的。
1.4 非局部變量的銷毀順序
- 非局部變量按照其初始化的逆序進行銷毀。不同源文件中的非局部變量的初始化順序是不確定的。所以其銷毀順序也是不確定的。