C++ 学习笔记(六)(const 限定符篇)

前言:主要是自己学习过程的积累笔记,所以跳跃性比较强,建议先自学后拿来作为复习用。

1 初识 const 限定符

const 是 C90 标准新加的关键字,用于限定一个变量只读。其出现的目的是为了取代预编译指令。仔细阅读本文你能了解到关于 const 的所有详细知识。

1.1 const 的特点

  • 重新赋值会产生语法错误

const 定义的变量被称为只读变量,又名常量,重新赋值会被编译器判定为语法错误。如果定义的时候没有初始化呢,某些编译器并不会报错,而是在执行的时候自动把一个很小的负数存放进去,再次赋值时仍然会产生语法错误。

  • 生命周期从程序开始到结束

常量的生命周期是程序运行的整个过程,但这并不意味着:如果在一个循环体内定义了一个常量 a 就能在循环外使用它,而是指常量一旦创建,直到程序结束前它都不会被销毁。正常来说,循环内定义的变量一旦出了循环就立即被销毁了(不再占用内存了)。定义成常量仅仅代表该变量有了静态特性,并不意味着它成了静态变量(用 static 关键字修饰的变量),要区分开来。

  • 存储在常量段中

局部变量(包括 main 函数中定义的变量)存储在中,全局变量和静态变量则存储在静态存储区,即中。而常量存储在对应栈或堆中的常量段。常量段中存储的是常量和只读变量等不可修改的量。

  • 不能用作数组的长度

常量本质上还是一个变量,在 C 语言中不能用作数组的长度。C++ 扩展了 const 和数组的含义,因此常量在 C++ 中可以用作数组的长度。

1.2 const 的作用域

  • cosnt 执行原理

为了支持分离式编译,C++ 将声明和定义区分开来,同时 C++ 规定变量的定义必须且只能出现在一个文件中。变量的定义是为变量分配存储空间和初始化的过程,而变量的声明是告诉程序变量的类型和名字的过程。如果一个变量在多个文件中都被定义,那么就会造成编译器无法确定哪个定义是有效的,从而导致错误。因此为了避免重复定义和冲突的问题,C++ 要求一个变量只能在一个文件中被定义一次,而在其他需要用到该变量的文件中,只能对其用关键字 extern 进行声明,而不能重复定义。这样就可以保证变量的唯一性和一致性。

const int a = 520;

默认情况下,常量仅仅在其所在文件内有效。对于如上语句,当常量被定义后,编译器会在编译过程中把所有用到 a 的地方都替换成 520。所以为了执行这个替换,编译器必须一开始就知道变量的值。假如程序包含多个文件,每个文件都要用到常量 a,那么每个文件都必须得能访问到 a 的初始值才行,也就意味着每个文件都要定义一个常量 a。还有如下让我们很为难的情况:

int GetValue()
{
	...
	return value;
}

const in a = GetValue();

常量 a 的值并不是一个给定的初始值,它的值由 GetValue 函数计算并返回给 a。我们希望它能在文件间共享,但其他文件中可能并不存在 GetValue 函数,我们也不希望编译器为每个文件分别生成独立的常量 a。相反,我们想只在一个文件中定义 a,而在多个文件中声明(区别于定义)并使用它。

解决的办法是,对于 const 变量不管是声明还是定义都添加 extern 关键字,这样只需定义一次就可以了。

// file_1.cpp 定义并初始化了一个常量,该常量能被其他文件访问
extern const int a = GetValue();
// file_1.h 头文件中声明常量 a
extern const int a; // 此时的 a 与 file_1.cpp 中定义的 a 就是同一个

如上所示,file_1.cpp 定义并初始化了 a。因为这条语句包含了初始值,所以它显然是定义。然而,因为 a 是一个常量,必须用 extern 加以限定以使其可以被其他文件使用。

file_1.h 头文件中的声明也用 extern 做了限定,其作用是指明 a 并非本文件所独有,它的定义将在别处出现。该句就是 const 变量的一次声明

总结一句话就是:如果想在多个文件之间共享常量,就在变量的定义和声明之前添加 extern。

2 const 的基本用法

2.1 定义和初始化

  • 定义常量时必须进行初始化

因为常量的值一旦创建后就不能改变,所以常量必须初始化,且其初始值可以是任意复杂的表达式。

const int a = GetValue();	// 合法语句
const int a = 10;			// 合法语句
const int a; 				// 非法语句
  • 基本数据类型对于 const 而言是透明的

基本数据类型放在 const 前或后没有区别,const 只负责限定对象为常量。

// 两条语句是等价的,都定义了整型常量 a
const int a = 10;
int const a = 10;
  • 限定一个变量为只读,则该变量不能作为左值出现

在常量上不能执行改变其值的操作,所以不能作为左值(例如被重新赋值)出现,任何试图为常量赋值的行为都将引发错误。

const int a = 10;
a = 100; // 非法语句,编辑器会报错

2.2 对常量的引用

先补充关于引用的一个说法:

int a = 52;
int &r1 = a;
double b = 52.0
double &r2 = b;

上述代码中,r1 用 int 进行了修饰,表明其是一个对整型的引用,因此可以将其绑定到整型变量 a 上;同理,r2 是一个对浮点型的引用,可以将其绑定到浮点型变量 b 上。

同理,我们可以将引用绑定到一个常量上,这种引用被称之为对XX类型常量的引用,简称“常量引用”。

const int a = 520;
const int &r1 = a; 	// 合法语句

将引用绑定到一个常量后,其所能执行的操作就有了限制,比如尝试通过引用 r1 修改 a 的值,或者将一个没有 const 修饰的引用绑定到常量上都会引发错误:

r1 = 250; 			// 非法语句,r1 是对常量的引用,无法改变
int &r3 = a; 		// 非法语句,识图将一个对非常量的引用绑定到一个常量对象

对于第一条语句,因为 a 是常量,不允许修改 a 的值,当然也就不能通过引用去改变 a,这一点很好理解。

第二条语句是对 r3 的初始化(引用必须初始化):假设该初始化是合法的,那么就意味着可以通过 r3 来改变它所引用对象 a ——也即前面定义的常量的值,这显然是与常量定义相矛盾的。因此,第二条语句是非法的,这也就意味着不能通过修改 对常量的引用 来修改它所引用(绑定)的对象

C++ 程序员们经常把词组 “对 const 的引用(对常量的引用)” 简称为 “常量引用”,这一简称还是挺靠谱的,不过前提是你得时刻记得这就是个简称而已。因为严格来说,并不存在常量引用。因为引用不是一个对象,所以我们没法让引用本身恒定不变。而事实上,由于 C++ 并不允许随意改变引用所绑定的对象,所以从这层意义上去理解,所有的引用又都算是常量了。引用的对象是常量还是非常量仅仅决定引用所能参与的操作, 却无论如何都不会影响到引用和对象的绑定关系本身。

在初始化对常量的引用时允许使用任意的表达式作为初始值,只要该表达式的结果能转换成引用的类型。因此,允许将一个对常量的引用绑定到非常量对象、字面值、甚至是一个一般表达式上:

int a = 520;
const int &r1 = a; 		// 允许将对“整型常量的引用”绑定到一个整型对象上
const int &r2 = 520; 	// 合法语句,r2 是一个常量引用
const int &r3 = r1 * 2; // 合法语句,r3 是一个常量引用
int &r4 = r1 * 2; 		// 非法语句,r4 是一个普通的非常量引用

要注意的是,尽管我们会有 “引用的类型” 的表述,但不能真的理解为 r1 是 int 类型的引用,因为引用不是对象,也就没有类型。在定义引用时,类型的定义只是告诉编译器该引用绑定的对象的类型

但是即便将对常量的引用绑定到一个非常量上,我们也不能通过该引用来修改其绑定的对象,这就需要弄清楚当一个对常量的引用绑定到非常量上时到底发生了什么:

double val = 3.14;
const int &r1 = val;

此处 r1 理应被绑定到一个 int 型的数上,对 r1 的操作应该是整数运算,但实际上其被绑定到了一个 double 类型的数 val 上。因此为了确保 r1 能绑定一个整数,编译器把上述代码变成了如下形式:

const int temp = val; // 由 val 生成一个临时的整型变量
const int &r1 = temp; // 让 r1 绑定这个临时量

在这种情况下,r1 便绑定了一个临时量(tempoary)。所谓临时量对象就是编译器一个暂时存在的对象。由此可知,将对常量的引用绑定到非常量上时,实际上绑定的是一个临时常量,因此对引用的赋值操作本身就是非法的。

必须认识到,对常量的引用仅对引用可参与的操作做出了限定,对于其绑定的对象可参与的操作没有做出限定,这也就意味着可以通过其他途径改变其绑定对象的值:

int a = 52;
int &r1 = a;		// 引用 r1 绑定对象 a
const int &r2 = a; 	// r2 也绑定对象 a,但不允许通过 r2 修改 a 的值
r1 = 0; 			// r1 并非常量引用,a 的值可被修改为0
r2 = 0; 			// 非法,因为 r2 是一个常量引用

对常量的引用 r2 绑定非常量整数 a 是合法行为,因此不允许通过 r2 修改 a 的值。但 a 的值仍然可以通过其他途径修改,既可以直接给 a 赋值,也可以通过引用 r1 来修改。

对于引用的本质可以参考这篇博客:C++的那些事:你真的了解引用吗

2.3 const 和指针

2.3.1 指向常量的指针

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

const double pi = 3.14; // pi 是一个常量,它的值不能改变
double *p1 = π 		// 非法语句,p1 只是一个普通指针
const double *p2 = π	// 合法语句,p2 是一个指向常量的指针
*p2 = 3.1415926; 		// 非法语句,不能给 pi 赋值。

在解释为什么需要这样之前,必须先讲清楚”指针的类型“这个概念。指针本身是一种特殊的数据类型,它并不属于基本数据类型。指针在计算机中占用固定的内存,32位计算机中为4字节,64位计算机中为8字节。指针存储的值是其所指对象的地址,而地址是一个无符号整数。因此指针本身为常量意味着指针本身存放于常量区,它的值,也就是它保存的地址一经初始化便无法改变。

总结一下就是:类似于对常量的引用,引用本身没有类型,但我们可以简述成“对XX类型的引用”;指针本身不属于基本数据类型,但我们依然可以简述为“XX类型的指针”。但是要记住的是,XX类型变量的地址要 “指向XX类型的指针”来存放,因此XX类型常量的地址要“指向XX类型常量的指针”来存放。

尽管如此,但仍然存在着一个例外,即允许令一个指向常量的指针指向一个非常量对象(这一点和引用一样):

double val = 3.14; 			// val 是一个双精度浮点数,它的值可以改变
const double *p2 = &val;	// 合法语句,但是不能通过 p2 改变 val 的值

和对常量的引用一样,并没有规定指向常量的指针必须指向一个常量。所谓指向常量的指针仅仅要求不能通过该指针改变其所指对象的值,而没有规定那个对象一定是个常量。同理也可以修改指向常量的指针的值来使其指向另一个变量,只要指针本身不是常量即可。

2.3.2 常量指针

所谓常量指针,就是指针的值(地址)不能改变,意味着一旦该指针指向了一个对象,它在这次程序运行期间就只能指向这个对象了。细心的读者可能已经发现了,这其实就相当于引用,而编译底层确实是用常量指针的方式实现引用的,但在 C++ 中两者是完全不同的概念,常量指针更强调指指向不再改变,引用更强调“变量别名”(既然是别名也就只能指代一个东西)。

在引用中,对常量的引用又称作常量引用。但在指针里面,指向常量的指针 ≠ 常量指针,两者最大的区别在于定义时 * 与 const 的位置不同。

指针是对象而引用不是,因此就像其他对象类型一样,允许把指针本身定义为常量。常量指针必须初始化,而且一旦初始化完成,则它的值(存放在指针中的地址)就不能再改变了。把 * 放在 const 关键字之前用以说明指针是一个常量,如下:

int a = 0;
int *const p = &a; 				// p 是一个常量指针,它将一直指向 a
const double pi = 3.1415926;
const double *const q = π	// q 是一个指向常量对象的常量指针

能否通过指针修改其所指对象的值 只与 对象的类型有关。例如上面的语句中,q 是一个指向常量的常量指针,则不论是 q 所指的对象还是 q 自己存储的那个地址都不能改变。相反的,p 指向的是一个非常量整数,那么就可以用 p 去修改 a 的值。

接下来讲讲如何快速判断哪种定义是指向常量的指针,哪种定义方式又是常量指针。比如下面三条语句:

const int *p; 		// 注意区分,这里 * 与 const 变换了位置
int *const q;
// 不存在 const *int q,因为 '*' 只能出现在 int 的右侧
const int *const r;	// 定义了一个指向 int 类型常量的常量指针

我们一般采用从右往左阅读的顺序来识别变量的具体类型。在第一条语句中,变量 p 左边第一个修饰符是 * ,表明 p 是一个指针;紧接着遇到了 int,表明该指针指向一个 int 类型的变量,再接着遇到了 const,表明该变量是一个常量,所以 p 是一个指向常量的指针

在第二条语句中,首先出现的是 const,表明变量 q 是一个常量;再往左遇到修饰符 * ,说明 q 是指针,且是常量指针,再往左读到了 int,表明 q 是一个指向 int 类型的常量指针

那么我自己常用的判断方法就是:const 优先修饰其左侧第一个修饰符,当其左侧没有修饰符时,它就修饰右侧第一个修饰符。比如:

  • 第一条语句:const 左侧没有修饰符,此时它转而修饰右侧的 int,因此表明指针 p 指向一个 int 型的常量;
  • 第二条语句:const 左侧有修饰符 *,因此它优先修饰 *,所以指针 q 是一个常量指针;
  • 第三条语句:第一个 const 修饰右侧的 int,第二个 const 修饰左侧的 *,所以指针 r 是一个常量指针,且指向一个 int 型的常量。

再看下面四条语句,即使顺序不同,但前两句都是定义了一个值为10的常量 a,后两句都是定义了一个指向常量的指针 p。 在类型前或后使用关键字 const 是没有区别的,推荐 const 在前的写法。

 // 注意区分,这里是 const 和 int 位置互换
const int a = 10;
int const a = 10;
const int *p;
int const *p;

3 顶层 const

如前所述,指针本身是一个对象,它又可以指向另外一个对象。因此,指针本身是不是常量以及指针所指的对象是不是一个常量就是两个相互独立的问题。

  • 顶层 const:指针本身是个常量,即常量指针。
  • 底层 const:指针所指的对象是一个常量,即指向常量的指针。

这两个名词可以应用到范围更广的层面上,顶层 const 可以表示任意的对象本身是常量,这一点对任何数据类型都适用,比如算数类型、类、指针等。底层 const 则与指针和引用等复合类型的基本类型部分有关。比较特殊的是,指针类型既可以是顶层 const,也可以是底层 const,这一点和其他类型区别较明显:

int a = 0;
int *cosnt p1 = &a; 		// 指针 p1 本身是一个常量,这是一个顶层 const
const int b = 520; 			// 变量 b 是一个常量,这是一个顶层 const
const int *p2 = &b; 		// 指针 p2 本身不是一个常量,这是一个底层 const
const int *const p3 = p2;	// 靠右的 const 是顶层 const,靠左的 const 是底层 const
const int &r = b; 			// 声明引用的 const 都是底层 const

当要执行对象的拷贝操作时,顶层 const 与底层 const 的区别比较明显。其中,顶层 const 不受什么影响:

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

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

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

int *p = p3;		// 错误,p3 包含底层 const 的定义,而 p 没有
p2 = p3; 			// 正确,p2 和 p3 都包含底层 const 的定义
p2 = &a; 			// 正确,int* 能转换成 const int*
int &r = b; 		// 错误,普通的 int& 不能绑定到 int 常量上
const int &r2 = a;	// 正确,const int& 可以绑定到一个普通 int 上

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

顶层和底层 const 的作用就在于更加明了的限制拷贝,从而忽视底下的拷贝细节,了解即可。

4 define vs const

const 定义的常量同时兼具变量常量的属性,所以也称为常变量。很多读者在学习 const 的时候都会混淆它与 define 的区别。从功能上说它们确实很像,而且编译器在编译时也都是执行的替换操作,但它们也有明显的不同:

  1. define 是预编译指令,而 const 是常量的定义。

define 定义的宏是在预处理阶段进行替换;而 const 定义的常量是在编译运行阶段使用的,只在执行到相关语句时进行替换。

  1. define 仅仅是替换的内容,而 const 定义的是常量 。

先别迷惑,往后看。define 定义的宏在编译后就不存在了,它不占用内存,因为系统只给具体的变量或常量分配内存。而 const 定义的常量本质上仍然是一个有类型且占用存储单元的变量。常量有名字,而 define 定义的内容则没有。有名字就便于在程序中被使用,除了在 C 中不能作为数组的长度,用 const 定义的常量具有宏优点的同时使用起来更方便。

  1. const 比 define 更加安全

const 定义的对象有数据类型,而宏定义的则没有。所以编译器可以对前者进行类型安全检查,而对后者只是机械地进行文本替换。这样就很容易出现诸如 “边际问题” 和 “括号问题” 等问题。因此能用 const 就不用 define。

5 最后一些补充

5.1 constexpr 常量表达式

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

const int a = 20; 			// a 是常量表达式
const int b = a + 1;		// b 是常量表达式
int c = 27; 				// c 不是常量表达式
const int d = GetValue();	// d 不是常量表达式

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

5.2 constexpr 变量

在一个复杂系统中,很难分辨一个初始值到底是不是常量表达式。当然,我们可以定义一个 const 对象并把它的初始值设为我们认为的某个常量表达式,但在实际使用时,却常常发现初始值并非常量表达式的情况。可以这么说,在此种情况下,对象的定义和使用根本就是两回事儿。

C+11 新标准规定,允许将变量声明为 constexpr 类型以便由编译器来验证变量的值是否是一个常量表达式。声明为 constexpr 的变量一定是一个常量,而且必须用常量表达式初始化:

constexpr int a = 20; 			// 20是常量表达式
constexpr int b = a + 1; 		// a + 1 是常量表达式
constexpr int c = GetValue();	// 只有当 GetValue 是一个 constexpr 函数时才是一条正确的声明语句

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

一般来说,如果你认定变量是一个常量表达式,那就把它声明成 constexpr 类型。

5.3 字面值类型

常量表达式的值需要在编译时就计算出来,因此对声明 constexpr 时用到的类型必须有所限制。因为这些类型一般比较简单,值也显而易见或者说容易得到,因此通常把它们称为字面值类型。算术类型、引用和指针都属于字面值类型。自定义类、IO 库、string 类型则不属于字面值类型,也就不能被定义成 constexpr。当然 C++ 中远不止这几种字面值类型的数据类型。

尽管指针和引用都能定义成 constexpr,但它们的初始值却受到严格限制。一个 constexpr 指针的初始值必须是 nullptr 或者0,或者是存储于某个固定地址中的对象的地址。由于函数体内定义的变量一般来说并非存放在固定地址中,因此 constexpr 指针不能指向这样的变量。相反的,定义于所有函数体之外的对象的地址固定不变,所以能用来初始化 constexpr 指针。C++ 允许函数定义一类有效范围超出函数本身的变量,这类变量和定义在函数体之外的变量一样也有固定地址。因此,constexpr 引用能绑定到这样的变量上,constexpr 指针也能指向这样的变量。

5.4 指针和 constexpr

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

const int *p = nullptr; 	// p 是一个指向整型常量的指针
constexpr int *q = nullptr; // q 是一个指向整数的常量指针
int *const q = nullptr;		// 上一条的等价语句

p 和 q 的类型相差甚远,p 是一个指向常量的指针,而 q 是一个常量指针,其中的关键在于 constexpr 把它所定义的对象置为了顶层 const

与其他常量指针类似,constexpr 指针既可以指向常量也可以指向一个非常量:

constexpr int *p = nullptr; 	// p 是一个指向整数的常量指针,其值为空
int a = 0;
constexpr int b = 520; 			// b 的类型是整型常量
// a 和 b 都必须定义在函数体之外
constexpr const int *q = &b;	// q 是常量指针,指向整型常量 b
constexpr int *r = &j; 			// r 是常量指针,指向整数 j

看完觉得有收获的话,希望能别吝啬赞美之情,动个小手点点赞好不好嘛~~~

  • 2
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值