分享

全面解析C++11新特性:现代编程的新起点

 深度Linux 2023-11-03 发布于湖南

C++11是指C++语言在2011年发布的标准,也称为C++11标准或C++0x。它引入了一系列新特性和改进,旨在提高代码的可读性、可维护性和效率。

一、C++ 11新特性

C++ 11 标准是C++98后的新标准,该标准在 C++ 98 的基础上修正了约 600 个 C++ 语言中存在的缺陷,同时添加了约 140 个新特性,这些更新使得 C++ 语言焕然一新,这使得C++11更像是从C++98/03中孕育出的一种新语言,相比与C++98,C++11能更好地用于系统开发和库开发,其语法更加简单、稳定和安全,不仅功能更强大,而且能提升程序员的开发效率。

1.1列表初始化

C++98中常使花括号{}来初始化数组,而C++11扩大了花括号括起的列表(初始化列表)的使用范围,使其可用于所有的内置类型和用户自定义的类型,使用初始化列表时,可添加等号(=),也可不添加。如:

int a={1};//内置类型
vector<int> v={1,2,3,4,5};//标准容器
list<string> lt{"hello","world"};//省略=号
int* arr = new int[5]={1,2,3,4,5};// 动态数组

对象想要支持列表初始化,需给该类(模板类)添加一个带有initializer_list类型参数的构造函数即可。initializer_list是系统自定义的类模板,该类模板中主要有三个方法:begin()、end()迭代器以及获取区间中元素个数的方法size()。如:

initializer_list<int> il{ 1,2,3,4,5 };
vector<int> v(il);//标准容器
class Vector{Vector(initializer_list<T> il){....}};//自定义类型添加一个构造函数

1.2类型推导

在类型未知或者类型书写复杂时,可能需要类型推导。

1)auto

C++11中,可以使用auto来根据变量初始化表达式类型推导变量的实际类型,可以给程序的书写提供许多方便。auto使用的前提是:必须要对auto声明的类型进行初始化,否则编译器无法推导出auto的实际类型。常用于范围for和迭代器命名。

2)decltype

decltype是根据表达式的实际类型推演出定义变量时所用的类型,如:

1.推演表达式类型作为变量的定义类型:

int a = 1,b=2;
// 用decltype推演a+b的实际类型,作为定义c的类型
decltype(a+b) c;

2.推演函数返回值的类型

int* f(int x){return &x;}
int main()
{
// 如果没有带参数,推导函数的类型
cout << typeid(decltype(f)).name() << endl;
// 如果带参数列表,推导的是函数返回值的类型,注意:此处只是推演,不会执行函数
cout << typeid(decltype(f(1))).name() <<endl;
return 0;
}

1.3final与override

1)final

final:修饰虚函数,表示该虚函数不能再被继承。例:

class A {
public:
virtual void func() final {}
};
class B :public A {
public:
virtual void func() {}//这里语法会出现错误
};

2)override

override: 检查派生类虚函数是否重写了基类某个虚函数,如果没有重写编译报错。例:

class A {
public:
virtual void func() {}
};
class B : public A {
public:
virtual void func() override{}//派生类中重写基类的函数错误时,会报错,这里不会
};

1.4新增加容器

C++11中增加了静态数组array、forward_list以及unordered系列

1)array

常用的用[]定义的都是在栈上开辟的数组,array是在堆上开辟空间,它的基本用法和序列式容器差不多。

2)forward_list

与list不同,它使用的是单链表,虽然这样节省了空间,但是进行操作时的效率比list低。

3)unordered系列

有unordered_set和unprdered_map两种,和set和map相比,它们的底层使用的是哈希桶,效率比底层是红黑树的set和map高很多,多数情况下优先使用unordered系列的容器。

1.5默认成员函数控制

在C++中对于空类编译器会生成一些默认的成员函数,比如:构造函数、拷贝构造函数、运算符重载、析构函数和&和const&的重载、移动构造、移动拷贝构造等函数。如果在类中显式定义了,编译器将不会重新生成默认版本。有时候这样的规则可能被忘记,最常见的是声明了带参数的构造函数,必要时则需要定义不带参数的版本以实例化无参的对象。而且有时编译器会生成,有时又不生成,容易造成混乱,于是C++11让程序员可以控制是否需要编译器生成。

1)显式缺省函数

在C++11中,可以在默认函数定义或者声明时加上=default,从而显式的指示编译器生成该函数的默认版
本,用=default修饰的函数称为显式缺省函数。如:

class A
{
public:
A(int a): _a(a){}//有参
A() = default;//无参,由编译器生成
private:
int _a;
};

2)删除默认函数

如果能想要限制某些默认函数的生成,在C++98中,是该函数设置成private,并且不给定义,这样只要其他人想要调用就会报错。在C++11中更简单,只需在该函数声明加上=delete即可,该语法指示编译器不生成对应函数的默认版本,称=delete修饰的函数为删除函数。如:

class A
{
public:
A(int a): _a(a){}
A(const A&) = delete;//禁止编译器生成拷贝构造函数,调用时报错
A& operator(const A&) = delete;//禁止编译器生成=运算符重载,调用时报错
private:
int _a;
};

1.6右值引用

1)左值与右值一般情况下:

  • 普通类型的变量,因为有名字,可以取地址,都认为是左值。

  • const修饰的常量,不可修改,只读类型的,理论应该按照右值对待,但因为其可以取地址(如果只是

  • const类型常量的定义,编译器不给其开辟空间,如果对该常量取地址时,编译器才为其开辟空间)。C++11认为其是左值。

  • 如果表达式的运行结果是一个临时变量或者对象,如C语言中的纯右值,比如:a+b(表达式), 100(常量),将亡值。比如:表达式的中间结果、函数按照值的方式进行返回。这些认为是右值。

  • 如果表达式运行结果或单个变量是一个引用则认为是左值。

2)引用与右值引用比较

普通引用只能引用左值,不能引用右值,const引用既可引用左值,也可引用右值。
C++11中右值引用,格式为类型名+&&(如:int &&),比引用多加一个“&”:只能引用右值,一般情况不能直接引用左值。如:

int main()
{
int a = 10; //a为左值,10为右值
int& ra1 = a; // ra为a的别名
//int& ra2 = 10; // 编译失败,因为10是右值
const int& ra3 = 10; //const引用右值
const int& ra4 = a; //const引用左值
int&& r1 = 10; //右值引用变量r1,编译器产生了一个临时变量,r1实际引用的是临时变量
r1 = 0; //r1就可以被修改了
int&& r2 = a; // 编译失败,因为右值引用不能引用左值
return 0;
}

3)移动语义

C++11提出了移动语义概念,即:将一个对象中资源移动到另一个对象中的方式,比如:

String
{
String(String&& s)
: _str(s._str)
{
s._str = nullptr;
}
private:
char *_str;
};

这里构造函数中添加了一个函数,它的参数是右值引用,这里是将s中成员变量赋值到构造的对象中,然后再处理s,也就是说,将s中的资源转移到构造对象中,由构造对象处理。在应用移动语义时,移动构造函数的参数不能为const类型的右值引用,而且编译器为类默认生成一个移动构造,该移动构造为浅拷贝,因此当类中涉及到资源管理时,用户必须显式定义自己的移动构造。

4) 右值引用引用左值

当需要用右值引用引用一个左值时,可以通过move函数将左值转化为右值。它的功能就是将一个左值强制转化为右值引用,然后实现移动语义。如:

struct Person
{
string _name;
string _sex;
int _age;
};
int main()
{
Person p1 = { "张三","男",18 };
string&& name = move(p1._name);//用move将_name转化为左值
return 0;
}

可以看到name和p1._name的地址是一样的。

5)完美转发

看以下一段代码:

void Fun(int& x) { cout << "左值引用" << endl; }
void Fun(int&& x) { cout << "右值引用" << endl; }
void Fun(const int& x) { cout << "const左值引用" << endl; }
void Fun(const int&& x) { cout << "const右值引用" << endl; }
template<typename T>
void PerfectForward(T&& t) { Fun(t); }
int main()
{
PerfectForward(10); // 右值引用
int a;
PerfectForward(a); // 左值引用
PerfectForward(std::move(a)); // 右值引用
const int b = 20;
PerfectForward(b); // const左值引用
PerfectForward(std::move(b)); // const右值引用
return 0;
}
左值引用
左值引用
左值引用
const左值引用
const左值引用

它的运行结果如上,通过结果可以看出,PerfectForward函数的参数为右值时,并没有调用对应的参数为右值的函数,可见编译器将传入的参数类型都转化成了左值,要想解决这种问题,就需要用到C++11中的完美转发了。

完美转发是指在函数模板中,完全依照模板的参数的类型,将参数传递给函数模板中调用的另外一个函数。完美转发是目标函数总希望将参数按照传递给转发函数的实际类型转给目标函数,而不产生额外的开销,就好像转发者不存在一样。所谓完美:函数模板在向其他函数传递自身形参时,如果相应实参是左值,它就应该被转发为左值;如果相应实参是右值,它就应该被转发为右值。这样做是为了保留在其他函数针对转发而来的参数的左右值属性进行不同处理(比如参数为左值时实施拷贝语义;参数为右值时实施移动语义)。

C++11通过forward函数来实现完美转发,将上面的PerfectForward函数中调用Fun的参数更改一下就可以解决,具体如下:

template<typename T>
void PerfectForward(T&& t) { Fun(std::forward<T>(t)); }
右值引用
左值引用
右值引用
const左值引用
const右值引用

这样就根据参数类型调用相应的Fun函数。

6)右值引用作用

  1. 实现移动语义(移动构造与移动赋值)

  2. 给中间临时变量取别名

  3. 实现完美转发

1.7lambda表达式

lambda表达式实际是一个匿名函数,它能简化代码。

1)书写格式:

[capture-list] (parameters) mutable -> return-type { statement }

lambda表达式各部分说明:

  • [capture-list] : 捕捉列表,该列表总是出现在lambda函数的开始位置,编译器根据[]来判断接下来的代码是否为lambda函数,捕捉列表能够捕捉上下文中的变量供lambda函数使用。

  • (parameters):参数列表。与普通函数的参数列表一致,如果不需要参数传递,则可以连同()一起省略

  • mutable:默认情况下,lambda函数总是一个const函数,mutable可以取消其常量性。使用该修饰符时,参数列表不可省略(即使参数为空)。

  • ->returntype:返回值类型。用追踪返回类型形式声明函数的返回值类型,没有返回值时此部分可省略。返回值类型明确情况下,也可省略,由编译器对返回类型进行推导。

  • {statement}:函数体。在该函数体内,除了可以使用其参数外,还可以使用所有捕获到的变量。

注意: 在lambda函数定义中,参数列表和返回值类型都是可选部分,而捕捉列表和函数体可以为空。

2)应用示例

int main()
{
// 最简单的lambda表达式, 无意义
[]{};

// 省略参数列表和返回值类型,返回值类型由编译器推导为int
int a = 10, b = 20;
[=]{return a + b; };

// 省略了返回值类型,无返回值类型
auto fun1 = [&](int c){b = a + c; };
fun1(20);
cout<<a<<" "<<b<<endl;//a为10,b为30

// 完整的lambda函数
auto fun2 = [=, &b](int c)->int{return b += a+ c; };
cout<<fun2(10)<<endl;//结果为50

return 0;
}

3)捕获列表说明

捕捉列表描述了上下文中那些数据可以被lambda使用,以及使用的方式传值还是传引用。

  • [var]:表示值传递方式捕捉变量var

  • [=]:表示值传递方式捕获所有父作用域中的变量(包括this)

  • [&var]:表示引用传递捕捉变量var

  • [&]:表示引用传递捕捉所有父作用域中的变量(包括this)

  • [this]:表示值传递方式捕捉当前的this指针

注意事项:

  • 父作用域指包含lambda函数的语句块

  • 语法上捕捉列表可由多个捕捉项组成,并以逗号分割。

  • 比如:[=, &a, &b]:以引用传递的方式捕捉变量a和b,值传递方式捕捉其他所有变量 [&,a, this]:值

  • 传递方式捕捉变量a和this,引用方式捕捉其他变量 c. 捕捉列表不允许变量重复传递,否则就会导致编

  • 译错误。 比如:[=, a]:=已经以值传递方式捕捉了所有变量,捕捉a重复

  • 在块作用域以外的lambda函数捕捉列表必须为空。

  • 在块作用域中的lambda函数仅能捕捉父作用域中局部变量,捕捉任何非此作用域或者非局部变量都

  • 会导致编译报错。

  • lambda表达式之间不能相互赋值,即使看起来类型相同

4)函数对象

函数对象,又称为仿函数,即可以像函数一样使用的对象,就是在类中重载了operator()运算符的类对象,如库中的less仿函数:

template <class T> struct less : binary_function <T,T,bool> {
bool operator() (const T& x, const T& y) const {return x<y;}
};

在调用仿函数时,可以用匿名对象调用,或者构建一个对象来调用,如:

int main()
{
int a = 10, b = 20;
cout << "a<b?: "<<less<int>()(a, b) << endl;//匿名对象调用

less<int> l;//创建对象l再调用
cout << "a<b?: "<<l(a, b) << endl;
return 0;
}
【文章福利】小编推荐自己的Linux C++技术交流群:【1106675687】整理了一些个人觉得比较好的学习书籍、视频资料共享在群文件里面,有需要的可以自行添加哦!!!前100名进群领取,额外赠送大厂面试题。

二、C++11经常考到的知识点

2.1自动类型推断(auto关键字)和范围-based for循环区别?

自动类型推断(auto关键字):在变量声明时使用auto关键字,编译器会根据变量的初始化表达式推断出变量的类型。例如:

auto x = 10; // 推断x为整数型
auto str = "Hello"; // 推断str为字符串型

这样可以简化代码,尤其对于复杂的类型名称或模板类型参数更加方便。

范围-based for循环:用于遍历容器中的元素,不需要手动控制迭代器。例如:

std::vector<int> numbers = {1, 2, 3, 4, 5};
for(auto num : numbers) {
std::cout << num << " ";
}

2.2范围-based for循环会依次将容器中的每个元素赋值给迭代变量num,使得遍历容器变得更加简洁和直观。

C++11引入了范围-based for循环(也称为foreach循环),它可以更方便地遍历容器中的元素。使用范围-based for循环,可以自动将容器中的每个元素赋值给迭代变量,使得遍历容器变得更加简洁和直观。

例如,对于一个容器vector<int>,我们可以使用范围-based for循环来遍历它:

std::vector<int> numbers = {1, 2, 3, 4, 5};
for (int num : numbers) {
// 对每个元素进行操作
std::cout << num << " ";
}

上述代码会依次将numbers中的每个元素赋值给迭代变量num,并输出该值。通过这种方式,我们可以方便地对容器进行遍历操作。范围-based for循环适用于支持迭代器或begin/end成员函数的各种容器类型。

2.3nullptr关键字,用于表示空指针吗?

是的,nullptr是C++11引入的关键字,用于表示空指针。它可以作为常量null的更安全和直观的替代品,在程序中明确表示一个空指针。使用nullptr可以避免在不同上下文中可能产生二义性的情况,并且能够提供更好的类型检查和类型推导。

2.4强制类型转换新规则,如static_cast、dynamic_cast、const_cast和reinterpret_cast。

强制类型转换是在C++中用于将一个类型的值转换为另一种类型。下面是四种常见的强制类型转换方式:

  1. static_cast:主要用于基本数据类型之间的转换,以及具有继承关系的指针或引用之间的转换。它在编译时进行类型检查,不提供运行时的检查。

  2. dynamic_cast:主要用于类层次结构中,进行安全地向下转型(派生类到基类)和向上转型(基类到派生类)。它在运行时进行类型检查,如果无效则返回空指针(对指针)或抛出std::bad_cast异常(对引用)。

  3. const_cast:主要用于去除const属性。通过const_cast可以将const对象转换为非const对象,并且还可以通过它修改原本被声明为const的变量。

  4. reinterpret_cast:这是一种较低级别和危险性较高的转换方式,它可以将任何指针或整数类型互相转换。它不会执行任何特定的检查,只是简单地重新解释给定值所占据内存位置的含义。

2.5Lambda表达式,用于创建匿名函数。

是的,Lambda表达式用于创建匿名函数。它提供了一种简洁的语法来定义并传递函数,通常在需要使用函数作为参数或需要一个临时函数的地方使用。

Lambda表达式的基本语法如下:

[捕获列表](参数列表) -> 返回类型 {
函数体
}

其中,

  • 捕获列表(Capture List)可以指定要在Lambda表达式中访问的外部变量。

  • 参数列表(Parameter List)定义了传递给Lambda函数的参数。

  • 返回类型(Return Type)指定了Lambda函数的返回值类型。

  • 函数体(Function Body)包含了实际执行的代码。

例如,以下是一个使用Lambda表达式创建匿名函数并传递给STL算法std::for_each的示例:

#include <iostream>
#include <vector>
#include <algorithm>

int main() {
std::vector<int> numbers = {1, 2, 3, 4, 5};

// 使用Lambda表达式打印每个元素
std::for_each(numbers.begin(), numbers.end(), [](int num) {
std::cout << num << " ";
});

return 0;
}

这个Lambda表达式 [ ](int num) { std::cout << num << " "; }接受一个整数参数,并输出该数字。在上述示例中,我们将其作为参数传递给std::for_each算法以打印每个元素。

2.6移动语义和右值引用(&&运算符),用于实现高效的资源管理和避免不必要的拷贝构造函数调用。

移动语义和右值引用是C++11引入的特性,用于实现高效的资源管理和避免不必要的拷贝构造函数调用。

移动语义通过将资源的所有权从一个对象转移到另一个对象来提高性能。在传统的拷贝操作中,会先进行深度复制,然后再销毁原始对象。而移动操作则是将原始对象的资源指针或状态信息转移到目标对象中,而不进行数据的复制。这样可以大大减少内存拷贝和数据处理开销。

右值引用(&&运算符)是表示“具名值”的左值引用(&运算符)之外的一种新类型引用。它主要与移动语义结合使用,在函数参数、返回值和赋值等场景中发挥作用。通过使用右值引用参数,可以显式地表达出一个临时对象可以被移动或接管其资源。

对于类设计者来说,合理利用移动语义和右值引用可以优化类的性能,并避免不必要的资源拷贝。同时,C++标准库中也提供了一些支持移动语义的容器、智能指针等工具,进一步简化了资源管理。

2.7初始化列表,允许在对象初始化时使用大括号进行成员初始化。

是的,初始化列表允许在对象初始化时使用大括号进行成员初始化。它可以在构造函数中使用,并且语法如下:

class MyClass {
public:
MyClass(int a, int b) : memberA(a), memberB(b) {
// 构造函数的其他操作
}

private:
int memberA;
int memberB;
};

在上面的例子中,memberAmemberB通过初始化列表进行初始化。这样可以避免先创建对象再逐个赋值的额外开销,提高了效率。同时,如果成员变量是常量或引用类型,则必须使用初始化列表进行初始化。

2.8类型别名与using关键字,用于定义自定义类型别名。

是的,C++中可以使用typedef关键字或using关键字来定义自定义类型别名。

使用typedef关键字:

typedef int myInt; // 将int类型定义为myInt类型的别名
typedef std::vector<int> IntVector; // 将std::vector<int>定义为IntVector类型的别名

使用using关键字:

using myInt = int; // 将int类型定义为myInt类型的别名
using IntVector = std::vector<int>; // 将std::vector<int>定义为IntVector类型的别名

无论使用typedef还是using,它们都可以用于简化复杂的类型声明,提高代码可读性。

2.9线程支持库(std::thread),允许并发执行代码块。

是的,std::thread是C++标准库中提供的线程支持库,它允许并发执行代码块。使用std::thread,你可以创建新的线程并在其中执行指定的函数或可调用对象。这样可以实现多个任务同时执行,从而提高程序的性能和响应性。

下面是一个简单示例:

#include <iostream>
#include <thread>

// 线程函数
void printMessage() {
std::cout << "Hello from thread!" << std::endl;
}

int main() {
// 创建新线程,并在其中执行printMessage函数
std::thread t(printMessage);

// 主线程继续执行其他任务
std::cout << "Hello from main thread!" << std::endl;

// 等待子线程完成
t.join();

return 0;
}

上述代码创建了一个新线程,并在该线程中执行printMessage函数。同时,主线程会打印"Hello from main thread!"。当子线程完成后,使用t.join()等待子线程退出。

需要注意的是,在使用std::thread时需要正确管理资源和同步操作,避免竞态条件和内存访问问题。

2.10合理使用智能指针(如std::shared_ptr和std::unique_ptr)来管理动态内存分配,避免内存泄漏和悬挂指针问题。

智能指针是一种强大的工具,用于管理动态分配的内存,可以帮助我们避免内存泄漏和悬挂指针问题。

std::unique_ptr是一种独占所有权的智能指针。它确保只有一个指针可以访问资源,并在不再需要时自动释放内存。它适合用于单个所有者场景,例如拥有一个对象或管理动态分配的数组。

std::shared_ptr是一种共享所有权的智能指针。多个 shared_ptr可以共享对同一资源的所有权,并且会自动跟踪引用计数。只有当最后一个 shared_ptr释放资源时,内存才会被释放。这使得 std::shared_ptr特别适用于需要共享资源所有权的场景。

使用智能指针可以有效地管理动态内存,并且不容易出现内存泄漏或悬挂指针问题。但要注意,在使用 std::unique_ptr时要避免循环引用,而在使用 std::shared_ptr时要考虑引起性能开销和潜在的死锁风险。

三、C++11新特性总结

3.1move semantics (移动语义)

1)为什么需要移动语义

假设我们先定义并初始化vector v1和v2,v1中有5个int,而v2为空, 然后执行 v2 = v1, 这会调用拷贝构造函数,会将v1的所有元素拷贝至v2。

一些情况下,这样的深拷贝是必要,但是有时候确实是低效的。就比如我们有createVector这样的函数,它会返回一个vector对象。在c++11之前,这样的代码

std::vector<int> v2{};
v2 = createVector();

将会对createVector返回的临时对象进行拷贝,即会在堆上分配新的空间将临时对象的内容拷贝过来,进而重置v2的状态。但是我们知道这个临时对象是很快就会被析构的(它将在下一行被析构),我们完全可以使v2“窃取”这个临时对象在堆上的内容。

就像下面这样,vector对象总共就存储了3个指针来管理整个数组,只要将指针拷贝过来再把temp对象的指针置为0就可以了。

什么样的对象可以窃取呢?---那些生命值非常短暂的对象,那些临时对象。这些对象可以绑定到右值引用上(这是C++11为了支持移动语义,新提出的一种引用类型),一旦察觉到一个引用是一个右值引用,那么编译器就可以直接窃取它们的所有物而不是拷贝它们(通常的表现是:编译器倾向于选择执行移动构造\赋值,而不是选择拷贝构造\赋值)。

右值引用与左值引用的最大区别在于: 右值引用的生命周期更短暂, 通常右值引用的作用域只在一行之内。

左值引用可以用取地址符号 & 进行操作, 但是右值引用不可以,由于右值引用的生命周期非常短,所以也就意味着我们可以“窃取”右值引用的所有物。

如何窃取这些暂态对象?我们可以定义移动构造\赋值函数。

2)移动构造\赋值函数

移动构造函数与移动语义一同被提出,C++11以后很多的stl容器添加了对应的移动构造\赋值函数。比如vector容器的operator=,在C++11后有两种典型的重载:

vector& operator=( const vector& other ); // 经典的拷贝赋值函数,执行深拷贝过程
vector& operator=( vector&& other ); // c++11起,移动赋值函数,执行“浅拷贝”过程

第一种则是经典的拷贝赋值函数;而第二种则是移动赋值函数。C++11后,如果我们再写这样的代码:

std::vector<int> v2{};
v2 = createVector();

编译器将识别到 = 右边是一个临时对象,将调用移动赋值函数将临时对象的元素“窃取”至v2中,提高了执行效率。

类的移动构造函数何时自动生成

如果程序员不声明(也不能标记为 =default 或者 =delete)5个特殊成员函数(拷贝构造、拷贝赋值、移动构造、移动赋值、析构函数)的任何一个,且类的每个非静态成员都是可移动时,那么编译器会为这个class自动生成移动构造和移动赋值。反之,如果手动定义了,或者只是将拷贝构造函数标记为 =default,那么编译器就不会为这个class生成移动构造和赋值函数。

如何编写自己的移动构造函数?

编写示范如下,其中与std::move相关的讨论见下一小节:

因为int是基本类型,所以在初始化阶段无论你用不用std::move转换都不会出错。但是对于字符串s来说就不一样了,如果我们不加std::move就会出错,因为 即使移动构造函数接受右值引用,但是 w 在这个构造函数中是一个左值引用(因为它有名字w),所以 w.s 也是一个左值引用,我们要调用 std::move(w.s)将字符串转换为左值,否则我们将会复制字符串而不是移动。右值引用的这个特性会在之后的内容中,引出“完美转发”这个话题

另外,还需要将w.pi置为nullptr,为什么?因为右值引用所绑定的对象是即将消亡的,当它在被析构时,只有将它所管理的指针置零,才不会将已经被转移的数据删除,才不会造成未定义行为。

3)std::move直接从代码例子看move的作用:

第一个赋值动作 v2 = v1 ,会调用vector的拷贝赋值函数,因为v1是一个左值;

第二个赋值动作中编译器识别到 = 号右边是一个临时对象,所以调用移动赋值操作符,这正好满足我们的需求。

而第三个赋值操作,std::move,将v1这个左值引用转换为了右值引用(std::move仅仅是一个static_cast),所以第三个赋值动作也会调用移动赋值函数。

请注意我们调用了 std::move(v1),它仅仅是对这个变量贴了一个标签,告诉编译器我们之后不会用到v1了,所以实际上std::move不会“移动”任何东西,它只是改变了变量的类型(从左值到右值),使得编译器选择了移动赋值函数。真正能够体现“move”的,是类的移动构造\赋值函数。

如果使用了move作用后的变量会怎么样?

不确定,我们不能对被move作用后的变量做出假设,C++标准只是规定这些被移动的对象(Moved From Object)处在一个未知但有效的状态(valid but unspecified state),这取决于函数编写者的具体实现。

但同时C++标准也规定这些处于未知但有效状态的被移动的对象能够:

  • 被摧毁,即能够调用析构函数

  • 被重新赋值

  • 赋值、拷贝、移动给另一个对象

因此一个被移动的对象,我们尽可能不要去操作它的指针类型成员,很可能造成未定义行为,但如果我们重新为这个被移动对象赋予了新的、有效的值,那么我们就可以重新使用它。

std::vector<int> v1{createVector();};
std::vector<int> v2{std::move(v1)};
// v1在被重新赋值之前,它处于未知状态,最好不要去使用它
v1 = createVector();
doSomething(v1); // v1被重新赋值后,我们又可以正常使用它了

4)noexcept与移动语义

下面是《C++ Move Semantics The Complete Guide》一书中的例子:

class Person{
private:
std::string name;

public:
Person(const char* c_name):name{c_name} {}
// 拷贝构造
Person(const Person& other):name{other.name} {
std::cout << name << " COPY constructed!" << std::endl;
}
// 移动构造
Person(Person&& other):name{std::move(other.name)} {
std::cout << name << " MOVE constructed!" << std::endl;
}
};

为Person类定义了拷贝构造函数和移动构造函数,并在函数体中打印提示动作。

然后,观察Person类对象与vector相关的动作:(注意下面的例子的字符串都很长,这是为了抑制小型字符串优化(SSO,具体实现依赖于union的特性,共用capacity字段和小型字符串的存储空间)),即短小的字符串类将直接在栈上保存内容,而非在堆开辟空间,栈上存放指向堆空间的指针;如果发生了SSO优化,那么移动操作并不比复制操作更快)

int main() {
Person p1{"Wolfgang Amadeus Mozart"};
Person p2{"Johann Sebastian Bach"};
Person p3{"Ludwig van Beethoven"};

std::cout << "\n push 3 ele in a vector whose capacity is 3 : \n";
std::vector<Person> v1;
v1.reserve(3);
v1.push_back((std::move(p1)));
v1.push_back((std::move(p2)));
v1.push_back((std::move(p3)));

std::cout << "\n push 4th ele in the vector, which will cause reallocation : \n";
Person p4{"Poelea Selo Beajuhhdda"};
v1.push_back(std::move(p4));
}

输出如下:

push 3 ele in a vector whose capacity is 3
Wolfgang Amadeus Mozart MOVE constructed!
Johann Sebastian Bach MOVE constructed!
Ludwig van Beethoven MOVE constructed!

push 4th ele in the vector, which will cause reallocation
Poelea Selo Beajuhhdda MOVE constructed!
Wolfgang Amadeus Mozart COPY constructed!
Johann Sebastian Bach COPY constructed!
Ludwig van Beethoven COPY constructed!

可以看到,在vector进行reallocation之前的所有push_back都使用了右值引用的版本,因为我们对具名对象使用了std::move使其转换成了右值。

但是当vector发生reallocation后,元素却是被拷贝到新的空间中的,照理说应该使用移动更方便才对,为什么编译器在这里使用了拷贝语义?

原因可能出在vector的push_back是“强异常安全保证”的函数:如果在vector的reallocation期间有异常抛出,C++标准库得保证将vector回滚到它之前的状态。

为了实现这种事务特性,比较容易的做法就是在重分配的过程中使用拷贝,如果有任何一个元素分配空间失败或者拷贝失败,那么仅仅把新创建的元素销毁然后释放空间就可以回滚到先前的状态了。

相对的,使用移动来实现这种事务特性就比较困难了,试想在reallocation期间有异常抛出,此时新的空间的元素已经“窃取”了就空间的元素,因此想要回退到先前的状态,销毁新元素是不够的,我们还得将新元素移回旧空间中--问题来了,怎么保证这个移动操作不发生任何错误呢?

可以看到,使用移动语义难以保证这种事务特性,除非编译器知道这个类的移动构造函数不会抛出任何异常,否则它会在vector的reallocation期间选择拷贝元素,而不是移动元素

而noexcept关键字就能够告知编译器:该方法不会抛出异常,如果我们在Person的移动构造函数后加上noexcept关键字,编译器就会在vector的reallocation期间选择移动构造函数。

Person(Person&& other) noexcept :name{std::move(other.name)} {
std::cout << name << " MOVE constructed!" << std::endl;
}

实际上,编译器自动生成的移动构造函数会检测:

  • 基类的移动构造是否noexcept

  • 类成员的移动构造是否noexcept

如果满足,则编译器自动生成的移动构造函数会自动加上noexcept关键字

Person(Person&& other) = default; // 使用编译器生成的移动构造函数

输出如下:

push 3 ele in a vector whose capacity is 3 : 

push 4th ele in the vector, which will cause reallocation :

没有拷贝构造函数的输出提示,表明重分配阶段使用了移动构造函数,也说明编译器为它自己生成的移动构造函数后加上了noexcept。

5)std::move 使用实例

来自CMU15445lab源码

// executor_factory.cpp    
// Create a new insert executor
case PlanType::Insert: {
auto insert_plan = dynamic_cast<const InsertPlanNode *>(plan);
auto child_executor =
insert_plan->IsRawInsert() ? nullptr : ExecutorFactory::CreateExecutor(exec_ctx, insert_plan->GetChildPlan());
return std::make_unique<InsertExecutor>(exec_ctx, insert_plan, std::move(child_executor)); // move了child_executor
}

InsertExecutor的构造函数应该这样写:

InsertExecutor::InsertExecutor(ExecutorContext *exec_ctx, const InsertPlanNode *plan,
std::unique_ptr<AbstractExecutor> &&child_executor)
: AbstractExecutor(exec_ctx), plan_(plan), child_executor_(std::move(child_executor)) {

如果把初始化列表中的std::move去掉,编译器报错如下:

Call to deleted constructor of 'std::unique_ptr<AbstractExecutor>', uniqueptr的拷贝构造函数是被删除的,所以我们不能用左值引用初始化一个uniqueptr,所以我们必须调用std::move将child_executor变量先转换为右值引用,这也说明了child_executor即使被绑定到一个右值引用上,它本身却是一个左值引用。

但是我们调用构造函数的时候确实将左值转换成右值了不是吗?

std::make_unique<InsertExecutor>(exec_ctx, insert_plan, std::move(child_executor));

可以这样理解,在这一行的作用域中, std::move(child_executor) 确实将左值转换成了右值,编译器确定child_executor在这一行以后将不会再被使用。但是进入到拷贝函数的作用域中,编译器又不能确定该参数的生命周期了,因此在拷贝函数的作用域中还是将其看作左值类型。

一句话总结就是,右值变量在连续的嵌套作用域中并不会传递"右值"这个属性,因此我们有了下一章对“完美转发”的讨论。

3.2完美转发

在《C++ Move Semantics The Complete Guide》一书中,它将完美转发放在了第三部分Move Semantics in Generic Code,也就是说完美转发是同时涉及到移动语义和泛型编程的一个概念。

1)为什么需要完美转发

“转发”的含义是一个函数把自己的形参传递给另一个函数(即调用另一个函数),但是在引入右值后,这些转发可能需要花费一些精力:

比如现有3个版本的foo()函数:

class X{
public:
X() {a = 1;}
int a ;
};
void foo(const X& x) {// 绑定所有只读变量
// do some read only job
cout << "foo(const X& x) called\n";
}

void foo(X& x) { // 绑定左值引用
// do a lot of job, can modify x
cout << "foo(X& x) called\n";
}

void foo(X&& x) { // 绑定右值引用
// do a lot of job, can modify x, even can move x since x is rvalue references
cout << "foo(X&& x) called\n";
// std::move(x) is valid!
}

假如要通过另一个函数callFoo调用foo函数,那么为了区分参数类型,callFoo也应该要写三个重载版本达成"完美转发"的效果:

void callFoo(const X& x) {
foo(x); // 调用void foo(const X&)
}

void callFoo(X& x) {
foo(x);// 调用void foo(X&)
}

void callFoo(X&& x) {
foo(std::move(x));// 调用void foo(X&&), 注意std::move, x在callFoo函数域中是一个左值
// 在调用foo前,需要将其转化为右值
}

注意第三个重载版本,在调用foo前必须对x进行std::move,因为“move semantics is not automatically passed through”(见上一章的源码实例)

注意到我们编写三个callFoo函数,能否使用泛型只写一个函数模板?恐怕很难。假设你只编写下面callFoo函数的泛型版本

template<typename T >
void callFoo(T x) {
foo(x);
}

在main函数中这样使用它:

int main() {
const X const_x;
X x;
callFoo(const_x);
callFoo(x);
callFoo(X());
}

输出是:

foo(X& x) called
foo(X& x) called
foo(X& x) called

三个callFoo全部都调用了foo(X& x) 函数,没有实现完美转发。原因与模板推导有关,因为void callFoo(T x)表示值传递,因此参数始终被推导为X,不会有引用性,也不会保留const属性,详见《effective modern C++》条款1。

如果你想打个补丁:

void callFoo(T& x)
void callFoo(T&& x)

g++编译器会报错Call to 'callFoo' is ambiguous。况且,即时哪种编译器能通过编译,这样的写法一点都不"泛型", 你都写了这么多重载的泛型函数了,为什么还用泛型?而且,如果函数参数有2个,那么需要编写9个版本,如果参数有3个则要编写27个重载版本,可以预见,需要提供的重载版本数随着泛型参数的增加呈现指数级增长。

因此C++11 引入了两种特殊的机制,以在泛型编程中达成上述的“完美转发”效果:

  1. 万能引用

  2. std::forward

具体代码如下:

template<typename T>
void callFoo(T&& arg) { // 这是一个万能引用,而不是右值引用
foo(std::forward<T>(arg)); // 使用std::forward保持参数的类型:如果arg在传入callFoo时是左值,则让其保持左值;否则将其转化为右值
}

只需要编写以上一个泛型版本的callFoo即可完成对foo函数参数的完美转发效果!

2)万能引用和std::forward

template<typename T>
void callFoo(T&& arg) // 右值引用? 不,是万能引用

在泛型编程中,T&&看上去像是右值引用,但它其实是万能引用,它能够绑定所有的对象(包括const、non-const,左值、右值),以下调用都是合法的,而且,它们能够保持参数的常量性和值的类型(左值\右值)。

X v;
const X c;
callFoo(v); // arg的型别 是 X&
callFoo(c); // arg的型别 是 const X&
callFoo(X{}); // arg的型别 是 X&&
callFoo(std::move(v)); // arg的型别 是 X&&
callFoo(std::move(c)); // arg的型别 是 const X&&

概括而言,如果调用函数时传递的参数类型是左值,那么万能引用就绑定到一个左值,如果传递的参数是右值,那么万能引用就绑定到一个右值

注意 :区分万能引用和右值引用(详见modern effective C++条款24)
并不是形如 T&&的引用就是万能引用,T必须涉及类型推导时,T&&才是万能引用,典型场景就是在泛型编程中的T&&。
且即时在泛型编程场景下,const T&&并不是万能引用,它只能绑定 const X&&
auto&& 也是一个万能引用,它也涉及型别推导
一句话:万能引用必须涉及型别推导
其余能够绑定任何类型的引用则是const&, 但是它没有保存参数是否是const的信息,而万能引用能保存参数是否为const的信息

为什么还需要std::forward呢?这与万能引用能够“绑定任何类型的对象”的特性有关:

  • 右值引用只能绑定可移动的对象,因此函数编写者100%确定他使用的函数参数能够被作用于std::move。
    void callFoo(X&& x) { //能够调用这个函数的参数一定也是右值引用foo(std::move(x)); // 因此能够毫无顾虑的调用std::move将其再次转化为右值}

  • 然而万能引用能够绑定任何对象,因此函数编写者不能确定他使用的参数是否在被std::move作用后是否保持原来的引用类型(“原来的类型”指的是函数作用域外,用来传递给函数形参的对象的类型),要实现完美转发不能使用std::move,只能使用std::forward。
    template<typename T> void callFoo(T&& arg) { // 这是一个万能引用,任何参数都能调用这个函数1. foo(std::move(arg)); // 如果使用std::move,则无条件将参数转化为右值,这是不对的!2. foo(std::forward<T>(arg));// 这样才合适,会先将arg转化为对应的类型,然后调用对应的函数版本}

std::forward的功能如下所述:

std::forward(arg)是一个有条件的std::move(arg), 即

  1. 如果arg是一个右值引用,则std::forward(arg)将会等效为std::move(arg)

  2. 如果arg是一个左值引用,则std::forward(arg)将会等效为 arg

通过万能引用和std::forward,我们就可以在泛型编程中实现完美转发:

template<typename T>
void callFoo(T&& arg) { // 这是一个万能引用,而不是右值引用
foo(std::forward<T>(arg)); // 使用std::forward保持参数的类型:如果arg在传入callFoo时是左值,则让其保持左值;否则将其转化为右值
}
// 调用的函数foo有3个重载版本,见上小节

X v;
const X c;
callFoo(v); // std::forward<T>(arg) => arg, 调用 foo(X&)
callFoo(c); // std::forward<T>(arg) => arg, 调用 foo(const X&)
callFoo(X{}); // std::forward<T>(arg) => std::move(arg), 调用foo(X&&)
callFoo(std::move(v)); // std::forward<T>(arg) => std::move(arg), 调用foo(X&&)
callFoo(std::move(c)); // std::forward<T>(arg) => std::move(arg), 调用foo(cosnt X&)

接下来,将阐述完美转发能够运行的原理。

3)引用折叠

引用折叠是完美转发能够起作用的底层机制,但是在理解引用折叠之前,需要再了解一些模板型别推导的知识。

因为这里主要涉及完美转发,因此只讨论涉及万能引用的函数模板型别推导。比如这样的函数声明:

template<typename T>
void callFoo(T&& arg);

若以某个表达式expr调用它:

callFoo(expr);

编译器会进行两处类型推导,一是推导T的型别,二是推导T&&的型别(即arg的型别)

且由于函数参数使用的是万能引用,因此会对左值类型的expr有特殊处理方法。

expr是右值的情景,编译器是这样对T进行型别推导的:

  1. 若expr有引用型别,则先将引用部分忽略

  2. 然后,对expr的型别和 T&& 进行模式匹配,来决定T的型别

比如callFoo(1),此时expr为1,它是一个右值,它的类型为 int&&, 在与T&&进行模式匹配后,得到T的类型为int。最后自然地得到arg的型别为T&&,即arg是一个右值引用。

②但如果expr是一个左值,编译器会将T推导为左值(至于为什么,我不是很清楚,个人倾向于将其解释为标准的规定)。

然后会将T&&的型别也就是arg的型别推导为左值! 例如:

int  x = 1;
callFoo(x); //expr型别为 int&, T的型别为 int&, arg的型别也是 int&

等等,T的型别被推导为int&, 那为什么arg的型别也是int&, 不应该是int& && 吗?

这就是引用折叠发挥作用的地方了,C++没有“引用的引用”这样的型别。因此如果你脑补了一个类的型别出现了3个或3个以上&符号,那么就一定得把它们转化成左值或者右值,具体的规则由C++标准如下规定:

这里主要观察第二个规则,该规定就决定了上例的arg的型别被推导为左值引用,从int& && 折叠为 int&。

做个总结,当使用万能引用的模板参数时,编译器有一套特殊的类型推导规则:

  1. 如果传递的参数是一个右值,T的推导结果就是非引用型别,arg的推导结果就是右值引用型别

  2. 如果传递的参数是一个左值,T的推导结果就是左值引用型别,又由于"引用折叠"这个规定,于是arg的推导结果也是左值引用型别

个人看来,虽说引用折叠是完美转发的底层机制,但这其实就是C++标准会的一系列规定,是从需求出发的定制的一系列规定。

有关模板类型推导的其余内容请参考《effecive modern C++》条款1。

4)std::forward原理解析

有了引用折叠的这个概念后,理解std::forward的原理也就不难了。

下面从《effecive modern C++》条款28种摘录的代码片段,它展示了一种不完全符合C++标准的std::forward实现,但用来理解原理已经足够:

template<typename T>
T&& forward(typename remove_reference<T>::type& param){
return static_cast<T&&>(param);
}

看到std::forward的底层实现就是一个static_cast,于此同时万能引用与引用折叠在这里默默起了很大的作用。下面,分别阐述使用左值和右值进行forward调用的参数推导过程。

仍然用上一节的例子进行说明:

template<typename T>
void callFoo(T&& arg){
foo(std::forward<T>(arg));
}
// 情况一,传递左值
int x = 1;
callFoo(x);

// 情况二, 传递右值
callFoo(1)

①如果传递给callFoo的参数原本为左值引用的int类型,那么按照上一节的参数推导规则,T将被推导为 int&,注意这里的类型推导指callFoo这个函数的类型推导,forward将不进行类型推导,因为在执行forwar调用时已经指明了具体类型(尖括号中的T)。将int& 插入forward模板中得到下面的代码:

int& && forward(typename remove_reference<int&>::type& param) {
return static_cast<int& &&>(param);
}

其中的remove_reference<int&>::type,看名字就可以知道这就是将<>内的型别去掉引用部分后得到的型别。在这里就是int,最后加上末尾的&,那么param的型别就被推导为int&。

最后再加上引用折叠的规则,我们得到:

int& forward(int& param) {
return static_cast<int&>(param) // static_cast 将参数转化为左值引用,实际上没什么作用,因为param已经是左值引用了
}

②如果传递给callFoo的参数原本为右值引用的int类型,T将被推导为int,它不是一个引用类型,将其插入forward模板得到:

int&& forward(int& param) {
return staric_cast<int&&>(param); // static_cast 将左值引用类型的参数转化为右值引用
}

这里没有发生引用折叠。

总结:

  1. 当传递参数为左值引用时,forward将返回左值引用

  2. 当传递参数为右值值引用时,forward将返回右值引用

这恰好就是完美转发需要的组件!

3.3智能指针

1)总览

C++ 11 总共有4种智能指针, std::auto_ptr std::unique_ptr std::shared_ptr std::weak_ptr

std::auto_ptr 是个从C++98残留下来的特性,在C++17中,已经被声明为depracated了

std::unique_ptr 借助右值引用使得移动操作成为可能,解决了auto_ptr的问题

std::weak_ptr则可以用来解决std::shared_ptr的循环引用的问题。

2)std::auto_ptr

首先看看 auto_ptr, 了解我们为什么C++弃用它,它有什么不足之处。

我们把动态分配的堆内存的释放任务交给这些类,当这些类的生命周期结束时会自动调用析构函数,析构函数常常有delete之类的操作释放这些动态分配的内存。这样的好处是,指针管理维护对我们造成的心智负担会大大减少。

我们写一个Auto_Ptr类,模拟指针的操作,并且在析构函数中 对自己维护的指针进行delete

template <typename T>
class Auto_Ptr {
public:
Auto_Ptr(T* ptr) : ptr_(ptr){ }
~Auto_Ptr() {
delete ptr_;
}
// 重载下面两个运算符,使得类能够像指针一样运作
T& operator*() {
return *ptr_;
}

T* operator->() {
return ptr_;
}
private:
T* ptr_;
};

class A {
public:
A() {
std::cout <<"class A construct!\n";
}
~A() {
std::cout << "class A destroyed";
}
int attr_a = 2;
};z

int main() {
Auto_Ptr<A> autp (new A());
std::cout << autp->attr_a << std::endl;// autO的行为就像是一个指针
std::cout << (*autp).attr_a << std::endl;
return 0; // Auto_Ptr类自动delete,释放动态分配的内存
}

能够得到下面的输出信息 :

class A construct!
2
2
class A destroyed

这样一个能够自动释放动态内存的类就与智能指针类的思想类似,但是Auto_ptr现在有两个问题

  • 不能pass by value , 否则,意味有两个以上的autp_ptr中的指针指向了同一块内存,这两个autp_ptr结束生命周期时一定会调用析构函数,但无论以哪种顺序调用析构函数,都会在同一个指针上调用两次以上delete操作,segment fault!。我们可以手动禁止Auto_Ptr的复制函数, 这样倒是可以解决这个的问题。

  • 但禁止Auto_Ptr的复制函数后,如何编写一个返回Auto_Ptr对象的函数?:
    Auto_Ptr generateResource() // delete了Auto_Ptr的复制构造函数后,不能这样写了{ Resource* r{ new Resource() }; return Auto_ptr1(r);// 编译器报错}

好,那我们不删除复制函数,而是改进它: 复制函数不仅仅简单拷贝指针, 而是将指针的所有权从源对象“转移”到目标对象

template <typename T>
class Auto_Ptr {
public:
...
Auto_Ptr( Auto_Ptr& source) {
ptr_ = source.ptr_;
source.ptr_ = nullptr;
}

Auto_Ptr& operator=(Auto_Ptr& source) {
if (&source == this) {
return *this;
}
delete ptr_;
ptr_ = source.ptr_;
source.ptr_ = nullptr; // 将源对象的指针进行delete
return *this;
}
...

bool isNull() const { return ptr_ == nullptr; }
};

至少我们现在能够 对函数参数进行passby value 了, 但是我们很容易又造成访问野指针的错误,因为传统观念来看,值传递的语义就是“复制”,但是我们改造了复制函数,实际上执行是“移动”。而且从函数的声明可以看到,我们传入的是non-const参数,表示我们要修改它,这和传统的拷贝函数大不相同!

void DoSomeThing(Auto_Ptr<A> s) { // pass by value 并进行相应操作
std::cout << s->attr_a;
}
int main() {

Auto_Ptr<A> res1 (new A());
DoSomeThing(res1); // 按值传递,成功。但是res1这个变量已经被"移动"了
std::cout << res1->attr_a <<std::endl; //再次使用res1,crash !
}

总结

autpptr是C++尝试“移动语义”的开始,但是总是表现出将资源从一个object转移到另一个object的行为

autp_ptr的缺点:

  • 使用复制构造\赋值函数模拟移动语义,非常容易造成野指针现象。也不能和标准库很好地一起工作,比如一个存放auto_ptr的vector容器,对它使用std::sort函数,sort函数在某步骤中会选取序列中的某一个并保存一个局部副本
    ... value_type pivot_element = *mid_point; ...
    算法认为在这行代码执行完之后,pivot_element 和 *mid_point是相同的,但是因为auto_ptr的拷贝操作是对移动操作的模仿,当执行完这行代码后,mid_point所指向的内存是不确定的。最后算法正确性就受到了破坏

  • auto_ptr中的析构函数总是使用delete ,所以它不能对动态分配的数组做出正确的释放操作(而unique_ptr可以自定义deleter)

核心问题 :

  • 如果我们在想让对象在拷贝的时候能够被拷贝,移动的时候能够被转移控制权,那么就一切好办了。这就是为什么C++提出了“移动语义”(好家伙,C++11的新特性好多都和移动语义相关)

  • C++11提出右值引用,很方便地表达了移动语义,以此带来了表示独占的、只能被移动而不能被拷贝的unique_ptr

3)std::unique_ptr

unique_ptr的大小与裸指针相同(如果不使用函数指针自定义删除器),这是智能指针中最常用的。

关于unique_ptr的大小,库函数使用了空基类优化的技巧,具体实现方式可以参考这篇文章

C++11引进了移动语义,能够将object的移动或拷贝以更清楚的方式区分,也多出了两种特殊的成员函数, 移动构造和移动赋值。下面用新的成员函数改造之前的Auto_Ptr。其实逻辑和之前实现的拷贝函数是一样的,但这里的逻辑是移动逻辑,不应该放在拷贝函数中。

...
// 参数是右值引用, 且非const
Auto_Ptr( Auto_Ptr&& source) {
ptr_ = source.ptr_;
source.ptr_ = nullptr;
}
// 参数是右值引用,非const
Auto_Ptr& operator=( Auto_Ptr&& source) {
if (&source == this) {
return *this;
}
delete ptr_;
ptr_ = source.ptr_;
source.ptr_ = nullptr;
return *this;
}
...

参数是non-const的右值引用,因为是右值引用,所以不用加const 属性, “右值”表示这个值的生命周期很短暂,无所谓我们改不改变它。

最后我们删除拷贝函数

Auto_Ptr(const Auto_Ptr& source) = delete;
Auto_Ptr& operator=(const Auto_Ptr& source) = delete;

这样的AutoPtr类就非常类似标准库的unique_ptr

unique_ptr只允许从右值转移资源,但不能从左值拷贝资源,我们使用std::move将左值转变为右值后就可以了。但是被转变的值已经不能使用了,既然你已经move了他,那就说明被move的值可以被转移,编译器是假设程序员知道这件事的,所以我们之后再使用已经被move的变量而后导致未定义行为,责任在程序员而不是编译器。

Auto_Ptr<A> getResource() {
A* res_f = new A();

return Auto_Ptr<A>(res_f);
}
int main() {

Auto_Ptr<A> res1 (new A());
//Auto_Ptr<A> res2 (res1);// 报错
Auto_Ptr<A> res2 (std::move(res1)); // 将左值cast为右值,编译通过
Auto_Ptr<A> res3(getResource()); // 传递临时对象,即一个右值,编译通过
DoSomeThing(getResource());
DoSomeThing(std::move(res3)); // 也能值传递了 , 但是 res3 在这行之后就已经被转移了
std::cout << "res3 is " << (res3.isNull() ? "null\n" : "not null\n"); // res3 is null
std::cout <<(*res3).attr_a << std::endl; // 使用已经被转移的变量, crash!
return 0;
}

最后将上面的代码修改整合,得到一份简单的Unique_Ptr实现:

template<typename T> 
class Unique_Ptr {
private:
// 原始指针
T* resource_;

public:
// unique_ptr是只移的,因此删除赋值函数
Unique_Ptr(const Unique_Ptr&) = delete;
Unique_Ptr& operator=(const Unique_Ptr&) = delete;

// 构造函数
explicit Unique_Ptr(T* raw_ptr): resource_(raw_ptr) { } // explicit防止隐式转换
// 移动构造函数
Unique_Ptr(Unique_Ptr&& other):resource_(other.resource_) {
other.resource_ = nullptr;
}
// 移动赋值函数
Unique_Ptr& operator=(Unique_Ptr&& other) {
if (&other != this) { // 注意自赋值的情况
delete resource_;
resource_ = other.resource_;
other.resource_ = nullptr;
}
return *this;
}
// 析构函数
~Unique_Ptr() {
if (resource_) {
delete resource_;
resource_ = nullptr;
}
}
// 解引用符号 * 重载
T& operator*() const{
return *resource_;
}
// ->符号重载
T* operator->() const{
return resource_;
}
};
unique_ptr的使用场景

作为工厂函数的返回值,unique_ptr能够方便高效地、无感地转换成shared_ptr。工厂函数并不知道调用者是对器返回的对象采取专属所有权好,还是共享所有权更合适。

// 函数声明返回unique_ptr
template<typename...TS>
std::unique_ptr<Investment>
makeInvestment(Ts&&... param);

// 用户程序可以取得一个shared_ptr<Investment>, 其中的转换会默认进行
std::shared_ptr<Investment> a = makeInvestment(...);

4)std::shared_ptr

与unique_ptr不同,share_ptr对象能够与其他share_ptr对象共同指向同一个指针,内部维护一个引用计数,每多一个对象管理原指针,引用计数(reference count)就加一,每销毁一个share_ptr,引用计数减一,最后一个被销毁的shared_ptr对象负责对原始指针进行delete操作

从底层数据结构看(下图源自《effective modern c++》),shared_ptr除了保存原始指针外,还会保存一个指向控制块的指针,所以一般情况下(unique_ptr没有使用函数指针当作自定义删除器)shared_ptr的大小会比unique_ptr大两倍。控制块是一动态分配在堆内存中的,其中有引用计数、弱计数、以及其他数据(比如自定义deleter、原子操作相关的数据结构),弱计数是统计指向T object 的weak_ptr数量,这个计数不影响T object的析构,当引用计数 = 0时,T object 就会被销毁,不会管弱计数(weak count)。

shared_ptr 能够被移动也能够被拷贝,被拷贝时引用计数+1,这个引用计数使用原子变量保证线程安全(但仅仅保证RefCount的线程安全性),被移动时则不需要。因此考虑效率时,如果能够移动构造一个shared_ptr那就使用移动,不要使用拷贝。

sharedptr的线程安全性?

sharedptr使用atomic变量使得计数器的修改是原子的(即上图的RefCount是原子的),但是sheared_ptr这个类本身不是线程安全的,因为整个SharedPtr对象有两个指针,复制这两个指针的操作不是原子的!更别说sharedptr管理的对象(上图的T Object)是否有线程安全性了,除非这个对象本身有锁保护,否则不可能通过只套一层sharedptr的封装来实现线程安全性。

关于std::atomic?

C++能够提供原子操作是因为多数硬件提供了支持,比如x86的lock指令前缀,它能够加在INC XCHG CMPXCHG等指令前实现原子操作。

std::atomic比std::mutex快,是因为std::mutex的锁操作会涉及到系统调用,比如在linux上会调用futex系统调用,在某些情况下可能陷入内核。

从效率上考虑,优先使用make_shared而不是直接new创建shared_ptr

shared_ptr类有两个指针,一个指向要管理的对象,一个指向控制块。

如果使用new来创建shared_ptr:

std::shared_ptr<SomeThing> sp(new SomeThing);

编译器则会进行两次内存分配操作,一次为SomeThing的对象分配,一次为控制块分配内存。

如果使用make_shared创建:

auto sp(std::make_shared<SomeThing>())

编译会只会进行一次内存分配,对象与控制块是紧挨着的。

实现一个简单的Shared_Ptr, 其余测试代码见github仓库
// 模拟控制块类
class Counter {
public:
std::atomic<unsigned int> ref_count_;

Counter():ref_count_(0){}
Counter(unsigned int init_count):ref_count_(init_count){ }
};

// Shared_Ptr模板类
template<typename T>
class Shared_Ptr{
private:
Counter* count_;
T* resource_;

void release() {
if (count_ && resource_) { // 注意这里应该判断count_是否为nullptr,可能已经被移走了
if (--count_->ref_count_== 0) {
delete resource_;
delete count_;
resource_ = nullptr;
count_ = nullptr;
}
}
}
public:
// 构造函数
explicit Shared_Ptr():count_(new Counter(0)),resource_(nullptr) { }
explicit Shared_Ptr(T* raw_ptr):count_(new Counter(1)),resource_(raw_ptr) { }
Shared_Ptr(std::nullptr_t nPtr) {
release();
resource_ = nPtr;
count_ = nPtr;
}
// 析构函数

~Shared_Ptr() {

release();
}
// 复制构造函数
Shared_Ptr(const Shared_Ptr& other) {
resource_ = other.resource_;
count_ = other.count_;
count_->ref_count_++;
}
// 赋值构造函数
Shared_Ptr& operator=(const Shared_Ptr& other) {
if (&other != this) {
// delete resource_; // 这里有问题,能直接delete吗?
// delete count_;
release();

resource_ = other.resource_;
count_ = other.count_;
count_->ref_count_++;
}
return *this;
}

// 移动构造函数
// 注意将被移动对象的资源置空
Shared_Ptr(Shared_Ptr&& other):resource_(other.resource_), count_(other.count_) {

other.resource_ = nullptr;
other.count_ = nullptr;
}
// 移动赋值函数
Shared_Ptr& operator=(Shared_Ptr&& other) {
// 注意将被移动对象的资源置空
if (this != &other) {
release(); // 释放资源

resource_ = other.resource_;
other.resource_ = nullptr;

count_ = other.count_;
other.count_ = nullptr;
}
return *this;
}
};

5)std::weak_ptr

std::weak_ptr是std::shared_ptr的一种补充,它不是独立出现的,std::weak_ptr通常通过unique_ptr来初始化,使用了与std::shared_ptr同一个控制块,但是不会增加refcout只会增加weakcount。它既不能执行提领操作,也没有->操作.

可以通过weak_ptr来构造shared_ptr(调用lock成员函数),如果shared_ptr所指涉的对象已经被销毁,那么转换为空指针。这样在使用某个智能指针前,可以先使用weakptr检测智能指针所指涉的对象是否已经被销毁(调用expire成员函数), 这是weak_ptr操作原对象的唯一方法(即转换成shared_ptr)

关于控制块与智能指针所管理的对象的内存释放时机
  • 如果使用make_shared来创建sharedptr,由于只进行了一次内存分配,那么得等到weakcount = 0时才会回收这块内存

  • 如果使用new来创建sharedptr,这里分别进行了两次内存分配,那么当refcount = 0时,智能指针所管理的对象的内存可以立即回收,但是控制块的内存还是得等到weakcount = 0时才会回收

弱指针的应用场景
  1. 解决循环引用的资源泄漏问题

  2. 带有缓存的工厂函数:函数返回sharedptr,工厂内部使用weak_ptr指涉客户所要创建的对象

  3. 观察者设计模式

为一个类设计一个成员函数,返回一个shared_ptr智能指针,指针指向自己?

错误的做法是:

struct Bad
{
std::shared_ptr<Bad> getptr()
{
return std::shared_ptr<Bad>(this);
}
~Bad() { std::cout << "Bad::~Bad() called\n"; }
};

为什么?因为getptr成员函数会再分配一个控制块来管理Bad的某个对象,如果这个对象已经被一个shareptr管理的话,那么就可能发生double free运行时错误。具体一点,就如下面这段代码:

// Bad, each shared_ptr thinks it's the only owner of the object
std::shared_ptr<Bad> bad0 = std::make_shared<Bad>();
std::shared_ptr<Bad> bad1 = bad0->getptr();
// UB: double-delete of Bad

第一个语句调用make_shared会分配一个控制块,第二个语句调用通过成员函数再次分配一个控制块,但是这两个控制块都控制同一个对象指针,最后一定会对对象进行两次的free,从而引发double free错误。

正确的做法是继承std::enable_shared_from_this,调用它提供的父类方法来获取指向自身的sharedptr:

class Good : public std::enable_shared_from_this<Good>
{
public:
std::shared_ptr<Good> getptr()
{
return shared_from_this();
}
};
// 正确的食用方式:
std::shared_ptr<Good> good0 = std::make_shared<Good>(); // 注意必须已经有一个sharedptr才可以,否则抛异常,详见cppreference的对应代码
std::shared_ptr<Good> good1 = good0->getptr();

那么enable_shared_from_this是怎么样避免double free错误的呢?猜一下就能知道它可能使用了weakptr:

template<class _Tp>
class _LIBCPP_TEMPLATE_VIS enable_shared_from_this
{
mutable weak_ptr<_Tp> __weak_this_;
// ...

3.4lambda表达式

1)本质

lambda的本质是一个仿函数(functor),编译器看到lambda表达式后会产生一个匿名class,这个class重载了()操作符

比如下面这个仿函数:

class X {
int a = 1;
public:
void operator()(int b) {
printf("a + b = %d\n", a + b);
}
};

X x_functor;

它的作用效果与下面lambda表达式相同:

auto x_lambda = [a = 1](int b) {printf("a + b = %d\n", a + b);};

两者的调用方式和调用一个函数的方式相同:

x_functor(1);
x_lambda(1);

编译期,编译器遇到lambda表达式则会生成一个匿名仿函数类型(closure type);运行期,当使用lambda表达式时,则根据编译器生成的匿名仿函数类型创建一个对象,该对象本质就是functor对象。

2)语法

lambda表达式的语法如下:

[捕获值] (参数列表) ->返回类型 {函数体}

捕获值

  • 能够捕获本lambda表达式所处作用域中的局部变量(不包括类的成员变量)或this指针,使其能够在{}内的函数体中可以被使用

  • 捕获方式有按值和按引用两种

  • 可以空着,这相当于生成了一个没有成员变量的仿函数

-> 返回类型

通常可不写,编译器从函数体中自动推导

其中关于捕获的注意点最多:按值和按引用捕获的区别

int main()
{
int x = 42;

auto byvalue = [x] ( ) // 按值捕获局部变量x,记住当lambda表达式被evaluated时,值就已经被捕获了
{
std::cout << "Hello from a lambda expression, value = " << x << std::endl;
};

auto byref = [&x] ( ) // 按引用捕获局部变量x
{
std::cout << "Hello from a lambda expression, value = " << x << std::endl;
};
x = 7;
byvalue(); // 42, 按值捕获且在lambda表达式被创建时就被捕获,因此不受影响
byref(); // 7 , 按引用捕获因此受影响
}

按值捕获的变量是只读的,如果要修改它,则应该在参数列表后加上mutable关键字

auto myLamb = [x] ( ) mutable { return ++x; };

避免默认捕获模式,详见effecttive modern C++条款31

  • 按引用的默认捕获方式容易造成指针空悬

  • 看似能够捕获成员变量,实际上则是捕获了this指针,因此也容易造成指针空悬

默认捕获不能捕获全局变量!

int g = 10;
auto kitten = [=]() { return g+1; }; // 默认按值捕获,但是编译器发现g是全局变量,根本不需要捕获
auto cat = [g=g]() { return g+1; }; // 广义的按值捕获则可能得到预期结果
int main() {
g = 20;
printf(%d %d\n", kitten(), cat());// 21 11
}

最好都是写成广义捕获的形式,这是C++14支持的特性

auto cat = [g=g]() { return g+1; }; // 按值捕获g 
auto dog = [&g=g]() { return g+1; }; // 按引用捕获g

注意,= 号两边的g是不同的,左边的g是lambda表达式所处作用域的局部变量,右边的g则是编译器为lambda表达式生成的functor中的成员变量

3.5四大转换

C++相比于C语言多出了4种转换,并且也兼容C风格的转换。C风格的转换几乎可以转换任何类型,简单方便的同时增大了出错地可能性。

// 两种通用的转换方式,容易出错
double x = 10.3;
int y;
// C++存在两种通用类型的转换,第二种则是C风格的转换,第一种和第二种的作用相同
y = int (x); // functional notation,
y = (int) x; // c-like cast notation

C风格的转换能够做以下所有的转换 :

  1. Between two arithmetic types

  2. Between a pointer type and an integer type

  3. Between two pointer types

  4. Between a cv-qualified and cv-unqualified type (简单说就是const类型与非const类型的转换)

  5. A combination of (4) and either (1), (2), or (3)

C风格转换的缺点 :

  1. They allows casting practically any type to any other type, leading to lots of unnecessary trouble - even to creating source code that will compile but not to the intended result.

  2. The syntax is the same for every casting operation, making it impossible for the compiler and users to tell the intended purpose of the cast.

  3. Hard to identify in the source code.

C++提供了另外四种转换:

1)dynamic_cast

dynamic_cast:只能转换指向class的指针或引用(通常涉及多态),能够确保转换的结果指向目标指针类型的完整对象( Its purpose is to ensure that the result of the type conversion points to a valid complete object of the destination pointer type.)。

1.dynamic_cast能够将类指针向上转型(派生类指针指向基类指针),这和static_cast相似,不需要被转换的类拥有虚函数,而且C++标准规定在这种情况下产生与static_cast一致的底层代码。如下所示,没有产生编译错误:

class A {

};

class B : public A{
};
int main() {
B* b = new B();
A* d = dynamic_cast<A*>(b); // 子类指针转向父类指针
}

也可以将执行向下转型(将基类型指针转换成派生类型的指针),但是满足两个条件转换才能成功 :

基类必须有虚函数,即只对那些展现“多态”的类型,才可能执行向下转换。否则编译器报错:

class A{

};
class B : public A {
};
int main() {
A* a = new A();
B* c = dynamic_cast<B*>(a);
// 编译器报错: cannot dynamic_cast 'a’ (of type 'class A*’) to type 'class B*’ (source type is not polymorphic)
}

最起码,父类具有虚函数才可以,这样父子类都有了虚函数,也就都有个运行时类信息,才能通过编译:

class A {
public:
virtual ~A() {
}
};
class B : public A{

};
int main() {
A* a = new A();
B* c = dynamic_cast<B*>(a);
}

2.但是通过编译不代表转换成功,如果转换后的对象指针确实是目标对象的指针,那么转换成功。但如果dynamic_cast向下转换失败则会返回nullptr(指针之间的转换)或者抛出异常(引用之间的转换)。程序员通过检查指针,就可以知道向下转型是否成功。

class A {
public:
virtual ~A() {}
};
class B : public A{
};
class C {
public:
virtual ~C() {}
};
int main() {

C* c_ptr = new C();
A* a = dynamic_cast<A*>(c_ptr);
printf("a = %p\n", a); // a = (nil), 说明转换不成功
}

dynamici_cast使用场景:

using namespace std;
class Base { virtual void dummy() {} };
class Derived: public Base { int a; };
int main () {
try {
Base * pba = new Derived;
Base * pbb = new Base;
Derived * pd;

pd = dynamic_cast<Derived*>(pba); // 转换成功
if (pd==0) cout << "Null pointer on first type-cast.\n";

pd = dynamic_cast<Derived*>(pbb); // 这个转换不会成功但不会抛出异常,只会返回nullptr
if (pd==0) cout << "Null pointer on second type-cast.\n";

} catch (exception& e) {cout << "Exception: " << e.what();}
return 0;
}
// 结果
Null pointer on second type-cast.

关于dynamic_cast的实现原理,看了《深度理解C++对象模型》后了解到编译器会将对象的运行时类型信息(RTTI)指针连同虚函数指针一起放在虚函数表中(RTTI的指针在函数指针的上方),这也就是为什么不具多态意图的class不能执行dynamic_cast的原因,因为这些类没有虚函数,也就没有虚函数表,那也没有地方存放类型信息。

2)static_cast

static_cast: 能够做与dynamic_cast相似的工作(即类层次指针间向上/向下转型),但是编译器不会在运行期检查(向下)转换后的object指针是否为目标object指针,因此转换是否成功是由开发人员自己保证的。static_cast用于有直接或间接关系的指针或引用之间转换。

没有继承关系的指针不能用static_cast转换,可以考虑使用reinterpret_cast。

当然static_cast除了可以做类层次结构指针之间的转换外还可以做其他很多其他类型的转换:

  • 将void指针转换成任何其他类型的指针,但是会检查void*指针是否由同一类型的指针转换而来(存疑!)(C风格的转换和reinterpret_cast不会检查)

  • 用于基本数据类型之间的转换

static_cast转换两个没有关系的类指针时会产生编译错误:
class A {

};

class B {

};
int main() {

A* a = new A();
B* b = new B();
B* c = static_cast<B*>(a); // compiler error ! invalid static_cast from type 'A*’ to type 'B*’
A* d = static_cast<A*>(b); // compiler error !

}

如果B继承自A或者A继承自B,就不会产生编译时错误

class B : public A{

};
“子类指针转换成父类指针,使用static_cast、dynamic_cast两种中的任意一种都会产生相同的代码”。接下来验证这件事

为了不至于太简单,我在B中加了一个虚函数,这样当子类转化成父类时,编译器将调整this指针跳过vptr

class A {
public:
int a = 1;
};

class B : public A{
public:
int b = 1;
virtual int fun1() {
return 1;
};
};

int main() {
B* b_ptr = new B();
A* a_ptr = static_cast<A*>(b_ptr);
A* a_ptr2 = dynamic_cast<A*>(b_ptr);
}

实验的编译器版本为g++7.5:

g++ cast.cpp -o cast && objdump -d cast > cast.asm

然后找到返汇编文件中的关于cast的相关代码,我删除了一些无关代码:

8e3:	48 8b 45 d8          	mov    -0x28(%rbp),%rax # 使用static_cast进行转换
8e7: 48 83 c0 08 add $0x8,%rax
8f2: 48 89 45 e0 mov %rax,-0x20(%rbp)

8fd: 48 8b 45 d8 mov -0x28(%rbp),%rax # 使用dynamic_cast进行转换
901: 48 83 c0 08 add $0x8,%rax
90c: 48 89 45 e8 mov %rax,-0x18(%rbp)

可以看出,dynamic_cast进行转换的逻辑与static_cast相同,换句话说,这里的dynamic_cast根本没有进行“动态”转换。

2)reinterpret_cast

reinterpret_cast能够将任何类型的指针转换成任意类型,即使这两个类型没有任何关系(主要是没有继承关系)。它只是在两个指针之间简单地执行二进制拷贝,不会进行任何检查。也可以将指针转换成整型。

reinterpret_cast几乎与C风格的转换可以做同样多的事,但它依然不能将const的类型的object转换成non const, 不止reinterpret_cast,以上三种C++的类型转换都不能将object的const属性去除(但是C风格的转换不管,这也是它不安全的原因之一),唯一能够将const对象转换成非const的C++风格的转换是下面的const_cast

3)const_cast

如上所说,这是C++提供的4种转换种的唯一一个可以"抹除"object const属性的转换方式

4)实战示例

来自CMU15445lab

reinterpret_cast在lab源码中出现的频率很高, 比如 :

reinterpret_cast<Page *>(bucket_page)->WLatch();
// modify bucket
reinterpret_cast<Page *>(bucket_page)->WUnlatch();

BucektPage 与 Page根本没有继承关系所以使用reinterpret_cast转换,但是这对Page中的成员的顺序由要求

下面是Page的成员组成:

class Page {
...
/** The actual data that is stored within a page. */
char data_[PAGE_SIZE]{};
/** The ID of this page. */
page_id_t page_id_ = INVALID_PAGE_ID;
/** The pin count of this page. */
int pin_count_ = 0;
/** True if the page is dirty, i.e. it is different from its corresponding page on disk. */
bool is_dirty_ = false;
/** Page latch. */
ReaderWriterLatch rwlatch_;
}

其中data_就是实际page的开始地址,我们使用reinterpret_cast把char* 转换为 BucketPage*

bucket_page =
reinterpret_cast<HASH_TABLE_BUCKET_TYPE *>(buffer_pool_manager_->FetchPage(bucket_page_id)->GetData());

按照Struct成员再内存中的分布,我们可以得到下面的示意图

编译器由低地址向高地址取得内存中的内容并将它解释为对应的类,无论是Page还是BucketPage都是合法的不会产生错误。

但如果 data_声明在最后会怎样?

因为使用reinterpret_cast,所以编译器不会进行任何检查,只会从低地址一直向上解释 length of data_ 个字节数为BucketPage, 很显然这是错误的。

    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多