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 非局部变量的销毁顺序
- 非局部变量按照其初始化的逆序进行销毁。不同源文件中的非局部变量的初始化顺序是不确定的。所以其销毁顺序也是不确定的。