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

本文涉及的产品
云解析 DNS,旗舰版 1个月
全局流量管理 GTM,标准版 1个月
公共DNS(含HTTPDNS解析),每月1000万次HTTP解析
简介: 全面解析C++11新特性:现代编程的新起点

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

image.png


一、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,我们可以使用范围-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。

640.jpg

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

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

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

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

640.jpg

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

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

640.png

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

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

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相关的讨论见下一小节:

640.jpg

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

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

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

640.jpg

第一个赋值动作 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', 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在这一行以后将不会再被使用。但是进入到拷贝函数的作用域中,编译器又不能确定该参数的生命周期了,因此在拷贝函数的作用域中还是将其看作左值类型。

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

相关文章
|
1月前
|
自然语言处理 编译器 Linux
|
25天前
|
设计模式 安全 数据库连接
【C++11】包装器:深入解析与实现技巧
本文深入探讨了C++中包装器的定义、实现方式及其应用。包装器通过封装底层细节,提供更简洁、易用的接口,常用于资源管理、接口封装和类型安全。文章详细介绍了使用RAII、智能指针、模板等技术实现包装器的方法,并通过多个案例分析展示了其在实际开发中的应用。最后,讨论了性能优化策略,帮助开发者编写高效、可靠的C++代码。
35 2
|
3天前
|
安全 编译器 C++
C++ `noexcept` 关键字的深入解析
`noexcept` 关键字在 C++ 中用于指示函数不会抛出异常,有助于编译器优化和提高程序的可靠性。它可以减少代码大小、提高执行效率,并增强程序的稳定性和可预测性。`noexcept` 还可以影响函数重载和模板特化的决策。使用时需谨慎,确保函数确实不会抛出异常,否则可能导致程序崩溃。通过合理使用 `noexcept`,开发者可以编写出更高效、更可靠的 C++ 代码。
10 0
|
3天前
|
存储 程序员 C++
深入解析C++中的函数指针与`typedef`的妙用
本文深入解析了C++中的函数指针及其与`typedef`的结合使用。通过图示和代码示例,详细介绍了函数指针的基本概念、声明和使用方法,并展示了如何利用`typedef`简化复杂的函数指针声明,提升代码的可读性和可维护性。
19 0
|
1月前
|
消息中间件 存储 安全
|
23天前
|
存储 编译器 C语言
【c++丨STL】string类的使用
本文介绍了C++中`string`类的基本概念及其主要接口。`string`类在C++标准库中扮演着重要角色,它提供了比C语言中字符串处理函数更丰富、安全和便捷的功能。文章详细讲解了`string`类的构造函数、赋值运算符、容量管理接口、元素访问及遍历方法、字符串修改操作、字符串运算接口、常量成员和非成员函数等内容。通过实例演示了如何使用这些接口进行字符串的创建、修改、查找和比较等操作,帮助读者更好地理解和掌握`string`类的应用。
36 2
|
29天前
|
存储 编译器 C++
【c++】类和对象(下)(取地址运算符重载、深究构造函数、类型转换、static修饰成员、友元、内部类、匿名对象)
本文介绍了C++中类和对象的高级特性,包括取地址运算符重载、构造函数的初始化列表、类型转换、static修饰成员、友元、内部类及匿名对象等内容。文章详细解释了每个概念的使用方法和注意事项,帮助读者深入了解C++面向对象编程的核心机制。
82 5
|
1月前
|
存储 编译器 C++
【c++】类和对象(中)(构造函数、析构函数、拷贝构造、赋值重载)
本文深入探讨了C++类的默认成员函数,包括构造函数、析构函数、拷贝构造函数和赋值重载。构造函数用于对象的初始化,析构函数用于对象销毁时的资源清理,拷贝构造函数用于对象的拷贝,赋值重载用于已存在对象的赋值。文章详细介绍了每个函数的特点、使用方法及注意事项,并提供了代码示例。这些默认成员函数确保了资源的正确管理和对象状态的维护。
79 4
|
1月前
|
存储 编译器 Linux
【c++】类和对象(上)(类的定义格式、访问限定符、类域、类的实例化、对象的内存大小、this指针)
本文介绍了C++中的类和对象,包括类的概念、定义格式、访问限定符、类域、对象的创建及内存大小、以及this指针。通过示例代码详细解释了类的定义、成员函数和成员变量的作用,以及如何使用访问限定符控制成员的访问权限。此外,还讨论了对象的内存分配规则和this指针的使用场景,帮助读者深入理解面向对象编程的核心概念。
86 4
|
2月前
|
存储 编译器 对象存储
【C++打怪之路Lv5】-- 类和对象(下)
【C++打怪之路Lv5】-- 类和对象(下)
31 4

推荐镜像

更多