【C++】C++11常用特性总结-1

简介: 【C++】C++11常用特性总结-1

哥们哥们,把书读烂,困在爱里是笨蛋!9cd304da00d64b45834917a0e3d585e9.jpeg


一、统一的列表初始化

1.统一的{}初始化


1.

以前在C语言的时候,{}一般用于初始化数组或结构体,例如下面代码的初始化方式,数组array1和array2可以在创建的同时进行初始化,同样结构体p1和p2也可以在定义的时候初始化,p2的_y成员变量默认初始化为0.

struct Point
{
  int _x;
  int _y;
};
int main()
{
  int array1[] = { 1, 2, 3, 4, 5 };
  int array2[5] = { 0 };
  Point p1 = { 1, 2 };
  Point p2 = { 1 };// _y默认初始化成0了
  return 0;
}


2.

然而在C++11中,扩大了{}列表初始化的范围,使其能够初始化所有的自定义类型和内置类型,实现统一的列表初始化{},并且在初始化的时候,如果不想写=赋值符号,也可以将其省略,只保留{}花括号列表。


例如下面的x1 x2变量在初始化时,也可以用{}来初始化,并且也可以省略=,同样的结构体p1和数组array1 array2都可以省略=,但并不推荐省略花括号,因为省略之后确实有些奇怪,但如果你非要省略,那编译器也拦不住你,语法规定既可以省略也可以不省略。

列表初始化当然也可以用在new开空间的时候,例如p2指针指向空间在开辟的时候,内部元素被初始化为0,p3结构体指针指向的结构体数组也可以用统一的列表进行初始化。

以上都是列表作用于C语言的对象上面,例如数组 结构体等对象。

struct Point
{
  int _x;
  int _y;
};
int main()
{
  //用初始化列表初始化时,可以省略 赋值符号=
  //以前的{}只能初始化结构体和数组,现在的{}对于自定义类型和内置类型的对象都可以初始化,一切皆可用列表初始化。
  int x1 = 1;
  int x2{ 2 };//不要这么玩,我们自己能看懂就行
  int array1[]{ 1, 2, 3, 4, 5 };
  int array2[5]{ 0 };
  Point p1{ 1, 2 };//对结构对象进行初始化,c++11可以省略=
  // C++11中列表初始化也可以适用于new表达式中
  int* p2 = new int[4]{ 0 };
  Point* p3 = new Point[2]{ {1,2}, {3,4} };
  return 0;
}


3.

列表在C++中作用于对象时,同样也可以初始化对象。当然,{}列表在初始化对象的时候会调用对象所属类的构造函数。如果你调试下面代码,其实就可以发现光标在初始化对象时,会跳到类的构造函数处进行对象的初始化。

class Date
{
public:
  Date(int year, int month, int day)
    :_year(year)
    , _month(month)
    , _day(day)
  {
    cout << "Date(int year, int month, int day)" << endl;
  }
private:
  int _year;
  int _month;
  int _day;
};
int main()
{
  Date d1(2022, 1, 1); // old style
  // C++11支持的列表初始化,下面会调用构造函数初始化
  Date d2{ 2022, 1, 2 };
  Date d3 = { 2022, 1, 3 };//构造一个临时对象,然后拷贝构造给d3-->编译器优化为直接构造
  return 0;
}


2.std::initializer_list类型的初始化


1.

下面对于STL容器初始化的方式,实际是使用了C++11中新增的一个类,叫做initializer_list,这个类的对象的形式其实就是下面代码赋值符号右边的部分,右边就是initializer_list对象,所以下面初始化的方式就是将initializer_list对象拷贝给v和vv对象。

int main()
{
  vector<int> v = { 1, 2, 3 };
  vector<int> vv = { 1, 2, 3, 4, 5, 6 };
}

3909c0531da5482a8532864b914b484e.png


2.

那vector容器实现了initializer_list对象的拷贝构造吗?当然实现了,在C++11中所有的STL容器(适配器不算)都实现了initializer_list对象的拷贝构造,包括map,set,list,vector,unordered系列等容器在C++11版本中都增加了initializer_list对象的初始化。

6de842160cd546aa806550619b04807b.png3.

那initializer_list大概是一个什么类呢?其实我们可以将其认为成是一个常量数组,这个类的成员函数也很简单,只有构造和两个获取迭代器的接口,当然他的底层实现也一定不复杂,因为他本质就是一个数组,维护的成本很低。

a9e2ecccc5674dab94ae1a051eb20f5f.png


4.

我们自己模拟实现一个用initializer_list对象拷贝构造vector对象的函数,也很简单,只需要遍历initializer_list对象,将每一个元素尾插到vector当中即可。例如下面代码实现的拷贝构造,因为initializer_list实现了迭代器,所以遍历initializer_list可以用范围for来实现,注意取initializer_list内部的迭代器类型时,要加typename关键字告诉编译器你取的是类内部的内嵌类型。遍历initializer_lis对象中的每一个元素将其拷贝到vector中,这样就实现了vector的initializer_list版本的拷贝构造了,实现拷贝赋值,我们可以让拷贝构造给我们打工,然后交换资源即可,即为交换指针,这样就实现了拷贝赋值,是不是很简单呢?

namespace wyn
{
  template<class T>
  class vector {
  public:
    typedef T* iterator;//vector的迭代器就是原生指针嘛
    vector(const initializer_list<T>& l)
    {
      _start = new T[l.size()];
      _finish = _start + l.size();
      _endofstorage = _start + l.size();
      iterator vit = _start;
      typename initializer_list<T>::iterator lit = l.begin();
      //取类模板的内嵌类型记得加typename关键字,因为编译器不知道你取的是类型还是静态变量或函数
      while (lit != l.end())
      {
        *vit++ = *lit++;
      }
      //for (auto e : l)
      //   *vit++ = e;
    }
    vector<T>& operator=(initializer_list<T> l) {
      vector<T> tmp(l);
      std::swap(_start, tmp._start);
      std::swap(_finish, tmp._finish);
      std::swap(_endofstorage, tmp._endofstorage);
      return *this;
    }
  private:
    iterator _start;
    iterator _finish;
    iterator _endofstorage;
  };
}
int main()
{
  wyn::vector<int> v = { 1, 2, 3, 4, 5, 6 };//用initializer_list对象进行vector的拷贝构造
  v = { 11, 22, 33, 44, 55, 66 };//用initializer_list对象进行vector的拷贝赋值
}


5.

除上面那样较为简单的initializer_list的使用方法外,向下面这样初始化vector< Date >和map<string, string>对象,同样也可以使用initializer_list对象。

下面两行代码赋值符号的右边都是混和用了C++11的列表初始化和initializer_list初始化。先用列表{}初始化对象,然后再把对象当作常量数组的元素,构造出initializer_list对象,最后将这个对象拷贝构造给v3和dict对象。

int main()
{
  vector<Date> v3 = { {1,1,1}, {2,2,2}, {3,3,3} };//用Date对象的一个常量对象数组来初始化构造一个vector
  // 这里{"sort", "排序"}会先初始化构造一个pair对象,然后用pair对象搞一个常量对象数组来初始化构造一个map
  //里层是pair类的列表初始化{},会调用pair的构造函数,外层是initializer_list类,会生成匿名initializer_list对象
  map<string, string> dict = { {"sort", "排序"}, {"insert", "插入"} };
  // 使用大括号initializer_list对象进行vector的赋值,stl容器也支持这样的赋值方式
  return 0;
}


6.

C++11新增的{}列表统一初始化,和用initializer_list对象初始化容器的这两种方式,在初始化形式上都是利用了{}进行初始化。所以从语法角度上讲,无论是在C++还是在C语言中,所有的变量和自定义对象都可以用{}花括号进行初始化了,包括STL的容器,实现统一的列表{ }初始化。

这里的统一实际上是指使用形式的统一,列表初始化是C++11新增的语法,initializer_list是C++新增的类,这一点我们要分清楚。


二、简化声明的关键字

1.decltype


1.

C++11新增了关键字decltype,此关键字可以帮助我们简化变量的声明,typeid仅仅能获取某一类型的信息,但decltype可以获取变量的类型后,定义出其他的变量,例如在F函数中,我们想用t1 * t2的结果类型定义出一个变量ret,但我们不清楚t1和t2是什么类型,我们不能单独用t1或t2类型定义出变量ret。此时就可以用decltype获取t1 * t2的结果类型,并用这个结果类型定义出变量ret。


2.

但实际上,我们可以用auto推导出t1*t2的结果类型,然后用结果类型定义出ret变量,所以decltype看起来花里胡哨的,但非常的鸡肋好吧,真不如auto好用。

template<class T1, class T2>
void F(T1 t1, T2 t2)
{
  //decltype()拿到括号里的类型,可以用类型定义出变量 --> 看起来挺牛逼,实际也没啥用
  decltype(t1 * t2) ret = t1 * t2;//用t1*t2的类型 定义变量ret,做到了typeid做不到的事情
  //typeid().name拿到的是类型的字符串,无法定义变量,只能用来打印
  cout << typeid(ret).name() << endl;
  cout << ret << endl;
}
int main()
{
  const int x = 1;    double y = 2.2;
  decltype(x * y) ret; // 用x * y的类型double,定义出ret变量
  decltype(&x) p;      // 用&x的类型int const*,定义出p变量
  cout << typeid(ret).name() << endl;
  cout << typeid(p).name() << endl;
  F(1, 'a');//字符其实就是ascii码
  F(x, y);
  return 0;
}


2.auto && nullptr


1.

auto是非常常见的一种简化变量或对象声明的一个关键字,例如在写范围for迭代遍历容器时,我们经常不想写容器元素的类型,此时一般都会选择用auto来声明容器元素的类型。

auto也可以进行引用推导,即定义一个变量,此变量的类型用auto推导,并且此变量为引用实体的别名,但需注意的是,auto在进行引用推导的时候,必须加&,也就是auto & r=x;这样的形式,r就是x的别名,x的类型auto会自动推导出来。

所以auto还是一个非常不错的关键字,在写代码的时候能帮使代码变得更加简洁一些。


2.

下面是C++98中NULL这个宏的定义,由于NULL既可表示字面值0,也可以表示空指针(void*)0,为了便于区分字面值和空指针,在C++11中引入了关键字nullptr,此关键字专门用来表示空指针,正好能和字面值0区分开来。

// NULL既能表示为整型常量,又能表示为指针常量
//所以出于清晰和安全的角度考虑,C++11中新增了nullptr,用于表示空指针。
#ifndef NULL
#ifdef __cplusplus
#define NULL   0
#else
#define NULL   ((void *)0)
#endif
#endif


三、STL中的一些变化

1.新增容器:array && forward_list && unordered系列容器


1.

C++11中新增了容器array,array是一个固定大小的序列式容器,其模板定义中含有非类型模板参数N,N代表数组的大小。

这个容器对标的其实就是C语言的静态数组,也就是平常我们定义的定长数组。array容器比我们定义的数组要说强,那其实就是强在越界访问检查机制上面了,array会进行越界访问的assert断言检查,而静态数组对于较远的空间位置的越界访问是有可能不报错的,因为较远的空间一般不会被OS分配出去,所以此时不会报错。


2.

但其实array也没个p用,我们都已经有vector了,为什么还要用array呢?vector也有断言检查越界啊,并且vector还是动态开辟的,静态我们就用自己定义的静态数组,为什么要用你的array啊?所以这个容器和关键字decltype一样都是鸡肋。

template < class T, size_t N > class array;
int main()
{
  // array<int, 10> a1;   vs   int a2[10];
  //array相比静态数组有什么优势?
  array<int, 10> a1;//堆空间
  int a2[10];//栈空间
  //越界读不会报错
  a2[10];
  a2[20];
  //   越界写的位置较近会报错,较远时不会报错 --> 系统对于普通数组的越界是一种抽查行为
  //如果越界的空间位置没有被分配出去,那一般访问时不会报错,如果被分配出去,则进行数据覆盖时会发生越权,那就会报错
  a2[10] = 10;//报错
  a2[20] = 20;//不报错
  a1[20];//a1无论读写都可以检查出来,a1是array的对象,这里调用operator[]函数,内部会进行assert检查。
  //但其实array也没啥用,虽然比C语言的普通数组多了越界的检查,但既然我已经有了vector,那为什么还要用你的array呢?
  //我的vector的operator[]也可以检查越界啊!
  vector<int> v(10);//默认给我们初始化成int(),也就是初始化为0,不比array用的舒服?你array还不给我初始化。
  return 0;
}



3.

C++11新增的forward_list是单链表,这个也没啥用,库里只实现了forward_list的头插和头删,尾插尾删并没有实现,因为得找尾,找尾的效率很低,所以forward_list和list相比更是被虐的渣渣不剩了,list是带头双向循环链表啊,结构比你单链表牛逼多了,我有list为啥用你单链表呢?

可能单链表唯一的优势就是占用的内存空间少一点,每一个结点能省下一个指针,也是比较鸡肋的。


4.

比较有用的就是unordered系列容器了,这个容器也是C++11新增的,其实就是我之前文章讲的哈希表,底层是用挂哈希桶的方式实现的,哈希桶就是用单链表的方式来实现的,单链表在这个地方作为某些数据结构的子结构还是有点用的。


2.新增接口:移动构造/赋值 && emplace系列接口


1.

移动构造和移动赋值放到第四部分展开讲解。

emplace涉及到可变参数模板和右值引用的知识,所以emplace放到第七部分讲解。


2.

C++11其实还新增了C系列的获取迭代器的接口,其实也没必要,因为原来的迭代器接口已经实现了const和非const两个版本了,但C++委员会可能怕有的人看不懂这样的函数重载,或者const关键字的用法,又专门搞出来C系列的获取迭代器的接口,专门给const对象获取const迭代器时进行调用。


四、右值引用和移动语义(重要)

1.什么是左值和右值?


1.

左值就是能够取地址,能被赋值的数据表达式,比如变量或解引用的指针等,解引用后的指针实际也是变量。左值既可以出现在赋值符号的左面,也可以出现在赋值符号的右面。f28fa594f2c444d08dd33fc0fa8d8aa2.png


2.

右值不能被取地址,不能被赋值,右值有字面值,表达式的返回值,传值返回的函数调用的返回值,匿名对象等等,都是常见的右值。右值在引用时用&&来表示右值引用。


fd66dbd29388443daf68b13b51ae7cf4.png


2.左值引用和右值引用 的引用规则


1.

左值引用只能引用左值,但const左值引用可以引用右值。右值引用只能引用右值,但右值引用可以引用move以后的左值,因为左值move以后,其实就变成右值了。

std命名空间中的move函数可以将左值转为右值,move其实就是移动语义,move后的左值变成将亡值。(右值可以分为纯右值和将亡值,纯右值例如字面值,表达式返回值,将亡值例如匿名对象,传值返回的返回值也就是临时对象)

什么是将亡值呢?例如匿名对象,传值返回的函数调用的返回值等,因为匿名对象在其所在代码行执行完毕后就会被销毁,并且传值返回的函数调用实际利用了中间生成的一个临时变量将返回值从被调用的函数栈帧即将销毁时带出,这个临时变量的值一旦被接收,也就是拷贝或者引用后,此临时变量就会被销毁。所以我们把匿名对象,传值返回的函数调用的返回值称为将亡值,将亡值也是右值。

int main()
{
    // 左值引用只能引用左值,不能引用右值。
    int a = 10;
    int& ra = a;   // ra为a的别名
    //int& ra2 = 10;   // 编译失败,因为10是右值
    // const左值引用既可引用左值,也可引用右值。
    const int& ra3 = 10;//权限平移
    const int& ra4 = a;//权限缩小
    return 0;
}
int main()
{
    // 右值引用只能右值,不能引用左值。
    int&& r1 = 10;
    // error C2440: “初始化”: 无法从“int”转换为“int &&”
    // message : 无法将左值绑定到右值引用
    int a = 10;
    int&& r2 = a;
    // 右值引用可以引用move以后的左值,move会返回右值引用
    int&& r3 = std::move(a);
    return 0;
}



3.右值引用价值之一:右值拷贝或赋值给其他对象(移动构造和移动赋值)


1.

左值引用的意义就是减少拷贝,提高效率。例如函数参数是左值引用,那就可以减少拷贝提高效率,事实上函数的返回值也可以是左值引用,但必须要求返回值出了函数作用域未被销毁,这样才能用左值引用返回,减少拷贝提高效率。

那当某个函数的返回值就是一个临时对象呢?我们就用不了传引用返回,只能用传值返回。如果返回的是内置类型,代价还好,如果返回的是一个vector< vector< int >>的二维数组呢?比如力扣杨辉三角那个题返回的就是二维数组,那就需要深拷贝一个二维数组,然后随着栈帧销毁,原来的二维数组也会被销毁,这样的代价就会非常的大了。如果返回的是一棵红黑树呢?我们在深拷贝一棵红黑树吗?效率未免也太低了吧!


2.

所以此时出现了右值引用,其价值之一就是为了补齐左值引用的最后一块儿短板,即当返回值为传值传递时,有可能带来深拷贝导致程序效率降低的问题。

实际上在以前没有右值引用的时候,是通过输出型参数来解决传值返回代价太大的问题,即在调用函数之前创建好要返回的对象,然后将这个返回的对象通过传引用的方式来传参,在函数内部通过改变这个输出型参数的方式来改变函数外面提前创建好的对象或变量。

输出型参数确实解决了问题,但由于使用起来有些别扭,C++11又搞出来了右值引用,当然右值引用解决传值返回代价大的问题只是右值引用的价值之一而已,右值引用在插入数据时也能提高效率,减少深拷贝的次数。


3.

下面是自己实现的一个string类,在拷贝构造,拷贝赋值等函数内部进行了语句的打印,方便我们在测试时看到对应的输出结果。并且还实现了一个专门用于测试传值返回的函数to_string()。

wyn命名空间中的移动构造和移动赋值也实现了,实际移动构造和移动赋值的原理很简单,就是因为右值是将亡值嘛,那既然你都是将亡值了,何不直接把资源转移给被拷贝或是被赋值的对象呢?这样就不需要进行资源的重新拷贝了啊。用移动拷贝和移动赋值对比拷贝构造和拷贝赋值就可以看出来,拷贝构造和拷贝赋值即使参数是左值引用,在传参时能够减少拷贝提高效率,但只要涉及到资源申请的时候,拷贝构造和拷贝赋值内部一定是将资源重新拷贝了一份,把重新拷贝的资源给到被拷贝对象或被赋值对象,这当然一点问题都没有。

但如果是右值的拷贝或赋值呢?因为拷贝构造和拷贝赋值的参数是const左值引用,自然可以接收右值的传参,在函数内部必然还是要进行资源的重新拷贝,那是不是有点太浪费了啊?此时就需要移动拷贝和移动赋值来提高这样场景下的效率,怎么提高呢?很简单,我们不再拷贝资源,而是直接将右值的资源移动给被拷贝对象或被赋值对象,省去一次深拷贝。

而像函数传值返回带来的深拷贝这样的问题此时也就迎刃而解了,如果我们要将传值返回的返回值接收,也就是用返回值初始化接收值时,由于返回值是一个右值,此时不再匹配拷贝构造或拷贝赋值,而是直接匹配移动构造或移动赋值,将右值的空间资源通过交换指针这样的浅拷贝的方式来实现转移,这样就不会进行深拷贝,提高了传值返回接收时带来的深拷贝的问题。

2d308aed5b0f49e3818071245d27cd5d.png



4.

从打印结果就可以看出,当拷贝对象是右值的时候,不会再调用深拷贝构造或是深拷贝赋值了,而是调用移动构造和移动赋值。

cef0c8ec86fd43fda284fc7237403891.png



5.

对于string这样涉及资源申请的类,实现移动构造和移动赋值是非常有必要的,因为某些右值拷贝或赋值的场景下,直接进行空间资源的转移,而不是进行空间资源的再申请,也就是空间资源的拷贝,效率会很高。

但对于Date这样不涉及资源申请的日期类,就没有必要实现移动构造和移动赋值了,因为你没有资源啊,压根不会出现深拷贝的场景,你所有资源的拷贝都是浅拷贝,移动语义的接口和拷贝构造、拷贝赋值这些接口没有区别,都是进行数据的浅拷贝。只有当某个类涉及到空间资源申请的时候,为了防止不必要的深拷贝,我们才会在这样的类里面实现移动构造和移动赋值,所以大家要区分好右值引用使用的场景。


6.

我们自己实现的string有移动构造和移动拷贝的接口,那库里面有嘛?库里面当然有,我们可以通过调试窗口看一下,移动构造之后s1和s3资源的转移。未执行第三行移动构造代码之前,s1内部包含字符串hello world,但在执行过后就可以发现,s1内部的资源被转移到s3去了,s1变成了一个空字符串。

所以,在C++11中,容器也都实现了右值版本的移动构造和移动赋值,以便于在某些场景下能够提高效率,如果拷贝的是一个右值,那么就会对应的调用移动构造和移动赋值,将资源进行转移,而不是进行拷贝一份,此时效率就会提高很多。

7d08989de61843529097517a0935d9b4.png


aaf94a053e9d47438647a256d673d9a5.png

6f4eb8b85a5a4d2281d7cd7bf621c365.png


7.

最后再说一下关于编译器优化的问题,一般来说越新的编译器优化的就越优,我们看到的现象也就越不明显,所以在测试优化时最好采用较久版本的编译器,比如说vs2013这样的编译器,我用的是vs2022,这款编译器优化的很厉害,对于左边的场景,一般的编译器就算开了优化之后,也需要进行一次拷贝构造,但vs2022连这一次的拷贝构造都优化没了,我都不知道他是怎么做到的,只能说优化太强了,但大部分编译器比如说2019之前的版本的编译器,他们还是比较正常的,对于左边场景下,也就是先构造临时变量,再拷贝构造ret,构造+拷贝构造会直接优化为构造ret,一般编译器都会调用一次拷贝构造,但我的编译器没有调,没调就没调吧,也不影响我学知识嘛。

对于右边的场景来说,编译器是不能优化的,因为被赋值对象是已经存在的,编译器不能直接构造ret,必须以赋值重载的方式来拷贝ret,那就必须需要一个临时变量将str的资源保存下来,然后将临时变量赋值给ret,所以这样的情况下,编译器无法做出优化,那么在平常写代码的时候,尽量写出左边的代码,左边的效率要比右边高一些,因为左边的场景编译器可以进行优化。


546a85b8f274422a913290423851722b.png



4.右值引用价值之二:push_back插入数据(补充知识:const右值引用)


1.

上面所说的右值引用在右值进行拷贝或赋值时,能够减少深拷贝,而是直接将资源进行转移,这其实是右值引用的价值之一,实际右值引用在容器插入数据的时候,也能提高很多的效率。

在C++98里面,下面三行代码的效率是一样的,都需要先进行对象的构造,然后将构造出来的对象作为左值传到链表对象的push_back接口,push_back内部一定会new一个结点出来,将左值参数里的资源重新拷贝一份放到new出来的结点里面。

但在C++11里面,后两行的代码效率要高于第一行,归根结底就是因为参数不再是左值,而是右值,第二行和第三行代码插入时,传参传的都是匿名对象,也就是右值,实际STL容器除实现移动构造和移动赋值外,还实现了右值引用版本的插入数据接口,意思是当插入数据为右值的时候,不会再调用C++98里面const左值引用版本的插入数据接口了,因为C++11现在实现了右值引用的版本接口,在调用时,如果是右值插入则一定优先调用右值引用版本的接口。在push_back内部new结点的时候,不会重新拷贝一份资源出来,而是直接将右值参数的资源转移到new出来的结点内部,减少一次深拷贝,提高效率。

int main()
{
  list<wyn::string> lt;
  wyn::string s1("111111");
  lt.push_back(s1);
  //lt.push_back(move(s1));
  lt.push_back(wyn::string("111111"));
  lt.push_back("111111");//单参数的隐式类型转换
  return 0;
}


2.

通过运行结果也可以看出,当wyn::string内部实现了移动构造后,list插入数据时,如果插入数据是右值,在new结点调用struct node结点的构造函数时,会调用string类的移动构造,我们自己实现的string类恰好实现了移动构造,所以对于list的插入接口来说,当插入数据为右值时,相比原来C++98只有const左值引用版本的插入接口,对于插入数据涉及到资源申请的时候,效率能提升很多。

c9a9c69c1b6149d093ee069aa17dcac0.png


3.

下面再补充一个知识点。除const左值引用外,实际上还有const右值引用,有人可能会问,右值不是不能被修改嘛?你搞一个const右值引用有什么意义啊?你说的没错哈,右值确实不能被修改,但右值引用后的引用对象就变成左值了,像下面的rr1和rr2都是右值引用,但rr1却可以修改,rr2不能被修改。这是为什么呢?实际上当右值被右值引用之后,引用对象就丢失了右值的属性,你可以理解为右值被右值引用之后,右值会被存储到名为引用对象的一个空间当中,此时rr1和rr2实际就变成了左值,丢失了右值的属性,rr1和rr2都能够取地址,因为他们其实各自有一块空间用来存储右值,但rr2是不能被修改的,因为rr2在引用时带有const属性。

6fed66b461204b2c83eef077b06bf2da.png


4.

知道上面的知识后,也就能解释为什么移动构造或移动赋值或右值引用版本的插入等等接口的参数都是普通右值引用了,因为这些接口都要对右值进行资源的移动,也就是改变右值引用的引用对象,所以右值引用的时候必须是普通的引用,如果用const那就无法实现资源移动了,也就无法实现效率的提升,那右值引用就失去了他的价值。


5.

所以const右值引用用的肯定不多,因为我们用右值引用的目的还是为了转移右值内部资源,你都无法修改右值引用的引用实体,那还转移啥资源啊。

网上有一种说法是右值引用延长了对象的生命周期,其实这句话是错误的,对象该什么时候销毁还是什么时候销毁,右值对象销毁前进行了资源的转移,所以实际上是延长了右值资源的生命周期。


5.改造自己写的list,实现右值引用版本的push_back


1.

实现右值引用版本的push_back并不困难,我们只要重载一个参数为右值引用的push_back就可以了,同样的结点的构造函数以及insert接口也都需要相应的实现右值引用版本,这些都不困难。

但非常容易忽略的一个点就是上面我们提到的那个知识点,右值被右值引用之后,引用实体会丢失右值属性转而变为左值,所以在传递引用实体时,我们必须加move,将其属性再变为右值,这样才能调用到下一个右值引用版本的接口,所以可以看到push_back,insert,list_node等函数在传递右值引用实体时,都需要加move保持其属性依旧为右值,这样才能在层层调用的过程中让引用实体一直保持为右值。


list_node(T&& x)
  :_next(nullptr)
  , _prev(nullptr)
  , _data(move(x))
{}
iterator insert(iterator pos, T&& x)
{
  node* newnode = new node(move(x));
  node* cur = pos._pnode;
  node* prev = cur->_prev;
  //prev newnode cur
  prev->_next = newnode;
  newnode->_prev = prev;
  newnode->_next = cur;
  cur->_prev = newnode;
  return iterator(newnode);
}
void push_back(T&& x)
{
  insert(end(), move(x));//如果仅仅只传x,则会调到insert的普通版本,因为右值引用之后x变成左值,所以还需要move
  //insert(end(), std::forward<T>(x));
  //move是强制转成右值了,完美转发是保持属性不变。
}


2.

在改造lisi过后,从打印结果就可以看到,当push_back的元素是右值时,我们自己实现的list也能够很好的完成右值引用版本的插入,不再进行像const左值引用版本的资源拷贝,而是直接移动右值资源,下面的四行代码,相比C++98的插入元素接口,减少了四次深拷贝,效率很不错。所以C++11新增的右值引用还是非常有货的,不像decltype那样鸡肋,而是实实在在的有用

653fae96cb054717868a044e7010722b.png


6.万能引用和完美转发( 函数模板std::forward< T >() )


1.

上面在解决右值在右值引用过后属性丢失,但是需要连续给多个接口传参的问题时,采用了不断move的方式来解决,以此来保持传递参数过程中右值的属性能够保持不变。

但这样的解决方式并不被青睐,C++11又推出了一种新的解决方式,叫做完美转发。在了解完美转发之前,还需要了解万能引用。


2.

&&这样的引用方式我们都知道他是右值引用,但&&在模板中并不代表右值引用,他有新的名词,叫做万能引用,所谓万能引用就是什么类型的参数都可以接收,包括普通左值,普通右值,const左值,const右值,万能引用都可以接收。


推演实例化的时候,也会由于参数的不同相应实例化出不同的函数实体,如果传过来的是左值,&&在接收的时候,会发生引用折叠,也就是由两个&变为一个&,从形式上的右值引用折叠为左值引用。


9eaf4f6dda794834b07cc29554d2fc7a.png


3.

那如果在函数模板内部,要调用Fun函数呢?我们想保证在调用的时候,依旧可以调用到对应参数类型的函数,也就是保持参数的属性不变。


如果此时直接调用Fun,并将t参数传过去,就会发生我们之前所说的问题,右值被右值引用过后属性丢失,引用对象重新变为左值,那t就会作为左值传递给Fun函数,所以就只能调用到const左值引用和左值引用版本的Fun函数,无法调到右值版本的函数了。

如果想要调用右值版本的函数,就需要借助move左值,将左值搞成右值,这样就可以调用到右值版本的Fun函数了,但这样的话又无法调到左值版本的Fun函数了,哎呦,真是难搞啊,属性老是丢失,该怎么办啊?


C++11此时就提出了完美转发:函数模板std::forward< T >(),此函数可以帮助我们在调用某些函数进行传参时,保证参数的原有属性不变。有了完美转发后,在上面改造list的时候,我们也可以不用不断move左值的方式向下传参,可以直接使用完美转发这个函数模板,保持参数原有属性不变。93f3ff20ca884f268bf205f94be1bb2e.png


93f3ff20ca884f268bf205f94be1bb2e.png

void Fun(int& x) { cout << "左值引用" << endl; }
void Fun(const int& x) { cout << "const 左值引用" << endl; }
void Fun(int&& x) { cout << "右值引用" << endl; }
void Fun(const int&& x) { cout << "const 右值引用" << endl; }
template<typename T>
void PerfectForward(T&& t)
{
  //*** t可能是左值,也可能是右值,所以如果Fun在函数模板里面作为中转层,无法很好保留参数的属性。
  Fun(t);//t作为参数往下一传,其实就是作为左值传下去了。
  Fun(move(t));//move一下也不行,那就又全变成右值引用了。
  Fun(std::forward<T>(t));//完美转发,保持属性
}
int main()
{
  //***以下形式的形参都可以传给函数模板的万能引用形参
  PerfectForward(10);           // 右值
  int a;
  PerfectForward(a);            // 左值
  PerfectForward(std::move(a)); // 右值
  const int b = 8;
  PerfectForward(b);            // const 左值
  PerfectForward(std::move(b)); // const 右值 --> const左值move了以后还带有const的属性
  return 0;
}


五、类的新功能

1.新增成员函数


1.

在C++98中,类的默认成员函数有六个,在C++11中新增了两个默认成员函数,分别为移动构造和移动赋值。

如果你自己没有实现移动构造函数,并且没有实现析构函数,拷贝构造,拷贝赋值这三个函数,那么编译器会自动默认生成一个移动构造,该移动构造对内置类型完成逐字节拷贝,对自定义类型需要看该自定义类型是否实现移动构造,如果实现移动构造就调用移动构造,如果没有实现就调用拷贝构造。

如果你自己没有实现移动赋值函数,并且没有实现析构函数,拷贝构造,拷贝赋值这三个函数,那么编译器会自动默认生成一个移动赋值,该移动赋值对内置类型完成逐字节拷贝,对自定义类型需要看该自定义类型是否实现移动赋值,如果实现移动赋值就调用移动赋值,如果没有实现就调用拷贝赋值。

当内置类型涉及资源申请的时候,编译器默认生成的移动构造或移动赋值是无法完成任务的,所以这种时候需要我们自己写移动构造或移动赋值。


2.

编译器默认生成的移动构造和赋值的要求太严苛了,那如果我们不小心破坏了生成的条件,但是还想使用编译器默认生成的该怎么办呢?也很简单,可以用一个关键字default强制编译器生成默认的成员函数。

f52e5a77ccc44acbb4fc4299eaed3516.png


2.类成员的初始化(缺省值,default,delete)


1.

由于构造函数对内置类型不处理,所以C++11在成员变量打了补丁,即允许在类定义的时候,给成员变量缺省值,这个缺省值会在构造函数的初始化列表使用,进行成员变量的初始化。别忘记拷贝构造也是构造函数,他们两个构成重载关系,拷贝构造也有初始化列表。


2.

default关键字在上面的部分已经见到过了,他的作用其实就是强制编译器生成默认的成员函数。

假设我们现在要实现一个不能被拷贝的类,也就是这个类的对象不允许被拷贝,这种场景叫做防拷贝,某些类的成员并不想被拷贝。我们该怎么解决这个问题呢?

C++98中,可以采用的方式就是拷贝构造函数设置为私有,这样在类外面如果有人想要进行对象的拷贝,他肯定是调不到拷贝构造函数的,这样的解决方式可以防止类外面进行对象的拷贝。

那如果类内的某个公有函数进行了对象的拷贝呢?设置为私有的方式就无法解决了,因为访问限定符只能限制类外,无法限制类内,这样的场景又该怎么办呢?其实还有一种方法,就是只声明不实现,这样就会报链接错误,因为编译阶段生成的符号表存的拷贝构造函数的地址是无效的,所以链接阶段通过地址找拷贝构造函数的定义时,就会发生链接错误,所以这样的方式也可以防拷贝。

上面都是C++98的解决方式,C++11中没有这么麻烦,只需要delete关键字就可以解决,在拷贝构造的声明处加上delete修饰即可,如果有人进行对象的拷贝,则编译期间就会报语法错误。


6d35a4fec4144ad4bbf527d36cbf3055.png




































相关文章
|
27天前
|
编译器 程序员 定位技术
C++ 20新特性之Concepts
在C++ 20之前,我们在编写泛型代码时,模板参数的约束往往通过复杂的SFINAE(Substitution Failure Is Not An Error)策略或繁琐的Traits类来实现。这不仅难以阅读,也非常容易出错,导致很多程序员在提及泛型编程时,总是心有余悸、脊背发凉。 在没有引入Concepts之前,我们只能依靠经验和技巧来解读编译器给出的错误信息,很容易陷入“类型迷路”。这就好比在没有GPS导航的年代,我们依靠复杂的地图和模糊的方向指示去一个陌生的地点,很容易迷路。而Concepts的引入,就像是给C++的模板系统安装了一个GPS导航仪
100 59
|
1月前
|
存储 编译器 C++
【C++】面向对象编程的三大特性:深入解析多态机制(三)
【C++】面向对象编程的三大特性:深入解析多态机制
|
1月前
|
存储 编译器 C++
【C++】面向对象编程的三大特性:深入解析多态机制(二)
【C++】面向对象编程的三大特性:深入解析多态机制
|
1月前
|
编译器 C++
【C++】面向对象编程的三大特性:深入解析多态机制(一)
【C++】面向对象编程的三大特性:深入解析多态机制
|
1月前
|
存储 安全 编译器
【C++】C++特性揭秘:引用与内联函数 | auto关键字与for循环 | 指针空值(一)
【C++】C++特性揭秘:引用与内联函数 | auto关键字与for循环 | 指针空值
|
20天前
|
C++
C++ 20新特性之结构化绑定
在C++ 20出现之前,当我们需要访问一个结构体或类的多个成员时,通常使用.或->操作符。对于复杂的数据结构,这种访问方式往往会显得冗长,也难以理解。C++ 20中引入的结构化绑定允许我们直接从一个聚合类型(比如:tuple、struct、class等)中提取出多个成员,并为它们分别命名。这一特性大大简化了对复杂数据结构的访问方式,使代码更加清晰、易读。
31 0
|
1月前
|
存储 编译器 C++
【C++】面向对象编程的三大特性:深入解析继承机制(三)
【C++】面向对象编程的三大特性:深入解析继承机制
|
1月前
|
编译器 C++
【C++】面向对象编程的三大特性:深入解析继承机制(二)
【C++】面向对象编程的三大特性:深入解析继承机制
|
1月前
|
安全 程序员 编译器
【C++】面向对象编程的三大特性:深入解析继承机制(一)
【C++】面向对象编程的三大特性:深入解析继承机制
|
1月前
|
存储 编译器 程序员
【C++】C++特性揭秘:引用与内联函数 | auto关键字与for循环 | 指针空值(二)
【C++】C++特性揭秘:引用与内联函数 | auto关键字与for循环 | 指针空值