C++11/14介绍(三)——语言运行期的强化(三)

右值引用

一、左值,右值的纯右值、将亡值,右值

  • 左值:赋值符号左边的值。左值是表达式后依然存在的持久对象

  • 右值:右边的值。指表达式结束后就不在存在的临时对象,C++11中为了引入强大的右值引用,将右值的概念进行了进一步的划分,分为

    • 纯右值:纯粹的右值,要么是纯粹的字面量,ex:10,true;要么是求值结果相当于字面量或匿名临时对象,ex:1+2。

    • 非引用返回的临时变量、运算表达式产生的临时变量、原始字面量、lambda表达式都属于纯右值

    • 将亡值:即将被销毁、却能够被移动的值,比如函数返回值,ex:

      std::vector<int> foo() {
          std::vector<int> temp = {1, 2, 3, 4};
          return temp;
      }
      
      std::vector<int> v = foo();
      

      在这样的代码中,函数 foo的返回值temp在内部创建然后被赋值给 v,然而 v获得这个对象时,会将整个temp拷贝一份,然后把 temp 销毁,如果这个 temp 非常大,这将造成大量额外的开销(这也就是传统 C++ 一直被诟病的问题)。在最后一行中,v 是左值、foo() 返回的值就是右值(也是纯右值)。

      但是,v 可以被别的变量捕获到,而 foo() 产生的那个返回值作为一个临时值,一旦被 v复制后,将立即被销毁,无法获取、也不能修改。

      将亡值就定义了这样一种行为:临时的值能够被识别、同时又能够被移动。

二、右值引用和左值引用

C++11提供了std::move将左值参数无条件的转换为右值,可以方便的获得一个右值临时对象

#include <iostream>
#include <string>

void reference(std::string& str) {
    std::cout << "左值" << std::endl;
}
void reference(std::string&& str) {
    std::cout << "右值" << std::endl;
}

int main()
{
    std::string  lv1 = "string,";       // lv1 是一个左值
    // std::string&& r1 = s1;           // 非法, s1 在全局上下文中没有声明
    std::string&& rv1 = std::move(lv1); // 合法, std::move 可以将左值转移为右值
    std::cout << "rv1 = " << rv1 << std::endl;      // string,

    const std::string& lv2 = lv1 + lv1; // 合法, 常量左值引用能够延长临时变量的生命周期
    // lv2 += "Test";                   // 非法, 引用的右值无法被修改
    std::cout << "lv2 = "<<lv2 << std::endl;      // string,string

    std::string&& rv2 = lv1 + lv2;      // 合法, 右值引用延长临时对象的生命周期
    rv2 += "string";                    // 合法, 非常量引用能够修改临时变量
    std::cout << "rv2 = " << rv2 << std::endl;      // string,string,string,

    reference(rv1);                     // 输出左值
    reference(rv2);                     // 输出左值
}

rv2 虽然引用了一个右值,但由于它是一个引用,所以 rv2 依然是一个左值。

三、移动语义

传统C++通过拷贝构造函数和赋值操作符为类对象设计了拷贝/复制的概念,但为了实现对资源的移动操作,调用者必须先复制,再析构的方式。也就是将对象A的内容复制给B,再将A中内容销毁,已达到移动的目的。右值引用解决了这一问题:

#include <iostream>
class A {
public:
    int *pointer;
    A() :pointer(new int(1)) {
        std::cout << "构造" << pointer << std::endl;
    }
    // 无意义的对象拷贝
    A(A& a) :pointer(new int(*a.pointer)) {
        std::cout << "拷贝" << pointer << std::endl;
    }

    A(A&& a) :pointer(a.pointer) {
        a.pointer = nullptr;
        std::cout << "移动" << pointer << std::endl;
    }

    ~A() {
        std::cout << "析构" << pointer << std::endl;
        delete pointer;
    }
};
// 防止编译器优化
A return_rvalue(bool test) {
    A a,b;
    if(test) return a;
    else return b;
}
int main() {
    A obj = return_rvalue(false);
    std::cout << "obj:" << std::endl;
    std::cout << obj.pointer << std::endl;
    std::cout << *obj.pointer << std::endl;

    return 0;
}

输出

构造0x780d90    //对象a地址
构造0x780f30    //对象b地址
移动0x780f30    //对象b中的移动构造,延长生命周期
析构0           //对象b的析构,将亡值指针设置为nullptr
析构0x780d90    //对象a析构
obj:
0x780f30
1
析构0x780f30
  • 首先在 return_rvalue内部构造两个A对象,于是获得两个构造函数的输出
  • 函数返回后,产生一个将亡值,被A的移动构造(A(A&&))引用,从而延长生命周期,并将这个右值中的指针拿到,保存到obj中,而 将亡值 的指针被设置为nullptr,防止了这块内存区域被销毁。

涉及标准库的例子:

#include <iostream> // std::cout
#include <utility>  // std::move
#include <vector>   // std::vector
#include <string>   // std::string

int main() {

    std::string str = "Hello world.";
    std::vector<std::string> v;

    // 将使用 push_back(const T&), 即产生拷贝行为
    v.push_back(str);
    // 将输出 "str: Hello world."
    std::cout << "str: " << str << std::endl;

    // 将使用 push_back(const T&&), 不会出现拷贝行为
    // 而整个字符串会被移动到 vector 中,所以有时候 std::move 会用来减少拷贝出现的开销
    // 这步操作后, str 中的值会变为空
    v.push_back(std::move(str));
    // 将输出 "str: "
    std::cout << "str: " << str << std::endl;

    return 0;
}

四、完美转发

在右值引用中,一个声明的右值引用其实是一个左值,这就为参数转发(传递)造成了困难

void reference(int& v) {
    std::cout << "左值" << std::endl;
}
void reference(int&& v) {
    std::cout << "右值" << std::endl;
}
template <typename T>
void pass(T&& v) {
    std::cout << "普通传参:";
    reference(v);   // 始终调用 reference(int& )
}
int main() {
    std::cout << "传递右值:" << std::endl;
    pass(1);        // 1是右值, 但输出左值

    std::cout << "传递左值:" << std::endl;    
    int v = 1;
    pass(v);        // v是左引用, 输出左值

    return 0;
}

输出:

传递右值:
普通传参:左值
传递左值:
普通传参:左值

对于pass(1)来说,虽然传递的是右值,但由于v是一个引用,所以同时也是左值。因此reference(v)会输出[左值],而对于pass(v)而言,v是一个左值,应该是不能传给pass(T&&)的,但是却传过去了,其原因如下:

这是基于引用坍缩规则:在传统C++中,我们不能够对一个引用类型继续进行引用,但C++由于右值引用的出现而放宽了这一做法,从而产生了引用坍缩规则,允许对引用进行引用,既能左引用又可以右引用。遵循以下规则:

函数形参类型 实参参数类型 推导后函数形参类型
T& 左引用 T&
T& 右引用 T&
T&& 左引用 T&
T&& 右引用 T&&

因此,模板函数中使用 T&& 不一定能进行右值引用,当传入左值时,此函数的引用将被推导为左值。更准确的讲,无论模板参数是什么类型的引用,当且仅当实参类型为右引用时,模板参数才能被推导为右引用类型。这才使得 v 作为左值的成功传递。

完美转发就是基于上述规律产生的。所谓完美转发,就是为了让我们在传递参数的时候,保持原来的参数类型(左引用保持左引用,右引用保持右引用)。为了解决这个问题,我们应该使用 std::forward 来进行参数的转发(传递)

#include <iostream>
#include <utility>
void reference(int& v) {
    std::cout << "左值引用" << std::endl;
}
void reference(int&& v) {
    std::cout << "右值引用" << std::endl;
}
template <typename T>
void pass(T&& v) {
    std::cout << "普通传参:";
    reference(v);
    std::cout << "std::move 传参:";
    reference(std::move(v));
    std::cout << "std::forward 传参:";
    reference(std::forward<T>(v));

}
int main() {
    std::cout << "传递右值:" << std::endl;
    pass(1);

    std::cout << "传递左值:" << std::endl;
    int v = 1;
    pass(v);

    return 0;
}

输出:

传递右值:
普通传参:左值引用
std::move 传参:右值引用
std::forward 传参:右值引用
传递左值:
普通传参:左值引用
std::move 传参:右值引用
std::forward 传参:左值引用

无论传递参数为左值还是右值,普通传参都会将参数作为左值进行转发,所以 std::move总会接受到一个左值,从而转发调用了reference(int&&)输出右值引用。

唯独 std::forward 即没有造成任何多余的拷贝,同时完美转发(传递)了函数的实参给了内部调用的其他函数。

std::forward和 std::move一样,没有做任何事情,std::move 单纯的将左值转化为右值,std::forward 也只是单纯的将参数做了一个类型的转换,从实现来看,std::forward(v) 和 static_cast<T&&>(v) 是完全一样的。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章