【C++】—— C++11新特性之 “右值引用和移动语义”

简介: 【C++】—— C++11新特性之 “右值引用和移动语义”

前言:

  • 本期,我们将要的介绍有关 C++右值引用 的相关知识。对于本期知识内容,大家是必须要能够掌握的,在面试中是属于重点考察对象。



(一)左值引用和右值引用

传统的C++语法中就有引用的语法,而C++11中新增了的右值引用语法特性,所以从现在开始我们之前学习的引用就叫做左值引用。无论左值引用还是右值引用,都是给对象取别名。

1、什么是左值?什么是左值引用?

C++98/03 标准中就有引用,使用 "&" 表示。但此种引用方式有一个缺陷,即正常情况下只能操作 C++中的左值,无法对右值添加引用。举个例子:

 

int main()
{
  int num = 10;
  int& b = num; //正确
  int& c = 10; //错误
  return 0;
}

输出展示:

【解释说明】

  • 如上所示,编译器允许我们为 num 左值建立一个引用,但不可以为 10 这个右值建立引用。因此,C++98/03 标准中的引用又称为左值引用。

那么到底什么是左值?什么是左值引用呢?

  1. 左值是一个表示数据的表达式(如变量名或解引用的指针),我们可以获取它的地址+可以对它赋,左值可以出现赋值符号的左边,右值不能出现在赋值符号左边;
  2. 定义时const修饰符后的左值,不能给他赋值,但是可以取它的地址。左值引用就是给左值的引用,给左值取别名。

注意:虽然 C++98/03 标准不支持为右值建立非常量左值引用,但允许使用常量左值引用操作右值。也就是说,常量左值引用既可以操作左值,也可以操作右值,例如:

 

int main()
{
    // 以下的p、b、c、*p都是左值
    int* p = new int(0);
    int b = 1;
    const int c = 2;
    // 以下几个是对上面左值的左值引用
    int*& rp = p;
    int& rb = b;
    //左值引用给右值取别名
    const int& rc = c;
    int& pvalue = *p;
    return 0;
}

2、什么是右值?什么是右值引用?

 

我们知道,右值往往是没有名称的,因此要使用它只能借助引用的方式。这就产生一个问题,实际开发中我们可能需要对右值进行修改(实现移动语义时就需要),显然左值引用的方式是行不通的。

为此,C++11 标准新引入了另一种引用方式,称为右值引用,用 "&&" 表示:

  • 右值也是一个表示数据的表达式,如:字面常量、表达式返回值,函数返回值(这个不能是左值引用返回)等等;
  • 右值可以出现在赋值符号的右边,但是不能出现出现在赋值符号的左边,右值不能取地址
  • 右值引用就是对右值的引用,给右值取别名。
int main()
{
  double x = 1.1, y = 2.2;
  // 以下几个都是常见的右值
  10;
  x + y;
  fmin(x, y);
  // 以下几个都是对右值的右值引用
  int&& rr1 = 10;
  double&& rr2 = x + y;
  double&& rr3 = fmin(x, y);
  return 0;
}

输出展示:

但是如果是下面这几个表达式,就会发生报错现象:

10 = 1;
x + y = 1;
fmin(x, y) = 1;

输出显示:

需要注意的是右值是不能取地址的,但是给右值取别名后,会导致右值被存储到特定位置,且可

以取到该位置的地址。例如下面代码所示:

int main()
{
  double x = 1.1, y = 2.2;
  int&& rr1 = 10;
  double&& rr2 = x + y;
  cout << rr1 << " " << rr2 << " " << endl;
  rr1 = 20;
  rr2 = 5.5;
  cout << rr1 << " " << rr2 << " " << endl;
  return 0;
}

输出展示:

当我们不想被修改时,我们可以加上 【const】关键字:

【解释说明】

  1. 不能取字面量10的地址,但是rr1引用后,可以对rr1取地址,也可以修改rr1;
  2. 如果不想rr1被修改,可以用const int&& rr1 去引用;
  3. 是不是感觉很神奇这个了解一下实际中右值引用的使用场景并不在于此,这个特性也不重要

(二)左值引用与右值引用比较

左值引用总结:

  • 1. 左值引用只能引用左值,不能引用右值。
  • 2. 但是const左值引用既可引用左值,也可引用右值
int main()
{
  // 左值引用只能引用左值,不能引用右值。
  int a = 10;
  int& ra1 = a; // ra为a的别名
    
    return 0;
}

输出展示:

又例如以下示例:

int main()
{
  // 左值引用只能引用左值,不能引用右值。
  int a = 10;
  int& ra2 = 10; // 编译失败,因为10是右值
    return 0;
}

输出展示:

左值引用只能引用左值,不能引用右值。但是当我们加上 const 时,此时左值引用可以给右值取别名:

int main()
{
    int a = 10;
    // const左值引用既可引用左值,也可引用右值。
  const int& ra3 = 10;
  const int& ra4 = a;
    
    return 0;
}

输出展示:

【解释说明】

值得一提的是,虽然C++ 语法上是支持定义常量右值引用的,但这种定义出来的右值引用并无实际用处:

const int& ra3 = 10;
  1. 一方面,右值引用主要用于移动语义和完美转发,其中前者需要有修改右值的权限;
  2. 其次,常量右值引用的作用就是引用一个不可修改的右值,这项工作完全可以交给常量左值引用完成。

右值引用总结:

  • 1. 右值引用只能右值,不能引用左值。
  • 2. 但是右值引用可以move以后的左值。

代码展示:

引用左值会发生报错行为:


通过 move 可以支持将左值转换为右值引用

在C++中,move是一个函数模板,可以将给定的对象转换为对应的右值引用。它并不执行实际的内存移动操作,而是将对象标记为可以进行移动操作的右值。这样,用户可以利用该标记来实现更高效的移动语义。


(三)右值引用使用场景和意义

前面我们可以看到左值引用既可以引用左值和又可以引用右值,那为什么C++11还要提出右值引用呢?是不是化蛇添足呢?下面我们来看看左值引用的短板,右值引用是如何补齐这个短板的!

现有以下代码:

【解释说明】

首先,对于上述代码中的 res1 和 res2 ,它们分别为左值和右值;

紧接着大家想想,我们对左值和对右值拷贝有没有什么区别呢?

  • 如果是内置类型,他们其实区别不是很大,但是对于自定类型他们的区别可就很大了,
  • 因为自定义类型的右值,一般很多地方又把它叫做将亡值。通常都是一些表达式的返回值、一个函数调用等;
  • 而对于右值又分为 纯右值(一般来说是内置类型)和将亡值(一般来说是自定义类型)

对于上述的 res1,它作为一个左值,我们不能对其进行操作,只能去做深拷贝。因为虽然看起来这里是一个赋值,其实应该是拷贝构造;

而对于 res2 来说,它本身是右值,假如是自定义类型作为一个将亡值,我们就没有必要去对其进行拷贝操作。此时就引出了关于右值引用实现引动构造的概念。


例如现在有这样一个我们手写模拟的 string :

namespace zp
{
  class string
  {
  public:
    typedef char* iterator;
    iterator begin()
    {
      return _str;
    }
    iterator end()
    {
      return _str + _size;
    }
    string(const char* str = "")
      :_size(strlen(str))
      , _capacity(_size)
    {
      //cout << "string(char* str)" << endl;
      _str = new char[_capacity + 1];
      strcpy(_str, str);
    }
    // s1.swap(s2)
    void swap(string& s)
    {
      ::swap(_str, s._str);
      ::swap(_size, s._size);
      ::swap(_capacity, s._capacity);
    }
    // 拷贝构造
    string(const string& s)
      :_str(nullptr)
    {
      cout << "string(const string& s) -- 深拷贝" << endl;
      string tmp(s._str);
      swap(tmp);
    }
    // 赋值重载
    string& operator=(const string& s)
    {
      cout << "string& operator=(string s) -- 深拷贝" << endl;
      string tmp(s);
      swap(tmp);
      return *this;
    }
    ~string()
    {
      delete[] _str;
      _str = nullptr;
    }
    char& operator[](size_t pos)
    {
      assert(pos < _size);
      return _str[pos];
    }
    void reserve(size_t n)
    {
      if (n > _capacity)
      {
        char* tmp = new char[n + 1];
        strcpy(tmp, _str);
        delete[] _str;
        _str = tmp;
        _capacity = n;
      }
    }
    void push_back(char ch)
    {
      if (_size >= _capacity)
      {
        size_t newcapacity = _capacity == 0 ? 4 : _capacity * 2;
        reserve(newcapacity);
      }
      _str[_size] = ch;
      ++_size;
      _str[_size] = '\0';
    }
    //string operator+=(char ch)
    string& operator+=(char ch)
    {
      push_back(ch);
      return *this;
    }
    string operator+(char ch)
    {
      string tmp(*this);
      tmp += ch;
      return tmp;
    }
    const char* c_str() const
    {
      return _str;
    }
  private:
    char* _str;
    size_t _size;
    size_t _capacity; // 不包含最后做标识的\0
  };
}

当我们再这样的场景在来观察上述的 res1 和res2:

我们可以发现此处的右值发生的是相应的深拷贝,这样显然是会造成不必要的浪费的。为了解决上述这样的问题,我们就可以引入 “移动构造” 这样的概念:

// 移动构造
string(string&& s)
  :_str(nullptr)
{
  cout << "string(string&& s) -- 移动拷贝" << endl;
  swap(s);
}

紧接着,再次运行上述代码,我们可以发现编译器会去自动识别:

此时,当我们就是想把 s1 转为右值可以怎么做呢?其实很简单(这里就体现了move):

输出展示:

我们通过调试也可以发现此时确实达到了预期的效果:

【小结】

通过上述我们可以发现左值引用的好处就是直接减少拷贝

左值引用的使用场景可以分为以下两个部分:

  • 做参数和做返回值都可以提高效率

左值引用的短板:

  • 但是当函数返回对象是一个局部变量,出了函数作用域就不存在了,就不能使用左值引用返回,只能传值返回。

例如现在有以下这样的代码:

zp::string to_string(int value)
  {
    bool flag = true;
    if (value < 0)
    {
      flag = false;
      value = 0 - value;
    }
    zp::string str;
    while (value > 0)
    {
      int x = value % 10;
      value /= 10;
      str += ('0' + x);
    }
    if (flag == false)
    {
      str += '-';
    }
    std::reverse(str.begin(), str.end());
    return str;
  }

【说明】

  • zp::string to_string(int value)函数中可以看到,这里只能使用传值返回,传值返回会导致至少1次拷贝构造(如果是一些旧一点的编译器可能是两次拷贝构造)。

紧接着,我们去打印看结果是什么:

此时,传值返回带来的代价得到了极大的解决:

右值引用和移动语义解决上述问题:

  • 在zp::string中增加移动构造,移动构造本质是将参数右值的资源窃取过来,占位已有,那么就不用做深拷贝了,所以它叫做移动构造,就是窃取别人的资源来构造自己;

再运行上面zp::to_string的两个调用,我们会发现,这里没有调用深拷贝的拷贝构造,而是调用了移动构造,移动构造中没有新开空间,拷贝数据,所以效率提高了。


c++11 不仅仅有移动构造,还有移动赋值:

在zp::string类中增加移动赋值函数,再去调用zp::to_string(1234),不过这次是zp::to_string(1234)返回的右值对象赋值给ret1对象,这时调用的是移动构造。

输出展示:

【解释说明】

  • 这里运行后,我们看到调用了一次移动构造和一次移动赋值。因为如果是用一个已经存在的对象接收,编译器就没办法优化了。zp::to_string函数中会先用str生成构造生成一个临时对象,但是我们可以看到,编译器很聪明的在这里把str识别成了右值,调用了移动构造。然后在把这个临时对象做为 zp::to_string 函数调用的返回值赋值给ret1,这里调用的移动赋值。

(四)完美转发

1、概念

  • 完美转发(perfect forwarding)是C++11引入的一项特性,旨在实现在函数模板中对参数类型进行精确传递的能力;
  • 它主要用于保留传递到函数模板的实参的值类别,并将其转发到内部调用的函数,从而实现类型和值类别的完全保持;

举个例子:

template<typename T>
void PerfectForward(T t)
{
    Fun(t);
}

【解释说明】

  1. 如上所示,PerfectForward() 函数模板中调用了 Func() 函数;
  2. 在此基础上,完美转发指的是:如果 PerfectForward() 函数接收到的参数 t 为左值,那么该函数传递给 Func() 的参数 t 也是左值;
  3. 反之如果 function() 函数接收到的参数 t 为右值,那么传递给 Func() 函数的参数 t 也必须为右值。

使用任何一种引用形式,可以实现转发,但无法保证完美。因此如果使用 C++ 98/03 标准下的 C++ 语言,我们可以采用函数模板重载的方式实现完美转发,例如:

template<typename T>
void Func(T& arg)
{
    cout << "左值引用:" << arg << endl;
}
template<typename T>
void Func(T&& arg)
{
    cout << "右值引用:" << arg << endl;
}
template<typename T>
void PerfectForward(T&& arg)
{
    Func(arg);  // 利用重载的process函数进行处理
}
int main()
{
    int value = 42;
    PerfectForward(value);       // 传递左值
    PerfectForward(123);         // 传递右值
    return 0;
}

输出展示:

【解释说明】

  1. 在上述示例中,我们定义了两个重载的函数模板 Func,一个接收左值引用参数T& arg,另一个接收转发引用参数T&& arg;
  2. 然后,我们再定义一个模板函数PerfectForward,其参数也是转发引用T&& arg。在PerfectForward函数内部,我们通过调用Func函数来处理传递的参数;
  3. 通过函数重载的机制,传递的左值参数将匹配到接收左值引用的Func函数,传递的右值参数则匹配到接收转发引用的Func函数,从而正确地进行区分和处理;
  4. 通过函数模板的重载,我们可以根据参数类型将左值和右值区分开来,并分别处理,实现了针对不同值类别的精确匹配和操作。

2、模板中的&& 万能引用

显然,上述使用重载的模板函数实现完美转发也是有弊端的,此实现方式仅适用于模板函数仅有少量参数的情况,否则就需要编写大量的重载函数模板,造成代码的冗余。为了方便用户更快速地实现完美转发,C++ 11 标准中允许在函数模板中使用右值引用来实现完美转发。

还是以 PerfectForward() 函数为例,在 C++11 标准中实现完美转发,只需要编写如下一个模板函数即可:

//模板中的&&不代表右值引用,而是万能引用,其既能接收左值又能接收右值。
template<typename T>
void PerfectForward(T&& t)
{
    Fun(t);
}

以如下代码为例:

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)
{
  Fun(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 右值
  return 0;
}

输出展示:

【解释说明】

  1. 模板中的&&不代表右值引用,而是万能引用,其既能接收左值又能接收右值。
  2. 模板的万能引用只是提供了能够接收同时接收左值引用和右值引用的能力,
  3. 但是引用类型的唯一作用就是限制了接收的类型,后续使用中都退化成了左值,
  4. 我们希望能够在传递过程中保持它的左值或者右值的属性, 就需要用我们下面学习的完美转发

3、std::forward

C++11 标准的开发者已经帮我们想好的解决方案,该新标准还引入了一个模板函数 forword<T>(),我们只需要调用该函数,就可以很方便地解决此问题。

  1. 完美转发通常与转发引用(forwarding reference)和 std::forward 函数一起使用;
  2. 转发引用是一种特殊的引用类型,使用&&语法进行声明,用于在函数模板中捕获传递的实参;
  3. std::forward 是一个模板函数,用于在函数模板内部将转发引用作为右值或左值引用进行转发。

如下演示了该函数模板的用法:

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)
{
  // forward<T>(t)在传参的过程中保持了t的原生类型属性。
  Fun(std::forward<T>(t));
}
int main()
{
  PerfectForward(10); // 右值
  int a;
  PerfectForward(a); // 左值
  PerfectForward(move(a)); // 右值
  const int b = 8;
  PerfectForward(b); // const 左值
  PerfectForward(move(b)); // const 右值
  return 0;
}

程序执行结果为:

通过完美转发,我们可以在函数模板中正确处理传递实参的值类别,并将其转发到内部函数,以达到类型和值类别的完全保持,提高代码的灵活性和效率。


总结

学到这里,一些读者可能无法记清楚左值引用和右值引用各自可以引用左值还是右值,这里给大家一张表格,方便大家记忆:

 

  • 表中,Y 表示支持,N 表示不支持。

以上便是关于左值引用和右值引用的全部知识讲解!感谢大家的观看与支持!!!

相关文章
|
1月前
|
编译器 程序员 定位技术
C++ 20新特性之Concepts
在C++ 20之前,我们在编写泛型代码时,模板参数的约束往往通过复杂的SFINAE(Substitution Failure Is Not An Error)策略或繁琐的Traits类来实现。这不仅难以阅读,也非常容易出错,导致很多程序员在提及泛型编程时,总是心有余悸、脊背发凉。 在没有引入Concepts之前,我们只能依靠经验和技巧来解读编译器给出的错误信息,很容易陷入“类型迷路”。这就好比在没有GPS导航的年代,我们依靠复杂的地图和模糊的方向指示去一个陌生的地点,很容易迷路。而Concepts的引入,就像是给C++的模板系统安装了一个GPS导航仪
108 59
|
1月前
|
存储 编译器 C++
【C++】面向对象编程的三大特性:深入解析多态机制(三)
【C++】面向对象编程的三大特性:深入解析多态机制
|
1月前
|
存储 编译器 C++
【C++】面向对象编程的三大特性:深入解析多态机制(二)
【C++】面向对象编程的三大特性:深入解析多态机制
|
1月前
|
C++
C++ 20新特性之结构化绑定
在C++ 20出现之前,当我们需要访问一个结构体或类的多个成员时,通常使用.或->操作符。对于复杂的数据结构,这种访问方式往往会显得冗长,也难以理解。C++ 20中引入的结构化绑定允许我们直接从一个聚合类型(比如:tuple、struct、class等)中提取出多个成员,并为它们分别命名。这一特性大大简化了对复杂数据结构的访问方式,使代码更加清晰、易读。
33 0
|
5天前
|
存储 编译器 C++
【c++】类和对象(下)(取地址运算符重载、深究构造函数、类型转换、static修饰成员、友元、内部类、匿名对象)
本文介绍了C++中类和对象的高级特性,包括取地址运算符重载、构造函数的初始化列表、类型转换、static修饰成员、友元、内部类及匿名对象等内容。文章详细解释了每个概念的使用方法和注意事项,帮助读者深入了解C++面向对象编程的核心机制。
25 5
|
11天前
|
存储 编译器 C++
【c++】类和对象(中)(构造函数、析构函数、拷贝构造、赋值重载)
本文深入探讨了C++类的默认成员函数,包括构造函数、析构函数、拷贝构造函数和赋值重载。构造函数用于对象的初始化,析构函数用于对象销毁时的资源清理,拷贝构造函数用于对象的拷贝,赋值重载用于已存在对象的赋值。文章详细介绍了每个函数的特点、使用方法及注意事项,并提供了代码示例。这些默认成员函数确保了资源的正确管理和对象状态的维护。
40 4
|
12天前
|
存储 编译器 Linux
【c++】类和对象(上)(类的定义格式、访问限定符、类域、类的实例化、对象的内存大小、this指针)
本文介绍了C++中的类和对象,包括类的概念、定义格式、访问限定符、类域、对象的创建及内存大小、以及this指针。通过示例代码详细解释了类的定义、成员函数和成员变量的作用,以及如何使用访问限定符控制成员的访问权限。此外,还讨论了对象的内存分配规则和this指针的使用场景,帮助读者深入理解面向对象编程的核心概念。
36 4
|
1月前
|
存储 编译器 对象存储
【C++打怪之路Lv5】-- 类和对象(下)
【C++打怪之路Lv5】-- 类和对象(下)
27 4
|
1月前
|
编译器 C语言 C++
【C++打怪之路Lv4】-- 类和对象(中)
【C++打怪之路Lv4】-- 类和对象(中)
24 4
|
1月前
|
存储 安全 C++
【C++打怪之路Lv8】-- string类
【C++打怪之路Lv8】-- string类
21 1