C++11 新特性
#01 auto 与 decltype
auto: 对于变量,指定要从其初始化器⾃动推导出其类型。⽰例:
auto a = 10; // 自动推导 a 为 int
auto b = 10.2; // 自动推导 b 为 double
auto c = &a; // 自动推导 c 为 int*
auto d = "xxx"; // 自动推导 d 为 const char*
decltype: 推导实体的声明类型,或表达式的类型。为了解决 auto 关键字只能对变量进⾏类型推导的缺陷⽽出现。⽰例:
int a = 0;
decltype(a) b = 1; // b 被推导为 int 类型
decltype(10.8) c = 5.5; // c 被推导为 double 类型
decltype(c + 100) d; // d 被推导为 double
struct { double x; } aa;
decltype(aa.x) y; // y 被推导为 double 类型
decltype(aa) bb; // 推断匿名结构体类型
C++11 中 auto 和 decltype 结合再借助「尾置返回类型」还可推导函数的返回类型。⽰例:
// 利⽤ auto 关键字将返回类型后置
template<typename T, typename U>
auto add1(T x, U y) -> decltype(x + y) {
return x + y;
}
从 C++14 开始⽀持仅⽤ auto 并实现返回类型推导,见下⽂ C++14 章节。
#02 defaulted 与 deleted 函数
在 C++ 中,如果程序员没有⾃定义,那么编译器会默认为程序员⽣成 「构造函数」、「拷贝构造函数」、「拷贝赋值函数」 等。
但如果程序员⾃定义了上述函数,编译器则不会⾃动⽣成这些函数。
⽽在实际开发过程中,我们有时需要在保留⼀些默认函数的同时禁⽌⼀些默认函数。
例如创建 「不允许拷贝的类」 时,在传统 C++ 中,我们经常有如下的惯例代码:
// 除非特别熟悉编译器自动生成特殊成员函数的所有规则,否则意图是不明确的
class noncopyable {
public:
// 由于下⽅有⾃定义的构造函数(拷⻉构造函数)
// 编译器不再⽣成默认构造函数,所以这⾥需要⼿动定义构造函数
// 但这种⼿动声明的构造函数没有编译器⾃动⽣成的默认构造函数执⾏效率⾼
noncopyable() {};
private:
// 将拷⻉构造函数和拷⻉赋值函数设置为 private
// 但却⽆法阻⽌友元函数以及类成员函数的调⽤
noncopyable(const noncopyable&);
noncopyable& operator=(const noncopyable&);
};
传统 C++ 的惯例处理⽅式存在如下缺陷:
- 由于⾃定义了「拷贝构造函数」,编译器不再⽣成「默认构造函数」,需要⼿动的显式定义「无参构造函数」
- ⼿动显式定义的「无参构造函数」效率低于「默认构造函数」
- 虽然「拷贝构造函数」和「拷贝赋值函数」是私有的,对外部隐藏。但⽆法阻⽌友元函数和类成员函数的调⽤
- 除⾮特别熟悉编译器⾃动⽣成特殊成员函数的所有规则,否则意图是不明确的
为此,C++11 引⼊了 default 和 delete 关键字,来显式保留或禁止特殊成员函数:
class noncopyable {
public:
noncopyable() = default;
noncopyable(const noncopyable&) = delete;
noncopyable& operator=(const noncopyable&) = delete;
};
#03 final 与 override
在传统 C++ 中,按照如下⽅式覆盖⽗类虚函数:
struct Base {
virtual void foo();
};
struct SubClass: Base {
void foo();
};
上述代码存在⼀定的隐患:
- 程序员并⾮想覆盖⽗类虚函数,⽽是 定义了⼀个重名的成员函数。由于没有编译器的检查导致了意外覆盖且难以发现
- ⽗类的虚函数被删除后,编译器不会进⾏检查和警告,这可能引发严重的错误
为此,C++11 引⼊ override 显式的声明要覆盖基类的虚函数,如果不存在这样的虚函数,将不会通过编译:
class Parent {
virtual void watchTv(int);
};
class Child : Parent {
virtual void watchTv(int) override; // 合法
virtual void watchTv(double) override; // 非法,父类没有此虚函数
};
⽽ final 则终⽌虚类被继承或虚函数被覆盖:
class Parent2 {
virtual void eat() final;
};
class Child2 final : Parent2 {}; // 合法
class Grandson : Child2 {}; // 非法,Child2 已经 Final,不可被继承
class Child3 : Parent2 {
void eat() override; // 非法,foo 已 final
};
#04 尾置返回类型
看一个比较复杂的函数定义:
// func1(int arr[][3], int n) 为函数名和参数
// (* func1(int arr[][3], int n)) 表示对返回值进⾏解引⽤操作
// (* func1(int arr[][3], int n))[3] 表示返回值解引⽤后为⼀个⻓度为 3 的数组
// int (* func1(int arr[][3], int n))[3] 表示返回值解引⽤后为⼀个⻓度为 3 的 int 数组
int (* func1(int arr[][3], int n))[3] {
return &arr[n];
}
C++11 引⼊「尾置返回类型」,将「函数返回类型」通过 -> 符号连接到函数后面,配合 auto 简化上述复杂函数的定义:
// 返回指向数组的指针
auto fun1(int arr[][3], int n) -> int(*)[3] {
return &arr[n];
}
尾置返回类型经常在 「lambda 表达式」、「模板函数返回」中使⽤:
// 使⽤尾置返回类型来声明 lambda 表达式的返回类型
[capture list] (params list) mutable exception->return_type { function body }
// 在模板函数返回中结合 auto\decltype 声明模板函数返回值类型
template<typename T, typename U>
auto add(T x, U y) -> decltype(x + y) {
return x + y;
}
#05 右值引⽤
何为左值与右值
- 左值:内存中有确定存储地址的对象的表达式的值
- 右值:所有不是左值的表达式的值。右值可分为「传统纯右值」和「将亡值」
上述的「传统纯右值」和「将亡值」又是什么?
-
纯右值:即 C++11 之前的右值。包括:
- 常见的字面量如 0、"123"、或表达式为字面量
- 不具名的临时对象,如函数返回临时对象
-
将亡值:随着 C++11 引入的右值引用而来的概念。包括:
- 「返回右值引用的函数」的返回值。如返回类型为 T&& 的函数的返回值
- 「转换为右值引用的转换函数」的返回值,如 std::move() 函数的返回值
同时,左值 + 将亡值又被称为「泛左值」。这几个概念对于刚接触的同学可能会比较混乱,我们梳理一下,如下图所示:
左值还是右值可以通过取地址运算符 & 来进⾏判断,能够通过 & 正确取得地址的为左值,反之为右值。
int i = 0;
int* p_i = &i; // 可通过 & 取出地址,固 i 为左值
cout << p_i << endl;
int* p_i_plus = &(i + 1); // 非法,i + 1 为右值
int* p_i_const = &(0); // 非法,0 为右值
何为左值引用与右值引用
C++11 之前,我们就经常使⽤对左值的引⽤,即左值引⽤,使用 & 符号声明:
int j = 0;
int& ref_j = j; // ref_j 为左值引⽤
int& ref_ret = getVal(); // ref_ret 为左值引用
int& ref_j_plus = j + 1; // ⾮法,左值引⽤不能作⽤于右值
int& ref_const = 0; // 非法,左值引用不能作用于右值
如上例代码所示,ref_j_plus 和 ref_const 为传统 C++ 中经常使用的左值引用,无法作用于 j+1 或 0 这样的右值。
C++11 引⼊了针对右值的引⽤,即右值引⽤,使用 && 符号声明:
int&& ref_k_plus = (i + 1); // ref_k_plus 为右值引用,它绑定了右值 i + 1
int&& ref_k = 0; // ref_k 为右值引用,它绑定了右值 0
右值引用的特点
以下述代码为例:
int getVal() {
return 1;
}
int main() {
// 这里存在两个值:
// 1. val(左值)
// 2. getVal() 返回的临时变量(右值)
// 其中 getVal() 返回的临时变量赋值给 val 后会被销毁
int val = getVal();
return 0;
}
上述代码中,getVal 函数产⽣的 「临时变量」 需要先复制给左值 val,然后再被销毁。
但是如果使⽤右值引⽤:
// 使用 && 来表明 val 的类型为右值引用
// 这样 getVal() 返回的临时对象(右值) 将被「续命」
// 拥有与 val 一样长的生命周期
int&& val = getVal();
上述代码体现了右值引⽤的第⼀个特点:
通过右值引⽤的声明,右值可「重⽣」,⽣命周期与右值引⽤类型的变量⽣命周期⼀样长。
再看如下例⼦:
template<typename T>
void f(T&& t) {}
f(10); // t 为右值
int x = 10;
f(x); // t 为左值
上述例⼦体现了右值引⽤的第⼆个特点:
在 ⾃动类型推断(如模板函数等)的场景下,T&& t 是未定的引⽤类型,即 t 并⾮⼀定为右值。如果它被左值初始化,那么 t 就为左值。如果它被右值初始化,则它为右值。
正是由于上述特点,C++11 引入右值引⽤可以实现如下⽬的:
- 实现移动语义。解决临时对象的低效率拷贝问题
- 实现完美转发。解决函数转发右值特征丢失的问题
右值引⽤带来的移动语义
在 C++11 之前,临时对象的赋值采⽤的是低效的拷贝。
举例来讲,整个过程如同将⼀个冰箱⾥的⼤象搬到另⼀个冰箱,传统 C++ 的做法是第⼆个冰箱⾥复制⼀个⼀摸⼀样的⼤象,再把第⼀个冰箱的⼤象销毁,这显然不是⼀个⾃然的操作⽅式。
看如下例⼦:
class HasPtrMem1 {
public:
HasPtrMem1() : d(new int(0)) {}
~HasPtrMem1() { delete d; }
int* d;
};
int main() {
HasPtrMem1 a1;
HasPtrMem1 b1(a1);
cout << *a1.d << endl;
cout << *b1.d << endl;
return 0;
}
上述代码中 HasPtrMem1 b(a)
将调⽤编译器默认⽣成的「拷贝构造函数」进⾏拷贝,且进⾏的是按位拷贝(浅拷贝),这将导致悬挂指针问题[1]。
悬挂指针问题[1]: 上述代码在执⾏ main 函数后,将销毁 a、b 对象,于是调⽤对应的析构函数执⾏ delete d 操作。但由 于 a、b 对象中的成员 d 指针同⼀块内存,于是在其中⼀个对象被析构后,另⼀个对象中的指针 d 不再指向有效内存,这个对象的 d 就变成了悬挂指针。
在悬挂指针上释放内存将导致严重的错误。所以针对上述场景必须进⾏深拷贝:
class HasPtrMem2 {
public:
HasPtrMem2() : d(new int(0)) {}
HasPtrMem2(const HasPtrMem2& h) :
d(new int(*h.d)) {}
~HasPtrMem2() { delete d; }
int* d;
};
int main() {
HasPtrMem2 a2;
HasPtrMem2 b2(a2);
cout << *a2.d << endl;
cout << *b2.d << endl;
return 0 ;
}
在上述代码中,我们⾃定义了拷贝构造函数的实现,我们通过 new 分配新的内存实现了深度拷贝,避免了「悬挂指针」的问题,但也引出了新的问题。
拷贝构造函数为指针成员分配新的内存并进⾏拷贝的做法是传统 C++ 编程中是⼗分常见的。但有些时候我们并不需要这样的拷贝:
HasPtrMem2 GetTemp() {
return HasPtrMem2();
}
int main() {
HasPtrMem2 a = GetTemp();
}
上述代码中,GetTemp 返回的临时对象进⾏深度拷贝操作,然后再被销毁。如下图所⽰:
如果 HasPtrMem2 中的指针成员是复杂和庞⼤的数据类型,那么就会导致⼤量的性能消耗。
再回到⼤象移动的类⽐,其实更⾼效的做法是将⼤象直接从第⼀个冰箱拿出,然后放⼊第⼆个冰箱。同样的,我们在将临时对象赋值给某个变量时是否可以不⽤拷贝构造函数?答案是肯定的,如下图所⽰:
在 C++11 中,像这样「偷⾛」资源的构造函数,称为 「移动构造函数」,这种「偷」的⾏为,称为 「移动语义」,可理解为「移为⼰⽤」。
当然实现时需要在代码中定义对应的「移动构造函数」:
class HasPtrMem3 {
public:
HasPtrMem3() : d(new int(0)) {}
HasPtrMem3(const HasPtrMem3& h) :
d(new int(*h.d)) {}
HasPtrMem3(HasPtrMem3&& h) : d(h.d) {
h.d = nullptr;
}
~HasPtrMem3() { delete d; }
int* d;
};
注意「移动构造函数」依然会存在悬挂指针问题,所以在通过移动构造函数「偷」完资源后,要把临时对象的 h.d 指针置为空,避免两个指针指向同⼀个内存,在析构时被析构两次。
「移动构造函数」中的参数为 HasPtrMem3&& h 为右值类型[2],⽽返回值的临时对象就是右值类型,这也是为什么返回值临时对象能够匹配到「移动构造函数」的原因。
右值类型[2]: 注意和上⾯提到的右值引⽤第⼆个特点做区分,这⾥不是类型推导的场景,HasPtrMem3 是确定的类型,所以 HasPtrMem3&& h 就是确定的右值类型。
上述的移动语义是通过右值引⽤来匹配临时值的,那么左值是否可以借助移动语义来优化性能呢?C++11 为我们 提供了 std::move 函数来实现这⼀⽬标:
{
std::list<std::string> tokens; // tokens 为左值
// 省略初始化...
std::list<std::string> t = tokens; // 这里存在拷贝
}
std::list<std::string> tokens;
std::list<std::string> t = std::move(tokens); // 这里不存在拷贝
std::move 函数实际没有移动任何资源,它唯⼀做的就是将⼀个左值强制转换成右值引⽤,从而匹配到「移动构造函数」或「移动赋值运算符」,应⽤移动语义实现资源移动。⽽ C++11 中所有的容器都实现了移动语义,所以使用了 list 容器的上述代码能够避免拷贝,提⾼性能。
右值引⽤带来的完美转发
传统 C++ 中右值参数后被转换成左值,即不能按照参数原先的类型进⾏转发,如下所⽰:
template<typename T>
void forwardValue1(T& val) {
// 右值参数变为左值
processValue(val);
}
template<typename T>
void forwardValue1(const T& val) {
processValue(val); // 参数都变成常量左值引用了
}
如何保持参数的左值、右值特征,C++11 引⼊了 std::forward,它将按照参数的实际类型进⾏转发:
void processValue(int& a) {
cout << "lvalue" << endl;
}
void processValue(int&& a) {
cout << "rvalue" << endl;
}
template<typename T>
void forwardValue2(T&& val) {
// 照参数本来的类型进⾏转发
processValue(std::forward<T>(val));
}
int main() {
int i = 0;
forwardValue2(i); // 传入左值,函数执行输出 lvalue
forwardValue2(0); // 传入右值,函数执行输出 rvalue
return 0;
}
#06 移动构造函数与移动赋值运算符
在规则 #05 已经提及,不再赘述。
#07 有作⽤域枚举
传统 C++ 的枚举类型存在如下问题:
- 每⼀个枚举值在其作⽤域内都是可见,容易引起命名冲突
// Color 下的 BLUE 和 Feeling 下的 BLUE 命名冲突
enum Color { RED, BLUE };
enum Feeling { EXCITED, BLUE };
- 会被隐式转换成 int,这在那些不该转换成 int 的场景下可能导致错误
- 不可指定枚举的数据类型,导致代码不易理解、不可进⾏前向声明等
在传统 C++ 中也有⼀些间接⽅案可以适当解决或缓解上述问题,例如使⽤命名空间:
namespace Color { enum Type