C++系列:const 关键字

这篇文章介绍 const 关键字。

概述

有时希望定义这样一种变量,它的值不能被改变。为了满足这一要求,可以用关键字 const 对变量的类型加以限定:

1
const int bufSize = 512;    // 输入缓冲区大小

这样就把 bufSize 定义成了一个常量。任何试图为 bufSize 赋值的行为都将引发错误:

1
bufSize = 1024;    // 错误:试图向 const 对象写值

因为 const 对象一旦创建后其值就不能再改变,所以 const 对象必须初始化。

默认状态下,const对象仅在文件内有效
默认情况下,const 对象被设定为仅在文件内有效。当多个文件中出现了同名的 const 变量时,其实等同于在不同文件中分别定义了独立的变量。
如果想在多个文件之间共享 const 对象,必须在变量的定义之前添加 extern 关键字。

1
2
3
4
// file_1.cc 定义并初始化了一个常量,该常量能被其他文件访问
extern const int bufSize = fun();
// file_1.h 头文件, 与 file_1.cc 中定义的 bufSize 是同一个
extern const int bufSize;

const 的引用

可以把引用绑定到 const 对象上,就像绑定到其他对象上一样,我们称之为对常量的引用(reference to const). 与普通引用不同的是,对常量的引用不能被用作修改它所绑定的对象:

1
2
3
4
const int ci = 1024;
const int &r1 = ci; // 正确:引用及对应的对象都是常量
r1 = 42; // 错误:r1 是对常量的引用
int &r2 = ci; // 错误:试图让一个非常量引用指向一个常量对象

因为不允许直接为 ci 赋值,当然也就不能通过引用去改变 ci. 因此,对 r2 的初始化是错误的,假设该初始化合法,则可以通过 r2 来改变它引用对象的值,这显然是不正确的。

初始化和对 const 的引用
引用的类型必须与其引用对象的类型一致,但是有两个例外,第一种例外情况就是在初始化常量引用时允许用任意表达式作为初始值,只要该表达式的结果能转换成引用的类型即可。尤其,允许为一个常量引用绑定非常量的对象、字面值,甚至是一个一般表达式:

1
2
3
4
5
int i = 42;
const int &r1 = i; // 允许将 const int& 绑定到普通 int 对象上
const int &r2 = 42; // 正确: r1 是一个常量引用
const int &r3 = r1 * 2; // 正确: r3 是一个常用引用
int &r4 = r1 * 2; // 错误: r4 是一个普通的非常量引用

对 const 的引用可能引用一个非 const 的对象
常量引用仅对引用可参与的操作作出了限定,对于引用的对象本身是不是一个常量未作限定。因为对象也可能是一个非常量,所以允许通过其他途径改变它的值:

1
2
3
4
5
int i = 42;
int &r1 = i; // 引用 ri 绑定对象 i
const int &r2 = i; // r2 也绑定对象i, 但是不允许通过 r2 修改 i 的值
r1 = 0; // r1 并非常量,i 的值修改为 0
r2 = 0; // 错误:r2 是一个常量引用

r2 绑定(非常量)整数 i 是合法的行为。然而,不允许通过 r2 修改 i 的值。尽管如此,i 的值仍然允许通过其它途径修改。既可以直接给 i 赋值,也可以通过像 r1 一样绑定到 i 的其它引用来修改。

指针和const

与引用一样,也可以令指针指向常量或非常量。类似于常量引用,指向常量的指针(pointer to const) 不能用于改变所指对象的值。要想存放常量对象的地址,只能使用指向常量的指针:

1
2
3
4
const double pi = 3.14;     // pi 是个常量,它的值不能改变
double *ptr = π // 错误: ptr 是一个普通指针
const double *cptr = π // 正确: cptr 可以指向一个双精度常量
*cptr = 42; // 错误:不能给 *cptr 赋值

正常情况下,指针的类型必须与其所指对象的类型一致,但是有两个例外。第一种例外情况是允许令一个指向常量的指针指向一个非常量对象:

1
2
double dval = 3.14;         // dval 是一个双精度浮点数,它的值可以改变
cptr = &dval; // 正确:但是不能通过cptr 改变 dal 的值

和常量引用一样,指向常量的指针也没有规定其所指的对象必须是一个常量。所谓指向常量的指针仅仅要求不能通过该指针改变对象的值,而没有规定那个对象的值不能通过其他途径改变。

const 指针
指针是对象而引用不是,因此就像其它对象类型一样,允许把指针本身定为常量。**常量指针(const pointer)**必须初始化,而且一旦初始化完成,则它的值(也就是存放在指针中的那个地址)就不能改变了。把 * 放在 const 关键字之前用以说明指针是一个常量,这样的书写形式隐含着一层意味,即不变的是指针本身的值而非指向的那个值:

1
2
3
4
int errNumb = 0;
int *const curErr = &errNumb; // curErr 将一直指向 errNumb
const double pi = 3.14159;
const double *const pip = π // pip 是一个指向常量对象的常量指针

要想弄清楚这些声明的含义最行之有效的办法是从右向左阅读。此例中,离 curErr 最近的符号是 const, 意味着 curErr 本身是一个常量对象,对象的类型由声明符的其它部分确定。声明符中的下一个符号是 *, 意思是 curErr 是一个常量指针。最后,该声明语句的基本数据部分确定了常量指针指向的一个 int 对象。与之类似,我们也能推断出,pip 是一个常量指针,它指向的对象是一个双精度浮点型常量。

指针本身是一个常量并不意味着不能通过指针修改其所指对象的值,能否这样做完全依赖于所指对象的类型。例如,pip 是一个指向常量的常量指针,则不论是 pip 所指的对象值还是 pip 自己存储的那个地址都不能改变。相反,curErr 指向的是一个一般的非常量整数,那么就完全可以用 curErr 去修改 errNumb 的值:

1
2
3
4
5
6
*pip = 2.72;        // 错误:pip 是一个指向常量的指针

if(*curErr) {
errorHandler();
*curErr = 0; // 正确:把 curErr 所指的对象的值重置
}

顶层 const

如前所述,指针本身是一个对象,它又可以指向另外一个对象。因此,指针本身是不是常量及指针所指的是不是一个常量就是两个相互独立的问题。用名词顶层 const(top-level const) 表示指针本身是一个常量,而用名词底层 const(low-level const) 表示指针所指的对象是一个常量。
更一般的,顶层 const 可以表示任意的对象是常量,这点对任何数据类型都适用,如算术类型、类、指针等。底层 const 则与指针和引用等复合类型的基本类型有关。比较特殊的是,指针类型既可以是顶层 const 也可以是底层 const, 这点和其它类型相比区别明显:

1
2
3
4
5
6
int i = 0;
int *const p1 = &i; // 不能改变 p1 的值,这是一个顶层 const
const int ci = 42; // 不能改变 ci 的值,这是一个顶层 const
const int *p2 = &ci; // 允许改变 p2 的值,这是一个底层 const
const int *const p3 = p2; // 靠右的 const 是顶层 const, 靠左的是底层 const
const int &r = ci; // 用于声明引用的 const 都是底层 const

当执行对象的拷贝操作时,常量是顶层 const 还是底层 const 区别明显。其中,顶层 const 不受什么影响:

1
2
i = ci;         // 正确:拷贝 ci 的值, ci 是一个顶层 const, 对此操作无影响
p2 = p3; // 正确:p2 和 p3 指向的对象类型相同,p3 是顶层 const 的部分不影响

执行拷贝操作并不会改变被拷贝对象的值,因此,拷入和拷出的对象是否是常量都没有什么影响。

另外一方面,底层 const 会影响数据的拷入和拷出。当执行对象的拷贝操作时,拷入和拷出的对象具有相同的底层 const 资格,或者两个对象的数据类型必须能够转换。一般来说,非常量可以转换成常量,反之则不行:

1
2
3
4
5
int *p = p3;            // 错误:p3 包含底层 const 的含义,而 p 没有
p2 = p3; // 正确:p2 和 p3 都是底层 const
p2 = &i; // 正确:int* 能够转换成 const int *
int &r = ci; // 错误:普通的 int& 不能绑定到 int 常量上
const int &r2 = i; // 正确:const int& 可以绑定到一个普通 int 上

p3 既是顶层 const 也是底层 const, 拷贝 p3 时可以不在乎它是一个顶层 const, 但是必须清楚它指向的对象是一个常量。因此,不能用 p3 去初始化p, 因为 p 指向的是一个普通的(非常量)整数。另一个方向,p3 的值可以赋给 p2, 是因为这两个指针都是底层 const, 尽管 p3 同时也是一个常量指针(顶层 const), 仅就这次赋值而言不会有什么影响。

constexpr 和常量表达式

常量表达式(const expression) 是指值不会改变并且在编译过程得能到计算结果的表达式。显然,字面值属于常量表达式,用常量表达式初始化的 const 对象也是常量表达式。
一个对象(或表达式)是不是常量表达式由它的数据类型和初始值共同决定,例如:

1
2
3
4
const int max_files = 20;           // max_files是常量表达式
const int limit = max_files; // limit 是常量表达式
int staff_size = 27; // staff_size 不是常量表达式
const int sz = get_size(); // sz 不是常量表达式

尽管 staff_size 的初始值是个字面值常量,但由于它的数据类型只是一个普通 int 而非 const int, 所以它不属于常量表达式。另一方面,尽管 sz 本身是一个常量,但它的具体值直到运行时才能获取到,所以也不是常量表达式。

constexpr变量
在一个复杂系统中,很难(几乎肯定不能)分辨出一个初始值到底是不是常量表达式。当然可以定义一个 const 变量并把它的初始值设为我们认为的某个常量表达式,但在实际使用时,尽管要求如此却常常发现初始值并非常量表达式的情况。可以这么说,在此种情况下,对象的定义和使用根本就是两回事。
C++11 新标准规定,允许将常量声明为 constexpr 类型以便由编译器来验证变量的值是否是一个常量表达式。声明为 constexpr 的变量一定是一个常量,而且必须有常量表达式初始化:

1
2
3
constexpr int mf = 20;          // 20 是常量表达式
constexpr int limit = mf + 1; // mf + 1 是常量表达式
constexpr int sz = size(); // 只有当 size 是一个 constexpr 函数时才是一条正确的声明语句

尽管不能使用普通函数作为 constexpr 变量初始值,不过在新标准中允许定义一种特殊的 constexpr 函数,这种函数应该足够简单以使得编译时就可以计算其结果,这样就能用 constexpr 函数去初始化 constexpr 变量了。

字面值类型
常量表达式的值需要在编译是就得到计算,因此对声明 constexpr 时用到的类型必须有所限制。因为这些类型一般比较简单,值也显而易见、容易得到,就把他们称为“字面值类型”(literal type)。

其中,算术类型、引用和指针都属于字面值类型(还有其它的字面类型)。自定义类 Sales_item、IO库、string 类型则不属于字面值类型,也就不能被定义成 constexpr.

尽管指针和引用都能定义成 consexpr, 但他们的初值却受到严格限制。一个 constexpr 指针的初始值必须是 nullptr 或者 0, 或者是存储于某个固定地址中的对象。

函数体内定义的变量一般来说并非存放在固定地址中,因此 constexpr 指针不能指向这样的变量。相反,定义于所有函数体之外的对象其地址不变,能用来初始化 constexpr 指针。另外,允许函数定义一类有效范围超出函数本身的变量,这类变量和定义在函数体之后的变量一样也有固定地址。因此,constexpr 引用能绑定到这样的变量上,constexpr 指针也能指向这样的变量。

指针和 constexpr

必须明确一点,在 constexpr 声明中如果定义了一个指针,限定符 constexpr 仅对指针有效,与指针所指的对象无关:

1
2
const int *p = nullptr;         // p 是一个指向整型常量的指针
constexpr int *q = nullptr; // q 是一个指向整数的常量指针

p 和 q 的类型相差甚远,p 是一个指向常量的指针,而 q 是一个常量指针,其中的关键在于 constexpr 把它定义的对象置为顶层 const.
与其它常量指针类似,constexpr 指针既可以指向常量也可以指向一个非常量:

1
2
3
4
5
6
constexpr int *np = nullptr;        // np 是一个指向整数的常量指针,其值为空
int j = 0;
constexpr int i = 42; // i 的类型是整型常量
// i 和 j 都必须定义在函数体之后
constexpr const int *p = &i; // p 是常量指针,指向整型常量 i
constexpr int *p1 = &j; // p1 是一个常量指针,指向整数 j