基础1:顶层const和底层const

const int* const p = new int(10); 中,同时具有顶层const与底层const。顶层const表示修饰的元素本身不可变,如 const int a = 10;,底层const表示指向的内容不可变,常量引用与常量指针相同,如 const int &ra = 10;

image-20260304193134629

《Primer C++》P58提到,当执行对象的拷贝操作时,常量是顶层const还是底层const区别明显。其中,顶层const不受什么影响;另一方面,底层const的限制却不能忽视。当执行对象的拷贝操作时,拷入和拷出的对象必须具有相同的底层const资格,或者两个对象的数据类型必须能够转换。一般来说,非常量可以转换成常量,反之则不行。

我们可以看到指针pa不是任意一种const,因此并不能被p赋值。我们并不关心pa的指向是否可变,反而更注重pa指向的数据是否可变,也就是底层const,这关系到赋值是否合法。

image-20260304194622112

对于const int a = 10;,虽然a是顶层const,但对a取地址(const int* pb = &a;)时将变为底层const。这是因为常量a从本身不能改变变为指针指向的数据不能改变,因此仍然需要增加底层const来适配。

注:

  1. 引用不是对象也不进行拷贝,不满足上面的原则。
  2. 常量引用在左侧时右侧可以跟任何元素:const int &ra = 10; // ...,但去除const后会报错。有一说法是在常量引用被字面量赋值时会创建一临时变量tmp,引用的是变量的引用&tmp。并且对于常量a是本身适配的,对于变量int b = 10;也仍然适配,意味ra是b的别名且仍然可以对b进行修改。
  3. 用常量给非常量引用赋值会引发报错:int &rb = a; // ERROR,如果允许非常量引用对数据进行修改,则常量失去了意义。
  4. 引用在等号右侧时忽略引用:在忽略&ra&后,ra为常量,这与3是一样的,int &rb = ra; // ERROR。引用本质上是别名,当我们使用引用时,也就是在引用原始的数据。
  5. 非常量可以被常量引用赋值,这是因为顶层const在元素的赋值中不受什么影响。

基础2:值类型与右值引用

在这节开始前,我们做出如下思考:

1
2
3
4
5
int getA(){
int a = 10;
return a;
}
int x = getA();

当我们从函数获取a时,执行了几次拷贝?

答案是2次。当我们从getA中获取数据时,若不进行优化,编译器会先创建一个不具名对象,也叫将亡值(tmp),将a赋值给tmp,最终释放getA的栈帧,将tmp赋值给x,销毁tmp。相当于int tmp = a; int x = tmp;

C++98的表达式类型中,由是否可以取地址分为左值和右值。左值是指向特定内存的具名对象,右值是临时对象,字符串常量除外。

int* p = &x++;int* p = &++x;中,第一个会引发错误。

image-20260306140423413

这是因为我们无法对临时对象取地址,也就是x++无法取地址的原因,而++x直接返回加一后的本身,因此可以进行取地址操作。

值类型

C++11将表达式类型详细的分为泛左值,右值,又将泛左值分为左值和与右值同划分的将亡值,右值除了划分出将亡值外,还划分出纯右值的概念。

image-20260306141149296

类别 英文全称 通俗理解 典型例子
lvalue left value 有名字、可以放在等号左边的 “持久” 值 int a = 10; 中的 a;函数返回左值引用
prvalue pure rvalue 纯临时值,生命周期短暂,通常是计算结果 10a + bstd::string("hello")
xvalue eXpiring value “将亡” 的临时值,生命周期即将结束 std::move(a);函数返回右值引用
glvalue generalized lvalue 泛左值,包含所有有身份的值 lvalue + xvalue
rvalue right value 可以被移动的值,是 “临时” 或 “将亡” 的值 prvalue + xvalue

在研究右值引用前,还需要探讨一个问题,对于赋值操作,在C++中只有拷贝一种方法吗?

右值引用

很明显以高效为著称的一个语言必然有着其他的赋值方式:拷贝,引用和移动。

  • 拷贝意味着我拥有和原数据的全部“经历”,因此十分耗时:int A = 1; int B = A;
  • 引用操作意味着我与原数据共享“经历”,二者捆绑:int rA = &A;
  • 移动操作意味着我把原数据的内容拿过来后,原数据死亡。若原数据不死亡的情况下,需要使用std::move使其死亡:int C = getA(); int B = std::move(A);

那么右值引用和移动语义之间具有什么样的关系?因为左值引用不接受右值,因此出现了右值引用这一概念(Type &&),移动语义(std::move)可以将左值变为右值。

我们可以通过将泛左值转化为将亡值:强制转换 static_cast<type &&>(...); 或使用移动语义std::move(...); ,也可以通过C++17引用的临时量实质化,将纯右值转换为临时对象实现。

移动语义没移动,完美转发不完美。

注:

  1. 即使是纯右值,也可以进行std::move
  2. 若类中未实现移动构造,即使使用std::move也仍然采用拷贝
  3. 右值的引用仍然是左值:int x = 10; int &&z = std::move(x);&z一定可以实现,z作为x的引用,共同指向x的存储地址。
  4. 如果将右值绑定到右值引用上,连移动都不会发生:int &&E = std::move(x); 意为为x创建一个别名,这与 int &rx = x; 是一样的,编译器并不会在创建一个别名的时候做些什么。
  5. 常量引用(const &&):const int x = 10; int &&z = std::move(x); 与基础一提到的(注3)一致,非常量引用被常量赋值时会报错,需要添加底层const。

最后举个例子:

1
2
3
4
int getNum(const int &num){
return num; // num为左值
}
// inr tmp = num; copy返回tmp
1
2
3
4
5
int makeNum(){
int num;
return getNum(num);
}
// int tmp2 = tmp; move返回临时变量tmp2
1
2
int num = makeNum();
// int num = tmp2; 同理move

因此当我们实现移动构造函数后可以大大提升程序的运行效率,否则例子中的一次copy两次move会变为三次copy。

在实践以上内容时,需要关闭编译器的返回值优化(set(CMAKE_CXX_FLAGS "-fno-elide-constructors")),如果关闭禁止返回值优化,即使未实现移动拷贝构造,也只会有一次copy。

基础3:数组指针与函数指针

由于数组的相关数据类型过于复杂,因此C++中建议使用标准模板库替换。在表达式 int array[5] = {1,2,3,4,5}; 的数据类型为 int[5](不是 int*)。

数组指针

对于 int* ptr = array; ,数组名退化为指针,也可以说&array与array表示的意思是一样的。但他与 int (*ptr2)[5] = &array; 不同,后者被称为数组指针,表示数组名取地址的类型,指向数据类型为int,大小为5的数组。

倘若去除括号(int* ptr2[5] = &array;)则表示一个存放指针的数组,被称为指针数组:int *ptr3[5] = {&a,&a,&a,&a,&a};

ptr类型为 int(*)[5],对数组的引用与数组指针类似,为 int (&ref)[5] = array;,数据类型为 int(&)[5];

需要注意的是,C++有指针数组但是没有引用数组。因为引用在等号右侧时会被忽略,这又变成了原值本身,这是不合理的。

在前文提到字符串字面值如“hello world”是左值,且数组可以退化为指针,因此我们可以用指针接受它的地址:const int* p = "hello world"; 同样也可以用数组指针 const char (*strptr)[12] = &"hello world"; 和数组引用 const char (&strref)[12] = "hello world"; 来表示。

在汇编中,当我们要给变量赋值时,只有将值存放进寄存器后进行,因为不进入内存所以不会有地址。若用此方法对字符串常量进行赋值,要先构造字符,再进行拷贝(char作为内置类型没有移动)。赋值成功后被清除,若是一直进行构造则会导致效率低下,因此编译器将字符串字面量放进内存省去了重复构造的过程。因为发生了拷贝,显而易见的是 &“hello world”char str[12] = "hello world"; 拿到的地址并不相同。

hello world 除了空格外,还有空终止字符(’\0’),因此是str[12]而不是str[11],否则会报错 “const char [12]” 类型不能用于初始化 “char [11]” 类型。对于const为什么能省去,详情参照基础1顶层const。请注意 const int* p = "hello world"; 中的const虽然去除后只会发出警告,但这里并不建议去除,因为字符串字面量存储在 .rodata 中,并不能进行修改。

数组名作为参数传递

数组名作为参数传递时会发生变化 void fun(int a[100]);void fun(int a[5]);void fun(int* a); 等价,但与 void fun2(int (*a)[100]);void fun2(int (*a)[5]); 不等价。这是因为数组作为参数传递时会退化为指针,而 void fun2(int (*a)[5]); 传入的数组指针与只传入数组类型不同。

而函数相关的数据类型中,bool fun(int a,int b); 作为函数数据类型bool(int,int),bool (*funptr)(int a,int b);作为函数指针,类型为bool(*)(int,int)。

  • 函数指针的赋值:funptr = &fun; // &可省略
  • 函数指针的使用:bool c = (*funptr)(1,2); // 可省略为funptr(1,2);
  • 函数指针作为形参:void fun2(int c,bool (*funptr)(int,int)); //可省略为void fun2(int c,bool funptr(int,int));
  • 函数指针做返回值:bool (*fun3(int c))(int,int);,需要注意的是,函数指针做返回值时只能用指针返回,不能返回本身。
  • 函数的引用:bool (&funref)(int,int) = fun;

这看起来也太复杂了,特别是函数指针作为返回值时,因此我们接下来引入类型别名的概念。

类型别名

与using比起来,typedef在这里的使用反而令人有些费解:

typedef bool FUNC(int,int); 表示 FUNC == bool(int,int); , typedef bool (*FUNCPTR)(int,int); 表示 FUNCPTR == bool(*)(int,int);

奇怪的点在于类型别名的声明不应该是 typedef bool (*)(int,int) FUNCPTR; 之类的吗?而using则更容易理解:using FUNC = bool(int,int);using FUNCPTR = bool(*)(int,int);,其中 FUNC* == FUNCPTR

接上部分的函数指针做返回值,倘若用类型别名简化后便大大方便了返回值的书写方式:FUNC* fun3(int c);FUNCPTR fun3(int c);

条款1:理解模板类型推导

在开始这一章节前,还需要补充一些知识。

  1. template<typename T>void f(const T param);void f(T const param); 等价,如果 T 推导为 int*,则两者均为顶层const。

  2. 顶层const不构成重载,如 fun(int a);fun(const int a);

  3. 指针的引用表示为:int* a = &b; int *&c = a;

  4. 在指针与函数引用中,函数指针的底层const只能用类型别名表示:int func(int a){ return 10; },若函数指针定义为 const int (*funcptr)(int) = func; 则会报错 “int(*)(int)" 类型不能用于初始化 “const int(*)(int)“ 类型。他的意思为int作为传入的参数,const int做返回值的 const int (*funptr)(int) = func;int const (*funptr)(int) = func; 均不能被正确定义。

    CPP认为函数具有语法原子性,不可分割,函数天然不可改变,所以函数指针不需要底层const。

    若仍然想要使用底层const呢?可以考虑类型别名的方法:using T = int(int); 后,便可以使用顶层与底层const。

    另外,函数引用的底层const会被编译器忽略。

在模板中:

image-20260310170939258

ParamType分别为:TT*T&T&&const Tconst T*const T&const T&&T* constconst T* const

image-20260310173333093

这是因为顶层const不构成重载,因此只讲述黑框中的一个类型。

1
template<Typename T>

对于:void f(T param){ std::cout << param; }

语句 推导 类型
int a = 10; int void f<int>(int param)
int *aptr = &a; int* void f<int*>(int* param)
int &aref = a; int void f<int>(int param)
int &&arref = std::move(a); int void f<int>(int param)

值传递时会发生 引用消失(reference removed),因此 int&int&& 都被推导为 int

语句 推导 类型
const int ca = 10 int void f<int>(int param)
const int *captr = &ca; const int* void f<const int*>(const int* param)
const int &caref = ca; int void f<int>(int param)
const int &&caref = std::move(ca); int void f<int>(int param)
int* const acptr = &a; int* void f<int*>(int* param)
const int* const cacptr = &ca; const int* void f<const int*>(const int* param)
10 int void f<int>(int param)

const int ca = 10 中,顶层const被忽略,因此推导为int,const int *captr = &ca; 底层则不能忽略。const int &caref = ca;const int &&caref = std::move(ca); 均为引用且是顶层const,因此推导出 int,int* const acptr = &a; 为顶层const,推导出int。

语句 推导 类型
int array[2] = {0,1}; int* void f<int*>(int* param)
"hello world" const char* void f<const int*>(const char *param)
int (*arrayptr)[2] = &array; int(*)[2] void f<int (*)[2]>(int (*param)[2])
int (&arrayref)[2] = array; int* void f<int*>(int* param)
func; // void(int,int) void(*)(int,int) void f<void(*)(int,int)>(void (*param)(int,int))
void (*funcptr)(in,int) = func; void(*)(int,int) void f<void(*)(int,int)>(void (*param)(int,int))
void (&funcref)(int,int) - func; void(*)(int,int) void f<void(*)(int,int)>(void (*param)(int,int))

int array[2] = {0,1}; 退化为指针,推导出int*"hello world" const数组退化为const int*指针。int (*arrayptr)[2] = &array; 不过数组指针不会退化,为int(*)[2]int (&arrayref)[2] = array; 也会退化,和原始的没有区别。func; // void(int,int) 函数会退化为指针,void (*funcptr)(in,int) = func; 不会退化,引用则是被编译器忽略,同上。

对于:void f(T *param){ std::cout << param; }

语句 推导 类型
int a = 10; - -
int *aptr = &a; int void f<int>(int* param)
int &aref = a; - -
int &&arref = std::move(a); - -

T* 表示 param 必须传入一个指针,int a = 10; 传入报错,int *aptr = &a; 则退出 int,与 * 共同组合成 ParamType。int &aref = a; int &&arref = std::move(a);int a = 10; 相同,均应该传入一个指针。

语句 推导 类型
const int ca = 10 - -
const int *captr = &ca; const int* void f<const int>(const int* param)
const int &caref = ca; - -
const int &&caref = std::move(ca); - -
int* const acptr = &a; int void f<int>(int* param)
const int* const cacptr = &ca; const int* void f<const int>(const int* param)
10 - -

const int ca = 10 const int &caref = ca; const int &&caref = std::move(ca); 均报错,const int* const cacptr = &ca; 中底层const不能忽略,推导出const int,ParamType为const int*int* const acptr = &a; 中顶层const可以忽略,推出int*const int* const cacptr = &ca; 与底层const一样,传入10则报错。

语句 推导 类型
int array[2] = {0,1}; int void f<int>(int* param)
"hello world" const char void f<const char>(const char* param)
int (*arrayptr)[2] = &array; int[2] void f<int[2]>(int (*param)[2])
int (&arrayref)[2] = array; int void f<int>(int* param)
func; // void(int,int) void(int,int) void f<void(int,int)>(void (*param)(int,int))
void (*funcptr)(int,int) = func; void(int,int) void f<void(int,int)>(void (*param)(int,int))
void (&funcref)(int,int) = func; void(int,int) void f<void(int,int)>(void (*param)(int,int))

int array[2] = {0,1}; 退化为指针,T推出int,ParamType为int*"hello world" 退化为 const char*,底层const不可忽略。func; 退化为指针,T推出void(int,int),推导为void(*param)(int,int)

对于:void f(T &param){ std::cout << param; }

语句 推导 类型
int a = 10; int void f<int>(int& param)
int *aptr = &a; int* void f<int*>(int*& param)

int a = 10; 中T为int,ParamType推出为引用,int *aptr = &a; 中T为int*类型,则会有 int*&param 的指针引用的类型。

语句 推导 类型
const int ca = 10 const int void f<const int>(const int& param)
const int *captr = &ca; const int* void f<const int*>(const int*& param)
int* const acptr = &a; int* const void f<int* const>(int* const& param)
const int* const cacptr = &ca; const int* const void f<const int* const>(const int* const& param)
10 - -

const int ca = 10 中虽然拷贝时为顶层const,但随着引用被转换为底层,所以T必须是const int。const int *captr = &ca; 同理,推导为 const int*,后加&int* const acptr = &a; 顶层const推出 int* const &param,顶层底层const都存在则都推导:const int* const。 传入10会因为用右值给左值引用赋值而报错。

语句 推导 类型
int array[2] = {0,1}; int[2] void f<int[2]>(int (¶m)[2])
"hello world" const char[12] void f<const char[12]>(const char (¶m)[12])
int (*arrayptr)[2] = &array; int (*)[2] void f<int (*)[2]>(int (*¶m)[2])
func; // void(int,int) void(int,int) void f<void(int,int)>(void (¶m)(int,int))
void (*funcptr)(int,int) = func; void(*)(int,int) void f<void(*)(int,int)>(void (*¶m)(int,int))
void (&funcref)(int,int) = func; void(int,int) void f<void(int,int)>(void (¶m)(int,int))

int array[2] = {0,1}; 中数组的引用不会退化,因此为 int(&param)[12] 而不是 int[12]"hello world"const char (&param)[12]void (*funcptr)(in,int) = func; 为指针的引用。

对于:void f(const T *param){ std::cout << param; }

语句 推导 类型
int a = 10; - -
int *aptr = &a; int void f<int>(const int* param)
int &aref = a; - -
int &&arref = std::move(a); - -

int a = 10; 报错,int *aptr = &a; 由于是拷贝,所以可以将非常量指针赋给常量,T为int,推出const int*

语句 推导 类型
const int ca = 10 - -
const int *captr = &ca; int void f<int>(const int* param)
const int &caref = ca; - -
const int &&caref = std::move(ca); - -
int* const acptr = &a; int void f<int>(const int* param)
const int* const cacptr = &ca; int void f<int>(const int* param)
10 - -

const int *captr = &ca; 为底层const,但由于模板本身为底层const,因此推导为int。int* const acptr = &a; 顶层const被忽略,仍为const int*

语句 推导 类型
int array[2] = {0,1}; int void f<int>(const int* param)
"hello world" char void f<char>(const char* param)
int (*arrayptr)[2] = &array; int[2] void f<int[2]>(const int (*param)[2])
int (&arrayref)[2] = array; int void f<int>(const int* param)
func; // void(int,int) void(int,int) void f<void(int,int)>(void (*param)(int,int))
void (*funcptr)(int,int) = func; void(int,int) void f<void(int,int)>(void (*param)(int,int))
void (&funcref)(int,int) = func; void(int,int) void f<void(int,int)>(void (*param)(int,int))

func; // void(int,int) 函数因为会退化为指针,而函数指针的底层const只能用类型别名表示,因此T什么都推不出来,报错。

对于:void f(const T &param){ std::cout << param; }

因为常量引用在左侧,因此可以传入任何值。func; 虽不会退化,但函数的底层const会被忽略,因此推导为void f<void (int,int)>(void (&param)(int,int))

对于:void f(T &&param){ std::cout << param; }

需要引入引用折叠的概念:int&与int&&可以折叠为int&,如int a = 10;,f(a) 中T被推导为int&,类型为int &param,三个引用引发变量折叠。而const T&&并不是一个万能引用,而是右值引用。