本篇是对 C++11-C++23 的学习整理,主要参考
cppreference 《现代 C++ 语言核心特性解析》
auto
& decltype
c++11 引入了 auto
和 decltype
关键字,提供了在编译期进行类型推导的能力
auto
: 用于推导出变量的类型const
和&
需要手动添加decltype(auto)
同理,但遵循 decltype 的推导规则,主要解决auto
无法表达引用类型的问题,must be the sole constituent of the declared type
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
// auto x = expr auto i = 0; // i is int auto& i0 = i; // i0 is int& auto&& i1 = 0; // i1 is int&& auto* p_i0 = &i; // p_i0 is int* auto p_i1 = &i; // p_i1 is int* auto j = 0.0; // j is double decltype(auto) j0 = j; // j0 is double --> decltype(j) is double decltype(auto) j1 = (j); // j1 is double& --> decltype((j)) is double& // The placeholder decltype(auto) must be the sole constituent of the declared type // new expression double* p = new double[]{1, 2, 3}; // creates an array of type double[3] auto p = new auto('c'); // creates a single object of type char. p is a char* auto q = new std::integral auto(1); // OK: q is an int* auto q = new std::floating_point auto(true) // ERROR: type constraint not satisfied auto r = new std::pair(1, true); // OK: r is a std::pair<int, bool>* auto r = new std::vector; // ERROR: element type can't be deduced // auto& f(); template<class T, class U> auto add(T t, U u) { return t + u; } // the return type is the type of operator+(T, U) // in the parameter declaration of a non-type template parameter template<auto n> // C++17 auto parameter declaration auto f() -> std::pair<decltype(n), decltype(n)> // auto can't deduce from brace-init-list { return {n, n}; } // function auto (*p)() -> int; // declares p as pointer to function returning int auto (*q)() -> auto = p; // declares q as pointer to function returning T, where T is deduced from the type of p
decltype ( entity )
&decltype ( expression )
: 用于推导表达式类型- decltype is useful when declaring types that are difficult or impossible to declare using standard notation, like lambda-related types or types that depend on template parameters.
- 如果加上括号,编译器会将其视为 expression,否则视为 entity
- if the value category of expression is xvalue, then decltype yields T&&;
- if the value category of expression is lvalue, then decltype yields T&;
- if the value category of expression is prvalue, then decltype yields T.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
const int &i = 0; decltype(i) j = 0; // j is const int& // perfect forwarding of a function call must use decltype(auto) // in case the function it calls returns by reference --> 如果写 auto,则无法表示引用类型 template<class F, class... Args> decltype(auto) PerfectForward(F fun, Args&&... args) { return fun(std::forward<Args>(args)...); } template<typename T, typename U> auto add(T t, U u) -> decltype(t + u) { // 这里只能后置返回类型,因为t和u的作用域在后面 return t + u; }
trailing return type
C/C++ 正常的函数声明都是返回类型前置的,但是在 C++11 中引入了后置返回类型 auto func() -> int
,主要用于解决函数返回类型依赖于参数类型的情况
使用
auto
和decltype
时 –> 受到用于类型推导的局部变量(参数)作用域的限制,只能后置1 2 3 4
template<typename T, typename U> auto add(T t, U u) -> decltype(t + u) { return t + u; }
函数指针作为返回值,需要后置返回类型 –> 函数指针的返回类型建议后置,不然容易产生歧义
1
auto fpif(int)->int(*)(int)
defaulted and deleted functions
default
: 用于指定函数的默认实现delete
: 用于指定函数的删除实现- default 和 delete 都是函数定义,与其他同名函数之间构成重载关系
- 五之法则:用户定义的析构函数、复制构造函数、复制赋值运算符的存在会阻止移动构造函数和移动赋值运算符的隐式定义 –> 任何想要移动语义的类必须声明全部五个特殊成员函数。
1 2 3 4 5 6 7 8 9
struct Test { Test(){} Test(const Test&) = delete; }; int main(){ Test t1; Test t2(std::move(t1)); // error: use of deleted function ‘Test::Test(const Test&)’ // 显示弃置的复制构造实际上也阻止了移动构造函数的隐式定义 }
final specifier and override specifier
这个特性比较简单,主要用于让我们在写继承类和重写虚函数时更加安全
override
: 用于指定虚函数必须重写基类的虚函数1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
struct Base { virtual void doSomething(int i) const { std::cout << "This is from Base with " << i << std::endl; } }; struct Derivied : Base { virtual void doSomething(int i) { std::cout << "This is from Derived with " << i << std::endl; } }; void letDoSomething(Base& base) { base.doSomething(419); } int main() { Derived d; letDoSomething(d); //输出结果: "This is from Base with 419" }
上述问题你或许遇到过,
Derived::doSomething
函数把Base::doSomething
的const
给搞丢了。所以他们两者并没有相同的函数签名,前者也没有如我们预想的对后者进行覆写。加上
override
之后,编译器就会报错,告诉我们这个函数并没有覆写基类的虚函数。1 2 3 4 5
struct Derivied : Base { virtual void doSomething(int i) override { std::cout << "This is from Derived with " << i << std::endl; } };
此外,作为一个覆写方法,使用
override
关键字后可以省略virtual
关键字。final
: 用于指定类或者虚函数不可被继承或者重写1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
struct Base { virtual void doSomething(int i) const { std::cout << "This is from Base with " << i << std::endl; } private: virtual int dontChangeMe() const = 0; }; struct Derived: Base { void doSomething(int i) const override { std::cout << "This is from Derived with " << i << std::endl; } private: int dontChangeMe() const final { return 419; } }; struct Derived2: Derived { private: // int dontChangeMe() const override { return 61; } };
lvalue, rvalue, xvalue, glvalue, prvalue
C++ 的复杂性很大程度上就在于,它提供了太多的内存模型—-
- 你有太多的地方能放对象:栈、堆、全局数据区
- 太多的方式去访问对象:变量访问,指针访问,引用访问
- 使用指针可以避免创建临时对象
- 使用(左值)引用可以避免指针带来的危险
- 太多的方式去传递对象:值传递,引用传递,指针传递
- 及其复杂的内存顺序模型:memory order
相比之下rust的内存模型就简单很多,一定意义上可以说rust的内存模型是c++内存模型的一个完备子集。
这一节仅仅只是对左值和右值相关概念的一个归纳,如果缺乏编译原理相关的基础知识可能还是会有些难以理解。
左值引用
左值引用是C++编程中非常常见的特性之一,它的出现让C++能一定程度上避免使用危险的指针。
左值引用如果加上const就是常量左值引用,它有一个很有趣的特性就是还能够引用右值,比如 const int & x = 5;
,例子中语句结束后,右值5的生命周期会被延长。
这个特性也许显得有些匪夷所思,那么不妨看看下面这段函数声明:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct Foo {
Foo() {}
Foo(const Foo &) {}
Foo& operator=(const Foo &) { return *this; }
};
Foo makeFoo() { return Foo(); }
int main() {
Foo f1;
Foo f2(f1);
Foo f3(makeFoo());
f3 = makeFoo();
}
这里构造函数需要接收一个常量引用作为参数,如果我们调用Foo函数时传入一个右值,那么这个右值的生命周期会被延长,这样foo函数就能够安全的使用这个右值了。
右值引用
右值引用是一种能且只能引用右值的方法。它更像是一种为了对右值实现移动语义而出现的语法糖,用于自动走右值的那个构造或者赋值函数,从而减少一部分额外的构造析构过程中(new和delete)的开销。
关于移动语义和具体例子请看下面两节。如果学习过rust的应该能联想到rust中的设计,rust中几乎每一个传参调用都是move语义。
概念详解
左值和右值的概念早在C++98的时候就出现了,字面意义上来说,expression (表达式)左边的值就是左值,而表达式右边的就是右值,但很明显左值也可能出现在表达式右边。所以想要准确区分左值右值还需理解其内在含义,只是当时这些概念对写代码来说并不重要。
后来C++11中出现了右值引用,于是值类别被赋予了新的含义,不过直到C++17标准中才对值的类别进行了明确的定义。
值的类别是表达式的一种属性,我们常说的左值和右值实际上指的是表达式。表达式首先被分为了泛左值和右值:
- expression 表达式:分为 glvalue 和 rvalue
- glvalue (generalized lvalue) 泛左值:指一个通过评估(evaluate)能够确定对象/位域/函数的标识的表达式 –> 简单来说,它确定了对象或者函数的标识 identifier(具名对象,可以取地址)
- lvalue
- xvalue (expiring value) 将亡值:表示资源可以被重用的对象和位域,通常是因为他们接近其生命周期的末尾,另外也有可能是经过右值引用的转换产生的。 –> 临时对象,可以取地址
- rvalue
- prvalue (pure rvalue) 纯右值:指一个通过评估(evaluate)能够用于初始化对象和位域,或者能够计算运算符操作数的值的表达式 –> 临时对象,不可以取地址
- xvalue
- glvalue (generalized lvalue) 泛左值:指一个通过评估(evaluate)能够确定对象/位域/函数的标识的表达式 –> 简单来说,它确定了对象或者函数的标识 identifier(具名对象,可以取地址)
例如,表达式i++
是先拷贝一个临时值作为返回值,即得到的是将亡值,表达式++i
是返回的自己,即左值。
xvalue
将亡值是比较重要的一个概念。将亡值一般产生自临时变量,属于右值的范畴,但可以通过右值引用延长其生命周期并接着使用,因此也属于泛左值。
本质上说,产生 xvalue 的途径有两种,第一种是使用类型转换将泛左值转换为其右值引用 static_cast<BigMemoryPool&&>(my_pool)
;
第二种就是临时量实质化(c++17引入),指的是纯右值转换为一个临时对象的过程。例如:
1
2
3
4
5
6
struct X {
int a;
};
int main() {
int b = X().a;
}
上述 X()
是一个纯右值,访问其成员变量a需要一个泛左值。在 b = X().a
这个表达式中,发生了一次临时变量实质化,X()
被转换为一个将亡值,然后才能访问a。
std::move
std::move
的作用是将一个泛左值转换为一个将亡值,这个将亡值可以被绑定到一个右值引用上,从而实现对一个左值的移动操作(通过移动构造函数)。
注意,单纯地将一个左值转换到另外一个左值没有什么意义,正确的使用场景是在一个右值被转换为左值后需要再次转换为右值,最典型的例子就是一个右值作为实参传递到函数中,形参变成了一个左值。例如:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct X {
int a;
X() : a(255) {}
X(const X&) {}
X(X&&) {}
~X(){}
};
void moveX(X&& x) {
// 无论实参是左值还是右值,其形参都是左值
X x2(std::move(x));
// X x2(static_cast<X&&>(x));
}
int main (){
moveX(X());
}
上面的例子中,函数moveX
的形参是一个右值引用,表示这里需要接收一个右值,但是在使用形参x的时候它是一个左值,因此为了能够调用移动构造函数,需要用 std::move
将其转换为右值。
万能引用 & 引用折叠
- 常量左值引用既可以引用左值又可以引用右值,可惜其具有常量性质,使用会受到限制
- c++11 引入了万能引用,即
T&&
,它可以引用左值也可以引用右值,但是它的具体类型取决于实参的类型
1
2
3
4
5
6
7
void foo(int &&i){} // 右值引用
template<class T>
void bar(T &&t){} // 万能引用
int get_val() { return 0; }
int &&x = get_val(); // 右值引用
auto &&y = get_val(); // 万能引用
所谓万能引用,其本质就是发生了类型推导,在 T&&
和 auto&&
的初始化过程中都会发生类型推导。这个推导过程中,如果源对象是左值,那么推导出来的类型就是左值引用,如果源对象是右值,那么推导出来的类型就是右值引用。
万能引用能够这么灵活引用的原因就在于C++11中新增了一套引用折叠规则:
模板类型 | T 实际类型 | 最终类型 |
---|---|---|
T& | R | R& |
T& | R& | R& |
T& | R&& | R& |
T&& | R | R&& |
T&& | R& | R& |
T&& | R&& | R&& |
PS:只要有左值参与,结果就是左值引用,否则就是右值引用。
std::forward
实现完美转发的时候需要利用引用折叠规则,并使用 static_cast<T &&>
进行类型转换,从而保留引用的类型。
1
2
3
4
5
6
7
8
9
template<typename T>
void show_type(T t) {
std::cout << typeid(t).name() << std::endl;
}
template<typename T>
void perfect_forwarding(T &&t) {
show_type(static_cast<T &&>(t));
}
和移动语义一样,c++11 包装了一个更为便捷的方法 std::forward
。
目前为止,C++中跟左值右值相关的概念已经基本介绍完了,可以试试下面这个更详细的示例。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#include <iostream>
#ifndef TYPE
#define TYPE int
#endif
struct Integer {
Integer(int i) : i_(i) {}
Integer(const Integer &i) : i_(i.i_) { std::cout << "copy" << std::endl; }
Integer(Integer &&i) : i_(i.i_) { std::cout << "move" << std::endl; }
int i_;
};
void PrintV(TYPE &t) { std::cout << "lvalue" << std::endl; }
void PrintV(TYPE &&t) { std::cout << "rvalue" << std::endl; }
template <typename T>
void Test(T &&t) {
std::cout << "----------------- " << &t << std::endl;
PrintV(t);
// PrintV(static_cast<T&&>(t));
PrintV(std::forward<T>(t));
PrintV(std::move(t));
}
int main() {
Test(TYPE(1)); // lvalue rvalue rvalue
TYPE a = 1;
std::cout << &a << std::endl;
Test(a); // lvalue lvalue rvalue
Test(std::forward<TYPE>(a)); // lvalue rvalue rvalue
Test(std::forward<TYPE &>(a)); // lvalue lvalue rvalue
Test(std::forward<TYPE &&>(a)); // lvalue rvalue rvalue
return 0;
}
编译器提供的的隐式移动
在编译器升级到新标准之后,有可能会发现程序运行的性能提升了。比如新标准的编译器会在一定情况下将隐式复制修改为隐式移动,从而提升程序的性能,例如经典的 RVO 优化。
想要观察到这种优化现象,注意需要提供-fno-elide-constructors
编译选项,关闭默认的返回值优化,不然看不到什么差别。
1
2
3
4
5
6
7
8
9
10
struct X {
X() = default;
X(const X&) { std::cout << "copy" << std::endl; }
X(X&&) { std::cout << "move" << std::endl;}
~X(){ std::cout << "destructor" << std::endl; }
};
X f(X x) { return x; }
int main() {
X x = f(X{});
}
有兴趣的话可以上 compiler explorer 查看编译器配合各种编译选项时的优化情况。
总结
右值引用是现代C++中一个极其重要的概念,它最为重要的作用就是提高了C++在对象数据转移时的执行效率,同时也增强了模板的功能,堪称C++11中最重要的特性没有之一。