移动语义
lvalue and rvalue
左值,即赋值符号左边的值,当然也可以出现在赋值符号右边,可以通过去地址运算符获取其地址是左值的根本特点;
右值,即赋值符号右边的值,只有数值的概念,在内存中没有一块固定的空间用于存放,如表达式中的数值、表达式运算的结果等等,如果熟悉汇编语言,可以理解为右值暂时存放在寄存器中。c++11为了引入右值引用,将右值进一步划分为纯右值和将亡值:
- 纯右值,即纯粹的右值,包括没有标识符、不可取地址的表达式和字面量(注意与字符串字面量区分,双引号括起来的字符串是存放在内存的可读区的)。
- 将亡值,临时对象,暂时存放在内存中,一般马上会被销毁,如函数返回的一个对象等等,这部分内存是可以被移动的。
1 | int a; |
左值引用与右值引用
左值引用,即引用对象是左值的引用;右值引用,即引用对象是右值的引用。以往我们直接称左值引用为引用,并使用&表示,为了区分左值引用,使用&&表示右值引用。使用规则如下:
- 左值引用,使用T&,只能绑定左值
- 右值引用,使用T&&,只能绑定右值
- 常量左值引用,const T&,可以绑定左值或右值,但是不能对其修改。
1 | int i = 10; |
Note:右值引用本身也是一个左值。如下fun为重载函数,一个接受左值参数,另一个接受右值参数。
1 | void fun(int &a) { |
编译运行结果如下,可见fun(b)调用的是左值版本。
1 | > g++ b.cc -g -o a |
std::move
左值具有持久的状态,而右值要么是字面常量,要么是在表达式求值过程中创建的临时对象。绑定到临时对象的右值引用,其引用的对象将要被销毁且无其他用户,因此我们可以接管其占有的资源。
虽然不能将一个右值引用绑定到左值上,但是可以通过move显示地将一个左值转换为对应的右值引用。上一节最后,使用了move作用于a,使得其从一个左值转换为右值引用类型。需要注意的是,可以销毁一个移后源对象,也可以赋予它新值,但是不能使用其值,因为其值已经和之前的状态不同了(比如被设置成初始状态)。
Why move
程序运行过程中,可能会产生许多的临时对象,这些临时对象拷贝到我们指定的左值,然后被销毁,如果一个对象占有大量的内存,则需要重复的产生和销毁,对程序的性能造成影响。
1 | class Obj |
编译运行,输出如下:
1 | g++ b.cc -O0 -o a && ./a |
可见,首先调用了默认构造函数,生成一个临时对象,然后将该临时对象拷贝一份存入vector,再销毁该临时对象。如果临时对象本身占有大量的资源,这些资源的拷贝和销毁会成为降低程序的性能。
由于临时对象本身没有其他用户且马上需要被销毁,我们可以直接接管其资源,既可以减轻销毁临时对象的工作,也无需拷贝一份重复的资源。或者有时候我们不需要使用一个左值了,此时move就派上了用场。类似拷贝构造函数和拷贝赋值运算符,c++11允许我们为类定义移动构造函数和移动赋值运算符,它们从给定的对象中“窃取”资源而非拷贝。
move-constructor and move-assign
重新设计一个类名为Array,其动态管理一个int数组,因此它需要管理自身占有的资源,首先思考这个类需要什么功能,即需要对外提供的接口:
- 初始化:根据给定的size和默认值初始化一个Array
- 拷贝构造和拷贝赋值:支持深拷贝
- 添加元素:类似vector的push_back
- 返回size:返回array对象的元素个数
- 返回容量:返回array对象的capacity
- 销毁:析构函数需要负责销毁自身管理的int数组
1 | class Array |
编写main函数测试该类:
1 | int main() |
生成时机
上一节讨论了移动语义在实际编程中的用法和优点,类似拷贝操作,编译器有时会为我们默认合成移动操作,这些默认的移动操作有时候就能满足我们的编程需求。但是有时候编译器不会提供默认合成的移动操作,而将其定义为删除的函数,此时如果我们在程序中显示要求使用移动操作,则编译器会默认使用拷贝操作替代,从而程序的行为不符合我们的预期。本节讨论如下内容:
- 默认移动操作的行为:移动每一个数据成员,其中内置类型的成员编译器可以移动,类类型的成员需要有对应的移动操作。
- 何时编译器会为类合成移动操作:类如果定义了自己的拷贝构造函数、拷贝赋值运算符或析构函数,则编译器不会为它合成移动操作。如果我们没有定义移动操作,且编译器也没有为我们合成时,即使我们指定使用移动操作,编译器会默默使用拷贝操作替代。此时,我们可以自定义移动操作,或者显式要求编译器为我们合成默认操作(=default)。
- 何时移动操作被定义为删除,即显式要求一个移动操作而编译器无法为其生成,编译器会将移动操作定义为删除的函数。此时如果我们需要移动操作,必须自定义,而不能显示要求编译器默认为我们合成。
- 显示定义移动操作会对默认合成的拷贝操作有什么影响:定义了移动操作(移动拷贝函数和移动赋值运算符之一),则拷贝操作默认被定义为删除。必须自定义或者显式要求编译器生成。
Note:需要区分编译器为我们合成移动操作时,是不会还是不能。
Prepare
设计三个类,分别为Dad、Son和Daughter,其中Dad类拥有一个Son类型的数据成员和一个Daughter类型的数据成员,可以理解为父亲类有一对儿女。
1 | class Daughter |
我们通过如下测试程序,根据打印的信息,探索移动操作的行为。c++默认的拷贝操作和移动操作,是拷贝或移动每个非static成员,因此,如果拷贝或移动了Dad对象,则其数据成员也会被拷贝或移动;如果拷贝赋值或者移动赋值了Obj对象,则其mem成员也会被拷贝赋值或者移动赋值。此时会打印处提示信息,我们就知道了Dad对象是被拷贝还是移动了。
1 | int main() |
编译运行,输出如下:
1 | g++ b.cc -std=c++11 -O2 -o b && ./b |
与预期结果相同,此时我们显式要求了编译器为我们提供移动操作和拷贝操作,并且编译器通过匹配调用了我们需要的操作。
Verify 1
验证:一个类定义了拷贝构造函数、拷贝赋值运算符或析构函数,则不会合成移动操作,编译器使用拷贝操作替代
注释掉拷贝操作、移动操作和析构函数:
1 | class Dad |
再次编译运行:
1 | g++ b.cc -std=c++11 -O2 -o b && ./b |
此时编译器的输出和最初的情况相同,即默认为我们生成了拷贝操作和移动操作,并在合适的地方调用了。
然后,显式要求编译器合成默认的拷贝构造函数、拷贝赋值运算符和析构函数中的任一几个(为节省篇幅仅给出显式合成析构函数):
1 |
|
编译运行,结果如下:
1 | g++ b.cc -std=c++11 -O2 -o b && ./b |
此时,编译器使用拷贝操作替代了移动操作,即使我们没有显式要求合成拷贝操作。此时,其对象是通过拷贝操作来实现“移动”的。
Verify 2
验证:定义了移动操作的类必须定义拷贝操作,否则拷贝操作被定义为删除的
显式要求合成移动构造函数,其余保持不变:
1 | class Dad |
编译,出现报错:
1 | g++ b.cc -std=c++11 -O2 -o b && ./b |
此时main函数中的如下两行,被编译器认定使用了删除的拷贝赋值运算符:
1 | o2 = o1; |
可见,仅仅定义了移动构造函数,移动复制运算符会被拷贝赋值运算符替代。尝试定义移动赋值而注释掉移动构造函数:
1 | class Dad |
尝试编译仍然出错,因为编译器也未默认合成移动构造函数,而是使用拷贝构造函数替代之,但是声明了移动赋值运算符,拷贝操作均被定义为删除。
因此我们必须同时定义两种移动操作:
1 | class Dad |
同时,我们修改main函数,仅仅测试拷贝赋值是否成功:
1 | int main() |
编译,出现错误(一般IDE也会提示报错而无需编译):
1 | g++ b.cc -std=c++11 -O2 -o b && ./b |
提示我们使用了删除的拷贝赋值,因为Dad类声明了移动构造函数或移动赋值运算符。
再次修改main函数测试拷贝构造函数,首先验证移动构造可以成功:
1 | int main() |
编译运行:
1 | g++ b.cc -std=c++11 -O2 -o b && ./b |
再次修改main函数测试拷贝构造:
1 | int main() |
编译运行:
1 | /usr/include/c++/11/ext/new_allocator.h:162:11: error: use of deleted function ‘Dad::Dad(const Dad&)’ |
同上,提示我们因为Dad类声明了一个移动构造函数或移动赋值运算符,拷贝构造函数被定义为删除。
Verify 3
验证:类成员的移动操作不可见或者被定义为删除时,类的移动操作也被定义为删除
Verify 4
验证:类似拷贝赋值,如果存在类成员是被const修饰的常量或者一个引用,则其移动赋值被定义为删除
为Dad类添加一个int类型常量成员i:
1 | class Dad |
修改main函数,测试构造函数部分:
1 | int main() |
编译运行:
1 | g++ b.cc -std=c++11 -O2 -o b && ./b |
修改main函数测试移动赋值部分:
1 | int main() |
编译出错,提示我们移动赋值操作被定义为删除的:
1 | g++ b.cc -std=c++11 -O2 -o b && ./b |
同样的步骤我们可以为Dad类添加一个引用类型成员,并逐步修改main函数测试,编译出错的原因是相同的:
1 | int i; |
编译器提示的错误信息如下:
1 | g++ b.cc -std=c++11 -O2 -o b && ./b |
可见,当存在类成员为引用类型或者是const,此时不允许赋值,这是显而易见的。
类型转换
有时候我们需要在表达式中显示地将对象强制转换为另一种类型参与运算,称作**强制类型转换(cast)。一个命名的强制类型的形式为cast-name
static_cast
任何具有明确定义的类型转换,且不包含底层const,都可以使用static_cast。
1 | // convert int to double |
const_cast
const_cast只能改变运算对象的底层const。对于将常量对象转换为非常量对象,称为去掉const性质。如果对象本身不是常量,则获得写权限是合法的行为,反之对其进行写操作会产生未定义的后果。 const_cast接受的操作对象只能是指针、引用或者指向类类型对象的成员指针。
const_cast与static_cast对比
1 | const char * sp; |
使用const_cast获得常量的写操作权限
1 | void write_const(const char * cp) |
该函数通过将指向常量的指针参数,去掉其底层const,尝试进行写操纵。
当cp指向真正的常量时,如下main函数
1 | int main() |
编译通过,运行。触发segment fault:
1 | > g++ main.cc -g -o main && ./main |
若sp指向的是非常量
1 | int main() |
编译通过,运行,写入成功:
1 | > g++ main.cc -g -o main && ./main |
如下程序通过去掉const特性修改string的内容:
1 |
|
运行结果如下:
1 | g++ main.cc -g -o a && ./a |
const_cast操作对象不是指针或引用时会编译出错:
1 |
|
reinterpret_cast
reinterpret_cast通常为运算对象的位模式提供较低层次上的重新解释。
1 | // 将一段存储数字的内存的地址转换为字符串的地址,并输出 |
运行结果为
1 | > g++ main.cc -g -o main && ./main |
dynamic_cast
dynamic_cast运算符,用于将基类指针或引用安全转换为派生类的指针或引用。使用形式如下
dynamic_cast<type *>(e),e必须是一个有效的指针
dynamic_cast<type &>(e),e必须是一个左值
dynamic_cast<type &&>(e),e不能是左值
其中,type必须是一个类类型,并且通常情况下该类型应该含有虚函数。e的类型必须符合如下三个条件中任一一个:
- e的类型是目标type的公有派生类
- e的类型是目标typpe的公有基类
- e的类型就说目标type的类型
1 | class base |
此种情况下,base类没有虚函数,编译器会提示错误信息
1 | the operand of a runtime dynamic_cast must have a polymorphic class typeC/C++(698) |
任一添加虚函数后,程序通过编译(note: 虚函数必须有定义,不能只声明)。
对于转换类型是指针和引用,当转换失败时的行为不同:若一条 dynamic_cast语句的转换目标是指针类型,则结果为0;若转换目标是引用类型,则dynamic_cast运算符抛出bad_cast异常。
1 | class base |
上述例子可以运行成功。下面这个例子中的指针和引用转换失败:
1 |
|
输出结果如下
1 | std::bad_cast |
Ring
ring,即rte_ring,是dpdk中的一种用于存取数据的基础数据结构,本质上是一种无锁实现的环形队列(lock-free)。
Feature
- FIFO
- maximum size是固定的,指针存放在一个table中
- 无锁实现
- 支持多消费者或单消费者出队
- 支持多生产者或单生产者入队
- Bulk operate:入队或出队指定数量的对象,或成功或失败
- Burst operate:入队或出队指定数量的对象,或成功或返回最大可用对象
Advantages
- Faster,操作很快只需要简单的CAS指令
- 比完全无锁实现的队列简单
- 适用于批量入队/出队操作(bulk operations)。通过操作一个table中的指针完成操作,不会造成大量cache miss。
Disadvantages
- 大小固定不够灵活
- 内存使用效率不如链表组织的队列 ,一个空的ring也需要占用内存
Use cases
- DPDK中应用程序的通信
- 用于内存池分配器(见mempool章)
Operations
Single Producer Enqueue
初始状态:
- Step-1: 共享内存中的变量prod_head和cons_tail被复制到本地局部变量prod_head和cons_tail,局部变量prod_next指向表中接下来的一个或几个元素;
- Step-2: 通过cons_tail判断是否没有足够的空闲空间;
- Step-3: 取出prod_head处的元素,通过CAS指令判断是否轮到当前线程继续执行,若是则将全局prod_head的值指向局部变量prod_next,反之继续从第一步开始执行;
中间状态: - Step-4: 修改全局prod_tail的值指向全局prod_head。
最终状态:
Single Consumer Dequeue
初始状态:
Steps:
- Step-1: 将全局cons_head和全局prod_tail复制到本地局部变量cons_head和prod_tail,局部变量cons_next指向table中的下一个或几个元素,如果没有足够空间返回error;
- Step-2: 通过prod_tail判断是否没有足够的空闲空间;
- Step-3: 通过CAS指令判断是否由当前线程执行,若是则修改全局cons_head的值执行局部变量cons_next,反之则重新开始循环,执行第一步;
中间状态: - Step-4: 修改全局cons_tail指向cons_head。
最终状态:
Multiple Producer Enqueue
- Step-1: 将全局prod_head和全局cons_tail复制到本地局部变量prod_head和cons_tail,局部变量prod_next指向table中的下一个或几个元素,如果没有足够空间返回error;
初始状态: - Step-2: 修改全局prod_head的值,使其指向prod_next,通过CAS指令,原子执行如下操作:
- 如果全局prod_head和当前局部变量prod_head不同,则CAS指令执行失败,重新执行第一步;
- 否则,更新全局prod_head指向prod_next,继续执行。
中间状态:
- Step-3: 直到全局的prod_tail等于当前局部变量prod_tail时,更新全局的prod_tail等于全局的prod_head。
最终状态:
Multiple Consumer Dequeue
- Step-1: 将全局prod_tail和全局cons_head复制到本地局部变量prod_tail和cons_head,局部变量cons_next指向table中的下一个或几个元素,如果没有足够空间返回error;
- Step-2: 修改全局cons_head的值,使其指向cons_next,通过CAS指令,原子执行如下操作:
- 如果全局cons_head和当前局部变量cons_head不同,则CAS指令执行失败,重新执行第一步;
- 否则,更新全局cons_head指向cons_next,继续执行。
- Step-3: 直到全局的cons_tail等于当前局部变量cons_tail时,更新全局的cons_tail等于全局的cons_head。
Implementation
struct
两对<head,tail>,包括生产者相关的br_prod_head和br_prod_tail,以及消费者相关的br_cons_head和br_cons_tail,均用volatile修饰,保证每次读取时都是内存中的最新值。
1 | struct buf_ring { |
Single Producer Enqueue
1 | ``` |
Multiple Producer Enqueue
1 | /* |
Multiple Consumer Dequeue
存在多个consumer,通过实现CAS指令功能的atomic_cmpset_int,同步对全局变量的更新操作。有可能存在一个线程,它在当前线程之前已经完成对全局cons_head更新,该线程的局部cons_next的值比当前线程的局部cons_next小,因此该线程需要先执行完毕对全局cons_tail的修改(否则当前线程更新完全局cons_tail后,这个值会被另一位线程修改为一个更小的值)。
1 | * multi-consumer safe dequeue |
Another
为什么同时需要cons_head、cons_tail、prod_head和prod_tail?只需要一个消费者头部cons_head和生产者头部prod_head会有什么问题?
生产者线程首先修改prod_head,但是不会修改prod_tail,可以理解为,生产者“预定”了这个位置,然后生产者在该位置写入指向待入队的对象的地址(指针),最后修改prod_tail可以理解为,生产者线程完成了入队操作。因此,仅仅查看prod_head是否移动无法得知生产者是否真正完成了入队操作。尤其对于消费者线程来说,如果消费者线程通过判断prod_head查看是否对内有元素可使用,则会尝试出队一个,生产者还未写入实际对象指针的位置处的指针。
如下是多消费者场景下的出队操作最后更新cons_tail的代码,为什么需要这个while循环?
1 | // 等待在当前线程更新全局cons_head之前的线程执行完毕 |
该循环等待在当前线程出队元素之前已经尝试出队的线程,完成出队操作。
reference
[1] DPDK官方文档https://doc.dpdk.org/guides-19.11/prog_guide/ring_lib.html
auto decltype
有时候程序中会出现难以拼写的复杂类型,或者是有时候程序不知道所需要的变量的类型是什么,C++提供了类型别名、auto类型说明符和decltype类型指示符以处理相应的情况。
类型别名
类型别名(type alias)是一个名字,是某种类型的同义词,它让复杂的类型名字变得简单易用。
有两种方法可以用于定义类型别名
- 传统方法,使用关键字typedef
- 新标准,使用别名声明,using
类型别名和类型的名字等价。但是如果某个类型别名指代的是复合类型或常量,可能会产生意想不到的结果。1
2
3
4
5
6
7typedef char *pstring;
char ps[] = "hello";
const pstring cstr = ps; // cstr是指向char的常量指针
const char *p = ps; // p是指向常量字符的指针
cstr[0] = 'J';
cout << ps << endl; // output Jello
p[0] = 'K'; // error! cannot change its content
auto类型说明符
有时候需要把一个较为复杂的表达式或者类型难以拼写的变量赋值给一个变量,C++11引入了auto类型说明符,可以让编译器分析表达式的值所属的类型,来推算变量的类型,显然,auto定义的变量必须有初始值。
1 | auto item = val1 + val2; // item初始化为val1和val2相加的结果,根据结果可以推断出item的类型 |
使用auto也能在一条语句中声明多个变量,但语句中所有变量的初始基本数据类型必须一样,即使是可以相互转换的类型也不行。
1 | auto i = 0, *p = &i; // 正确,i是int类型,p是指向int的指针 |
复合类型、常量和auto
编译器推断出来的auto类型有时和初始值的类型并不完全一样,编译器会适当改变结果类型使其更加符合初始化规则。
使用引用其实是使用引用的对象,因此引用作为初始值时,参与初始化的是引用对象的值,编译器以此为auto的类型。
1 | int i = 0, &r = i; |
其次,auto一般会忽略掉顶层const,底层const则会保留。
1 | const int ci = i, &cr = ci; // ci是const int类型,cr是const int&类型 |
如果希望推断出的auto类型是一个顶层const,需要明确指出(如上面例子中的cr):
1 | const auto f = ci; // ci的推演类型是int,f是const int |
可以将引用的类型设为auto,初始值中的顶层const仍然保留。
1 | auto & g = ci; // g是一个整型常量的引用 |
一条语句中定义多个变量时,符合&和*只从属于某个声明符,而非数据类型的一部分。
1 | auto k = ci, &l = i; // k是int,l是int & |
多维数组循环遍历中的auto
c++11新增了范围for语句,因此可以使用其遍历二维数组:
1 | int ia[][4] = {{1,2,3,4}, {5,6,7,8}}; |
除了最内层的循环外,外层循环的控制变量必须是引用类型,否则auto推断类型会把数组转为指针。
decltype类型指示符
有时候希望从一个表达式的类型推断出所需要定义的变量的类型,但又不想用该表达式的值初始化变量。c++11引入了类型说明符decltype,
它的作用是选择并返回操作数的数据类型。编译器并不计算表达式的值。
1 |
|
decltype处理顶层const和引用的方式与auto不同,如果decltype使用的表达式是一个变量,则decltype返回该变量的类型(包括顶层const和引用)。
1 | const int ci = 0, &cj = ci; |
其次,如果decltype使用的表达式不是一个变量,则返回该表达式结果对应的类型,如果表达式结果可赋值,则得到引用类型。
1 | int i = 42, *p = &i, &r=i; |
如果表达式的结果是左值类型,则返回一个引用,注意还有不可修改的左值——数组:
1 | int ia[][4] = {{1,2,3,4}, {5,6,7,8}}; |
对于变量,如果加上了括号,则被当作表达式,而变量是可赋值的特殊表达式,因此会返回引用类型。
1 | decltype(((i))) d; // int & |
另外与auto不同的地方是,当数组作为一个auto变量的初始值时,推断得到的类型是指针,而decltype关键字不会发生上述转换:
1 | int ia[] = {1,2,3,4}; |
Avl Tree
有时候我们需要用一个变量存储一个不变的值,例如缓冲区的大小、数组的元素个数等等。好处是我们可以根据需求很容易对其进行调整,并且也能防止程序不小心修改了这个值,此时可以使用关键字const对变量的类型加以限定。
初始化
const对象一旦创建后,其值就无法修改,因此必须初始化。初始值可以是任何复杂的表达式,包括常量和变量。
1 | int i = 10; // i是一个已初始化的变量 |
输出结果如下:
1 | 6 |
文件作用域
当以编译时初始化的方式定义一个const对象时,如下
1 | const int bufSize = 512; |
编译器将在编译过程中把用到该变量的地方都替换成对应的值。为了执行替换,编译器必须知道变量的初始值,如果程序包含多个文件,则每个用了const对象的文件必须能够访问到它的初始值。因此,就必须在每个用到变量的文件中都有对它的定义,为了支持这一用法,同时避免对同一变量的重复定义,默认情况下,const对象被设定为仅在文件内有效。当多个文件中出现了同名的const变量,其实等同于在不同文件中分别定义了独立的变量。
main.cc中声明了func()函数,其定义在a.cc中,初始化bufSize = 512,在main函数中打印它的值,并调用func()。
1 | // main.cc |
a.cc中定义了func()函数,其输出定义在本文件中的const变量bufSize。
1 | // a.cc |
命令行编译并执行:
1 | rda@pa ~/Downloads> g++ a.cc main.cc -g -o main |
可见,定义在不同文件中的同名const对象是不同的。
若需要在文件之间共享const变量,则对于其声明和定义都添加extern关键字,这样只需定义一次就可以了。
为main.cc中的bufSize添加extern关键字。
1 | // main.cc |
为a.cc中的bufSize添加extern关键字,并舍去其初始值。
1 | // a.cc |
再次编译并执行:
1 | rda@pa ~/Downloads> g++ a.cc main.cc -g -o main && ./main |
此时bufSize在两个文件之间共享。
const的引用
可以把引用绑定到const对象上,称之为对常量的引用。对常量的引用无法修改它所绑定的对象:
1 | const int ci = 1024; |
一般来说,引用的类型必须与其所引用的对象的类型一致,但当初始化常量引用时允许使用任意表达式作为初始值,只要该表达式的结果能转换成引用的类型即可。允许一个常量引用绑定到非常量的对象、字面值甚至是一般表达式:
1 | int i = 42; |
当一个常量引用绑定到另一种类型上时,如下:
1 | double dval = 3.14; |
编译器把上述代码变成如下格式:
1 | const int temp =dval; // temp的值为3 |
这种情况下,ri绑定到了一个临时量(temporary)对象,所谓的临时对象就是当编译器需要一个空间来暂存表达式的求值结果时临时创建的一个未命名的对象。
指针和const
可以令指针指向常量或非常量。指向常量的指针不能用于改变其所指的对象的值,想要存放常量对象的地址,只能使用指向常量的指针。
1 | const double pi = 3.14; |
指针的类型必须与其所指对象的类型一致,但是有两个例外,第一个例外是,允许一个指向常量的指针指向一个非常量对象。
1 | double dval = 3.14; |
所谓指向常量的指针或引用,仅仅是自身无法修改所指向的对象或所绑定的对象,而没有规定那个对象的值无法修改。
指针是对象,而引用不是,因此允许把指针本身定义为常量,即常量指针,意味着不变的是指针本身的值而非指向的地址中存储的值。和其他常量一样,常量指针必须初始化,且一旦初始化完成,则它的值(存放在指针中的地址)不能再改变。
1 | int errNumb = 0; |
顶层const和底层const
对于指针来说,其本身是一个对象,且指向另一个对象(存储该对象的地址),因此指针本身是不是一个常量以及其指向的是不是一个常量对象是独立的。名词顶层const表示指针本身是个常量,而底层const表示指针所指向的对象是一个常量。更为一般的,顶层const可以表示任意的对象是一个常量。
1 | int i = 0; |
拷贝时,两个对象必须具有相同的底层const资格,或者两个对象的数据类型可以转换,一般来说,非常量可以转换成常量,反之则不行。
1 | int *p = p3; // 错误,p3和p的底层const不同且常量无法转换为非常量 |
重载函数中的const
实参初始化形参时会忽略顶层const,当形参含有顶层const时,传递给它常量对象或非常量对象都是可以的,因此如下重载函数会发生错误。
1 |
|
但底层const可以用来区分不同的重载函数,此时,常量对象调用底层const对应版本的函数。
1 | const int i = 3; |
输出结果为:
1 | call const fcn, i = 3 |
注意,如下情况编译不会报错,但运行时会有错误。
1 |
|
无法得知调用的是哪个版本的重载函数。
const限定符
有时候我们需要用一个变量存储一个不变的值,例如缓冲区的大小、数组的元素个数等等。好处是我们可以根据需求很容易对其进行调整,并且也能防止程序不小心修改了这个值,此时可以使用关键字const对变量的类型加以限定。
初始化
const对象一旦创建后,其值就无法修改,因此必须初始化。初始值可以是任何复杂的表达式,包括常量和变量。
1 | int i = 10; // i是一个已初始化的变量 |
输出结果如下:
1 | 6 |
当用i、j和k去初始化a、b和c时,根本无须在意是不是用常量初始化,常量特征仅仅在执行改变该对象的操作时才会发生作用。任何试图改变const对象的内容的行为都是非法的。
1 | a = b; // 错误,不能修改常量的值 |
文件作用域
当以编译时初始化的方式定义一个const对象时,如下
1 | const int bufSize = 512; |
编译器将在编译过程中把用到该变量的地方都替换成对应的值。为了执行替换,编译器必须知道变量的初始值,如果程序包含多个文件,则每个用了const对象的文件必须能够访问到它的初始值。因此,就必须在每个用到变量的文件中都有对它的定义,为了支持这一用法,同时避免对同一变量的重复定义,默认情况下,const对象被设定为仅在文件内有效。当多个文件中出现了同名的const变量,其实等同于在不同文件中分别定义了独立的变量。
main.cc中声明了func()函数,其定义在a.cc中,初始化bufSize = 512,在main函数中打印它的值,并调用func()。
1 | // main.cc |
a.cc中定义了func()函数,其输出定义在本文件中的const变量bufSize。
1 | // a.cc |
命令行编译并执行:
1 | rda@pa ~/Downloads> g++ a.cc main.cc -g -o main |
可见,定义在不同文件中的同名const对象是不同的。
若需要在文件之间共享const变量,则对于其声明和定义都添加extern关键字,这样只需定义一次就可以了。
为main.cc中的bufSize添加extern关键字。
1 | // main.cc |
为main.cc中的bufSize添加extern关键字,并舍去其初始值。
1 | // a.cc |
再次编译并执行:
1 | rda@pa ~/Downloads> g++ a.cc main.cc -g -o main && ./main |
此时bufSize在两个文件之间共享。
const的引用
可以把引用绑定到const对象上,称之为对常量的引用。对常量的引用无法修改它所绑定的对象:
1 | const int ci = 1024; |
一般来说,引用的类型必须与其所引用的对象的类型一致,但当初始化常量引用时允许使用任意表达式作为初始值,只要该表达式的结果能转换成引用的类型即可。允许一个常量引用绑定到非常量的对象、字面值甚至是一般表达式:
1 | int i = 42; |
当一个常量引用绑定到另一种类型上时,如下:
1 | double dval = 3.14; |
编译器把上述代码变成如下格式:
1 | const int temp =dval; // temp的值为3 |
这种情况下,ri绑定到了一个临时量(temporary)对象,所谓的临时对象就是当编译器需要一个空间来暂存表达式的求值结果时临时创建的一个未命名的对象。
指针和const
可以令指针指向常量或非常量。指向常量的指针不能用于改变其所指的对象的值,想要存放常量对象的地址,只能使用指向常量的指针。
1 | const double pi = 3.14; |
指针的类型必须与其所指对象的类型一致,但是有两个例外,第一个例外是,允许一个指向常量的指针指向一个非常量对象。
1 | double dval = 3.14; |
所谓指向常量的指针或引用,仅仅是自身无法修改所指向的对象或所绑定的对象,而没有规定那个对象的值无法修改。
指针是对象,而引用不是,因此允许把指针本身定义为常量。和其他常量对象相同,常量指针必须初始化,且初始化后不能改变指针的值。把*放在const前说明指针本身是一个常量。
1 | int errNum = 0; |
指针本身是一个常量并不意味着无法通过指针修改其指向对象的值,能否这样做取决于其指向的对象是否为常量。
顶层const和底层const
指针本身是一个对象,因此指针本身是否为常量和其指向的对象是否为常量是相互独立的。名词顶层const表示指针本身是常量,名词底层const表示指向的对象是一个常量。
更一般的,顶层const可以表示任何对象是常量,而底层const则与指针和引用等复合类型的基本类型部分有关。
1 | int i = 0; |
执行对象拷贝操作时,常量是顶层const还是底层const有明显区别,顶层const不受影响:
1 | i = ci; |
底层const具有限制作用,拷入和拷出的对象必须具备相同的底层const资格,或者两个对象的数据类型必须能够转换,一般来说,非常量可以转换成常量,反之则不行:
1 | int *p = p3; // 错误,p3包含底层const |
const形参和实参
VS C
c语言中也有const关键字,c++虽然与c保持兼容,但还是存在一定差别,而const关键字上表现出的不同特性就是其一。
本身含义
严格来说,c语言中const修饰的变量并不是常量,只不过其值不允许被改变,因此称为只读变量更合适;因此如下代码是可以通过编译的:
1 | > cat a.c |
可以不初始化const变量的值。
c++中的const修饰的是真正的常量,在compiler-time或者run-time必须被初始化:
1 | > cat a.cc |
文件作用域
c++中const修饰的变量文件作用域为本文件内,即internal linkage,因此两个不同的文件中相同的全局const变量是不相互影响的,通过上述描述也可以看出;而c语言中const修饰的变量具有外部链接,即external linkage。分别准备两个c++源文件(a.cc和b.cc)和两个c文件(a.c和b.c),功能类似:
a.cc
1 |
|
b.cc
1 |
|
a.c
1 |
|
b.c
1 |
|
编译运行c++程序,如下:
1 | > g++ a.cc b.cc -g -o main |
分别打印出各自源文件内定义的const变量的值。
编译运行c程序,如下:
1 | > gcc a.c b.c -g -o main |
提示链接错误:重复定义变量。