C++ 11

概要

alt

C++ 总共有四个官方版本,都是以 ISO 标准被接受的年份命名的,它们是 C++98, C++03, C++11C++14C++98C++03 仅有一些技术细节上的不同,可以统称为 C++98C++14则是C++11的超集。总的来说,C++11C++所有版本中跳跃性最大的一个版本,很多人认为相对之前的版本,C++11是一个新语言。

工作中,由于系统平台很多,有些较早时间开发的并不支持C++11gcc编译器是在 4.7 版本中加入的C++11,在androidarmv6以上cpu的系统是可以支持的,那今天就结合工作中C++11使用情况,来对C++11的知识进行一个总结。参考书籍主要是<<Effective Modern C++>>,该书作者是Scott Meyers,他的Effective系列书籍,都是同样的配方,熟悉的味道,对所有的特性进行系统的分析,包括实现原理,主要解决问题等,而且挖的很深,很细,往往需要多次阅读,才能完全吸收。

我会结合工程中具体使用场景进行简短分析,同时加入相关测试使用代码,挑选一些平常工作中用的到的特性。

本章内容分为:

  • 语言可用性的强化
  • 语言运行期的强化
  • 标准库扩充:新增容器
  • 标准库扩充:智能指针

系统环境

  • gcc  版本   4.8.5   20150623   (Red Hat 4.8.5-28)   (GCC)
  • Linux  yejy  3.10.0-514.el7.x86_64  #1  SMP  Tue  Nov  22  16:42:41  UTC  2016  x86_64  x86_64  x86_64  GNU/Linux

CMake 编译参数:

add_definitions(-std=c++11)

语言可用性的强化

nullptr 和 constexpr

nullptr 的引入主要是为了避免 0NULL 在重载决议中的意外,同时可以提高代码的清晰性。

1
2
3
4
5
6
7
void f(int);  // f的三个重载版本
void f(bool);
void f(void*);

f(0); // 调用f(int), 而不是f(void*)
f(nullptr); // 调用f(void*), nullptr 会通过隐式转型为裸指针
f(NULL); // 可能通不过编译

提高代码清晰性例子:

1
2
3
4
5
6
7
8
9
10
11
auto result = findRecord(/*实参*/);

// 按照以前,这个是指针还是整形 ?
if(result == 0){
...
}

// 必然指针
if(result == nullptr){
...
}

constexpr 主要作用是,编译器在编译时,constexpr修饰函数如果传入实参是编译期已知的,就把这些表达式直接优化出结果并植入到程序运行时,从而增加程序的性能,并且 constexpr具备 const 属性。

类型推导 (auto 和 decltype)

auto 关键字,类型推导,在之前的C++中,auto作为一个存储类型,如果一个变量不是register(寄存器)变量,就是auto了。一个主要使用场景,就是遍历容器:

1
2
3
4
5
6
7
8
9
10
// 以前
unordered_map<char, int> hashMapA;
for(unordered_map<char, int>::const_iterator it = hashMapA.begin(); it != hashMapA.begin(); it ++){
...
}

// 有了auto以后,代码量可以减少很多
for(auto it = hashMapA.begin(); it != hashMapA.begin(); it ++){

}

auto 不能用于函数传参,和数组推导。

decltype 关键字是为了解决 auto 关键字只能对变量进⾏类型推导的缺陷而出现的。它的用法和 sizeof 很相似.

decltype ( 变量/表 达 式 )

1
2
3
4
5
6
7
// nullptr
if (std::is_same<decltype(NULL), decltype(0)>::value)
std::cout << "null == 0" << std::endl;
if (std::is_same<decltype(NULL), decltype((void *)0)>::value)
std::cout << "null == (void *)0" << std::endl;
if (std::is_same<decltype(NULL), decltype(nullptr)>::value)
std::cout << "null == nullptr" << std::endl;

列表初始化

C++11 提供了一种统一的初始化方法,在 C++98/03 中我们只能对普通数组和 POD(plain old data,简单来说就是可以用memcpy复制的对象) 类型可以使用列表初始化,如下:

数组的初始化列表: int arr[4] = {1,2,3,4}

C++11 将此方法适用性范围进行了放大,列表初始化的方式对:内置类型(int、float、double、char等)、数组、自定义的类、函数参数列表、STL标准模板库等都是有效的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// pod
class Test1
{
public:
int a;
int b;
int c;
};

Test1 typic = {0,1,2};

int d{0};
float b{1.2};

std::map<int,std::string> _map{{1,"lxg"},{2,"the answer"},{3,"hello world."}}; // c++ 98 不支持

列表初始化在 STL 中的实现则是基于std::initializer_list类型, STL有提供std::initializer_list类型作为参数的构造函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// vector 可变长参数初始化
class initializer_list_Test
{
public:
initializer_list_Test(std::initializer_list<int> list)
{
for (std::initializer_list<int>::iterator itr = list.begin(); itr != list.end(); itr++)
{
m_vecTest.push_back(*itr);
}
}

std::vector<int> m_vecTest;
};

// initializer_list 可以作为类construct的形参类型,也可以作为普通函数的形参类型
initializer_list_Test initializer_Test{0, 1, 2, 3, 4, 5};

区间 for 迭代

主要是提供一种类似脚本语言的遍历循环的方式:

1
2
3
4
5
6
7
8
9
10
// 以前
unordered_map<char, int> hashMapA;
for(unordered_map<char, int>::const_iterator it = hashMapA.begin(); it != hashMapA.begin(); it ++){
...
}

// 现在
for(auto elementB : hashMapA){
...
}

模板别名

我们应该尽量避免使用typedeftypedef 可以为类型定义一个新的名称,而没有办法为模板定义一个新的名称,因为模板不是类型, C++11提供了一个using关键字来处理模板别名,该关键字在函数指针使用过程中,也比较容易理解

1
2
3
4
5
6
7
8
9
10
// 函数指针
typedef void(*FP)(int, const std::string&);

using FP = void(*)(int, const std::string&); // 使用别名是不是更直观

// 模板别名
template <typename T>
using MyAllocList = std::list<T, MyAlloc<T>>; // MyAllocList 就是 std::list<T, MyAlloc<T>> 同义词

MyAllocList<Widget> lw; // 客户代码

模板变长参数

模板参数可以不固定,如果需要对其中数据进行解包,C++11中可以使用递归的方式。

1
2
3
4
5
6
7
8
9
10
11
12
// 至少包含一个模板参数的模板类
template < typename Require , typename ... Args > class Magic ;

// 可变模板参数的模板函数
template <typename ... Args>
void magic(Args ... args)
{
std::cout << sizeof ... (args) << std::endl;
}

magic(0); // 传入1个实参
magic(1,2,3); // 传入3个实参

override 和 final

有时候,程序员并不想尝试覆盖父类虚函数,但是又恰好加入了一个具有相同名字的函数。另一个可能的情形是,当基类的虚函数被删除后,子类拥有旧的函数就不再覆盖该虚函数,摇身一变成为了一个普通的类方法,这将造成灾难性的后果,导致函数表现完全不一样了。

C++11 引入了 overridefinal 这两个关键字来防止上述情形的发生。

override 当重载虚函数时,引入 override 关键字将显式的告知编译器进行覆盖,编译器将检查基函数是否存在这样的虚函数,否则将无法通过编译:

1
2
3
4
5
6
7
8
9
10
class Base {
public:
virtual void foo( int );
};

class SubClass : Base {
public:
virtual void foo( int ) override ; // 合法
virtual void foo( float ) override ; // 非法, 父类没有此虚函数
};

final 则是为了防止类被继续继承以及终止虚函数继续覆盖引入的。

1
2
3
4
5
6
7
8
9
10
11
12
13
class Base {
virtual void foo () final ;
};

class SubClass1 final : Base {
}; // 合法

class SubClass2 : SubClass1 {
}; // 非法 , SubClass1已 final

class SubClass3 : Base {
void foo (); // 非法, foo已 final
};

语言运行期的强化

Lambda 表达式

什么是C++11 Lambda 函数,如何使用 Lambda 函数。

Lambda函数是C++中的一种匿名函数, 而匿名函数则是在需要一个函数,但是又不想费力去命名一个函数的情况下去使用的。这样的场景其实有很多很多,所以匿名函数⼏乎是现代编程语言的标配。

Lambda 函数其实就像一个普通的函数一样:

You can pass arguments to it (可以传参)
It can return the result (可以返回结果)

但它没有任何名字。它主要用于我们必须创建非常小的函数, 以作为回调传递给另一个 API的场景 (回调)。

Lambda 语法:

1
2
3
4
5
6
7
// lamda
/** [ captures ] <tparams>(optional)(c++20) ( params ) specifiers exception attr ->
* ret requires(optional)(c++20) { body } (1)
* [ captures ] ( params ) -> ret { body } (2)
* [ captures ] ( params ) { body } (3)
* [ captures ] { body } (4)
*/

解释成中文就是:

1
2
3
[ 捕 获 列 表 ]( 参 数 列 表 ) mutable ( 可 选 ) 异 常 属 性 -> 返 回 类 型 {
// 函 数 体
}

除了这个捕获列表外,其他应该都很好理解,捕获列表主要含义有以下几个:

[] 空捕获列表
[name1, name2, . . . ] 捕获一系列变量
[&] 引用捕获, 让编译器自行推导捕获列表 (引用即别名,内部修改该变量,会改变闭包外部该捕获变量的值)
[=] 值捕获, 让编译器执行推导应用列表 (值捕获为拷贝,内部修改该变量,不会改变闭包外部该捕获变量的值)

1
2
3
4
5
6
7
8
9
10
int test_param1 = 56;

auto f = [](int param1) { return param1; };

// mutable 允许表达式修改捕获的参数 noexcept 函数不会抛出异常
auto test_param2 = [=](int i = 6) mutable noexcept(true)->int{
i += 12;
test_param1 += 1;
return test_param1 + i;
};

std::function

Lambda 表达式的本质是一个函数对象,当 Lambda 表达式的捕获列表为空时, Lambda 表达式还能够作为一个函数指针进行传递。

C++11 std::function 是一种通用、多态的函数封装,它的实例可以对任何可以调用的目标实体进行存储、复制和调用操作,它也是对 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
#include <functional>

int test()
{
int a = 5;
return a;
}

int main()
{
// std::function
std::function<int()> function_Test;

function_Test = nullptr;

try
{
function_Test(); // function 为空时进行调用,会出现异常
}
catch (std::bad_function_call e)
{
std::cout << e.what() << std::endl; // 输出: bad_function_call
}

function_Test = test; // 存储函数

std::cout << function_Test() << std::endl;

function_Test = []() { std::cout << "i" << "" << "j" << std::endl; return 5; }; // 存储 `Lambda`

function_Test();

return 0;
}

函数指针可以说是一个相当重要的运行时机制,现在我们可以用 Lambda表达式和 std::function来实现,既正规又清晰。对于实际开发中的回调实现,不同代码层次间的适配工作提供了很好的支持。

std::bind/std::placeholder

std::bind 则是用来绑定函数调用的参数的,它解决的需求是我们有时候可能并不一定能够一次性获得调用某个函数的全部参数,通过这个函数,我们可以将部分调用参数提前绑定到函数身上成为一个新的对象,然后在参数齐全后,完成调用。

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
37
38
39
40
41
42
43
44
45
46
47
48
#include <functional>

class Test
{
public:
void MemberFunction1()
{
std::cout << "member function 1" << std::endl;
}

void MemberFunction2(int i)
{
std::cout << "member function 2, i = " << i << std::endl;
}

int iMemData = {2000};
};

int main()
{
// std::bind ; std::placeholders
using namespace std::placeholders; // 占位符

Test testClass;

// 测试类中的 MemberFunction2,bind 函数时,第一个参数位置先占用,不传入实参
auto bindFunction = std::bind(&Test::MemberFunction2, &testClass, _1);

// 传入实参
bindFunction(100);

// 直接传入实参
function_Test1 = std::bind(&Test::MemberFunction2, &testClass, 6600);

// 调用 bind 函数
function_Test1();

std::function<void()> function_Test2;

// bind 函数传入 std::function
auto bindFunction2 = std::bind(&Test::MemberFunction2, &testClass, 6600);

function_Test2 = bindFunction2;

function_Test2();

return 0;
}

右值引用

在介绍右值引用前,我们先了解一下左值。具体什么是左值呢? 左值是可以通过地址访问的变量值。

lvalue is anything whose address is accessible. It means we can take address of lvalue using & operator.

因此,右值正好与左值相反。

Rvalue is anything that is not lvalue. It means we cannot take address of rvalue and it also don’t persist beyond the single expression.

我们不能通过 & 运算符来获取右值的地址,而且右值在使用时,不会超出单个表达式。 看下面例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
int x = 1;

int * ptr3 = &(x+1); // Compile Error

int a = 7; // a is lvalue & 7 is rvalue

int b = (a + 2); // b is lvalue & (a+2) is rvalue

int c = (a + b) ; // c is lvalue & (a+b) is rvalue

int * ptr = &a; // Possible to take address of lvalue

//int * ptr3 = &(a + 1); // Compile Error. Can not take address of rvalue

int getData()
{
int data = 0;
return data;
}
int * ptr = &getData(); // Compile error - Cannot take address of rvalue

传统C++中的 Reference变量是始终指向已存在变量的别名,也就是始终指向左值。

1
2
int x = 7;
int & lvalueRef = x; // lvalueRef is a lvalue reference

右值引用在C++11中引入,右值引用可以执行左值引用无法执行的操作,即右值引用可以引用右值。

Declaring rvalue reference

1
int && rvalueRef = (x+1); // rvalueRef is rvalue reference

这里,rvalueRefrvalue reference,它指向一个rvalue,即(x + 1)

1
2
3
4
5
int getData()
{
return 9;
}
int && rvalueRef2 = getData();

那么右值引用的概念基本如上所示,那么C++11中使用右值引用来干什么呢?

移动语义

alt

没错,就是引入移动语意,上图是原理图。

传统 C++ 通过拷贝构造函数和赋值操作符为类对象设计了拷贝/复制的概念,但为了实现对资源的移动操作,调⽤者必须使用先复制、再析构的方式,否则就需要自己实现移动对象的接口。这个设计是非常反人类的,已经有一个现成的”地”在这里了,为什么要重新开辟另一块相同的”地”,然后还把原来那一块”地”给毁了,这不是吃力不讨好吗?

移动语意和右值引用,就是为了解决临时对象在内存上的负载,每次我们从函数返回一个对象时,都会创建一个临时对象,最终会被复制。在最后等于我们创建了一个对象的 2 个副本,而我们只需要一个。

移动语意则不会进行复制操作,而是如上图中所示转移内存的控制权,将指针传给当前对象,原来的指针置空。

完美转发

一个模板函数,如何能够判断传入的参数是右值还是左值,是该进行右值引用,还是左值处理呢? 如何才能做到参数完美派发呢?

首先我们介绍一个规则,引用坍缩/折叠规则:在传统 C++ 中,我们不能够对一个引用类型继续进行引用,但 C++ 由于右值引用的出现而放宽了这一做法,从而产生了引用坍缩/折叠规则,允许我们对引用进行引用,既能左引用,又能右引用。

规则如下:

1
2
3
4
5
6
7
8
//函数形参类型  实参参数类型  推导后函数形参类型
1、 T& + & = T&

2、 T& + && = T&

3、 T&& + & = T&

4、 T或T&& + && = T&&

因此,模板函数中实参类型为 T&&不一定能进行右值引用,所谓完美转发,就是为了让我们在传递参数的时候,保持原来的参数类型(左引用保持左引用,右引用保持右引用)。为了解决这个问题,我们使用 std::forward 来进行参数的转发。

std::forward 即没有造成任何多余的拷贝,同时完美转发 (传递) 了函数的实参给了内部调用的其他函数。std::move 单纯的将左值转化为右值。

两者的内部实现都是 static_cast 转型动作, std::forward<T>(v)static_cast<T&&>(v) 是一样的。

我们看一个推导过程:

1
2
3
4
5
template<class A1> 
void f(A1 && a1)
{
return g(static_cast<A1 &&>(a1));
}

当传给f一个左值(类型为T)时,由于模板是一个引用类型,因此它被隐式装换为左值引用类型T&,根据推导规则1,模板参数A被推导为T&。这样,在f内部调用F(static_cast<A &&>(a))时,static_cast<A &&>(a)等同于static_cast<T& &&>(a),根据引用叠加规则第2点,即为static_cast<T&>(a),这样转发给g的还是一个左值。

当传给f一个右值(类型为T)时,由于模板是一个引用类型,因此它被隐式装换为右值引用类型T&&,根据推导规则2,模板参数A被推导为T。这样,在G内部调用F(static_cast<A &&>(a))时,static_cast<A &&>(a)等同于static_cast<T&&>(a),这样转发给F的还是一个右值(不具名右值引用是右值)。

可见,引入 std::forward<T>(v) ,配合折叠引入推导规则,是可以做到参数完美转发的。

标准库扩充:新增容器

std::unordered_map 和 std::unordered_set

我们知道传统 C++ 中的有序容器 std::map/std::set,这些元素内部通过红黑树进行实现,插入和搜索的平均复杂度均为 O(log(size))。在插入元素时候,会根据 < 操作符比较元素大小并判断元素是否相同,并选择合适的位置插入到容器中。当对这个容器中的元素进行遍历时,输出结果会按照 < 操作符的顺序来逐个遍历。

而无序容器中的元素是不进行排序的,内部通过哈希表实现的,插入和搜索元素的平均复杂度为 O(constant),在不关心容器内部元素顺序时,能够获得显著的性能提升。

C++11引入了两组无序容器:std::unordered_map/std::unordered_multimapstd::unordered_set/std::unordered_multiset

用法与原来的 std::map/std::multimap/std::set/set::multiset 基本类似。 之前应该已经比较熟悉了,就举一个std::unordered_map 使用例子。

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
#include <unordered_map>
using namespace std;

// 元素相同,顺序不同
// hash_map
bool isPermutation(string stringA, string stringB){
if(stringA.length() != stringB.length()){
return false;
}

unordered_map<char, int> hashMapA;
unordered_map<char, int> hashMapB;
for(auto elementA : stringA){
hashMapA[elementA]++;
hashMapB[elementA]++;
}

if(hashMapA.size() != hashMapB.size()){
return false;
}

for(auto elementB : hashMapA){
if(elementB.second != hashMapB[elementB.first]){
return false;
}
}

return true;
}

std::array 和 std::forward_list

std::array 是一个固定大小的数组容器,相比std::vector(堆内存), 它申请的内存是在栈上的,访问其中元素会更加灵活,性能更高,内存消耗更少。 std::array不能被隐式转换为指针。

1
2
3
4
5
6
7
8
9
10
void foo( int *p, int len) 
{
return ;
}

std :: array <int , 4> arr = {1 ,2 ,3 ,4}; // 传入元素类型和个数即可

// C风格接口传参
// foo (arr , arr . size ()); // 非法 ,无法隐式转换
foo (& arr [0] , arr. size ());

std::forward_list 内部实现则是一个单向链表,使用接口和std::list基本类似,相比于std::list来说,如果是一些无需双向迭代的场景,std::forward_list 空间利用率更高。

std::tuple

传统 C++ 中的容器,除了 std::pair 外,似乎没有现成的结构能够⽤来存放不同类型的数据(通常我们会自己定义结构)。但 std::pair 的缺陷是显⽽易见的,只能保存两个元素。std::tuple 元组则可以保存多个不同类型的元素。
主要接口:

std::make_tuple: 构造元组
std::get: 获得元组某个位置的值
std::tie: 元组拆包

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <tuple>

// 构造元组
auto student = std :: make_tuple (3.8 , 'A', " 张 三 ");

// 获得元组某个位置的值
std::get <0>(student)

// 拆包
double gpa;
char grade ;
std :: string name ;

std :: tie(gpa , grade , name) = student;

标准库扩充:智能指针

在构造函数的时候申请空间,⽽在析构函数(在离开作⽤域时调⽤)的时候释放空间,也就是我们常说的 RAII 资源获取即初始化技术,智能指针就是使用了该技术。

这一部分,我就不介绍了,因为之前已经分析过了,具体见:

unique_ptr: https://www.cnblogs.com/blog-yejy/p/8972858.html
shared_ptr: https://www.cnblogs.com/blog-yejy/p/9030722.html
weak_ptr: https://www.cnblogs.com/blog-yejy/p/9727070.html

总结

C++11 比之前版本好用很多有没有,语言可用性强化方面的各种修缮,以及新增语法糖,运行期强化部分的 Lambda表达式,右值引用对性能的极大提升,智能指针对资源管理的强化,新增容器对旧有容器的适用场景补充等, C++ 语言正变得越来越好,日久弥新。至于新加入的线程库和正则表达式支持,后续再统一介绍,线程相关总体和陈硕老师的muduo网络库中的封装思路是类似的,使用方式也不会相差太多。

参考

Effective Modern C++
高速上手 C++11/14/17
https://thispointer.com/c11-tutorial/
https://blog.csdn.net/ink_cherry/article/details/74573225