[C++]右值引用和转移语义

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/stary_yan/article/details/51284929

右值引用和转移语义

本文尝试着解释何为右值引用和转移语义以及使用它们具有优势,并提供相关案例分析。

定义

左值和右值

首先我们先来理解一下什么是左值和右值

C/C++语言中可以放在赋值符号左边的变量,左值表示存储在计算机内存的对象,左值相当于地址值。右值:当一个符号或者常量放在操作符右边的时候,计算机就读取他们的“右值”,也就是其代表的真实值,右值相当于数据值。

C/C++语言中可以放在赋值符号左边的变量,即具有对应的可以由用户访问的存储单元,并且能够由用户去改变其值的量。左值表示存储在计算机内存的对象,而不是常量或计算的结果。或者说左值是代表一个内存地址值,并且通过这个内存地址,就可以对内存进行读并且写(主要是能写)操作;这也就是为什么左值可以被赋值的原因了。相对应的还有右值:当一个符号或者常量放在操作符右边的时候,计算机就读取他们的“右值”,也就是其代表的真实值。简单来说就是,左值相当于地址值,右值相当于数据值。右值指的是引用了一个存储在某个内存地址里的数据。

左值右值翻译:

L-value中的L指的是Location,表示可寻址。A value (computer science)that has an address.

R-value中的R指的是Read,表示可读。in computer science, a value that does not have an address in a computer language.

左值和右值是相对于赋值表达式而言的。左值是能出现在赋值表达式左边的表达式。左值表达式可以分为可读写的左值和只读左值。右值是可以出现在赋值表达式右边的表达式,他可以是不占据内存空间的临时量或字面量,可以是不具有写入权的空间实体。如

int a=3;
const int b=5;
a=b+2; //a是左值,b+2是右值
b=a+2; //错!b是只读的左值但无写入权,不能出现在赋值符号左边
(a=4)+=28; //a=4是左值表达式,28是右值,+=为赋值操作符
34=a+2; //错!34是字面量不能做左值

(from 百度百科)

左值引用

左值引用根据其修饰符的不同,可以区分为常量左值引用和非常量左值引用。左值引用实际上就是指针。

  • 非常量左值引用只能绑定到非常量左值,不能绑定到常量左值和常量右值,(因为非常左值可以改变其值,但常量不可改变,性质相矛盾),非常量右值。而如果绑定到非常量右值,就有可能指向一个已经被销毁的对象。

  • 常量左值引用能绑定到非常量左值,常量左值,非常量右值,常量右值。

右值引用

从实践角度讲,它能够完美解决C++中长久以来为人所诟病的临时对象效率问题。从语言本身讲,它健全了C++中的引用类型在左值右值方面的缺陷。从库设计者的角度讲,它给库设计者又带来了一把利器。从库使用者的角度讲,不动一兵一卒便可以获得“免费的”效率提升…

右值引用 (Rvalue Referene) 是 C++ 新标准 (C++11, 11 代表 2011 年 ) 中引入的新特性 , 它实现了转移语义 (Move Sementics) 和精确传递 (Perfect Forwarding)。它的主要目的有两个方面:

  • 消除两个对象交互时不必要的对象拷贝,节省运算存储资源,提高效率。

  • 能够更简洁明确地定义泛型函数。

左值引用和右值引用的语法

为了区别,C++把&作为左值引用的声明符,把&&作为右值引用的声明符。

void process_value(int& i) {
    std::cout << "LValue processed: " << i << std::endl;
}
void process_value(int&& i) {
    std::cout << "RValue processed: " << i << std::endl;
}

int main() {
    int a = 0;
    process_value(a);
    process_value(1);  //  1对于编译器而言就是临时对象。
}

output:

LValue processed: 0
RValue processed: 1
Program ended with exit code: 0

但是如果临时对象通过一个接受右值的函数传递给另一个函数时,就会变成左值,因为这个临时对象在传递过程中,变成了命名对象

void process_value(int& i) {
    std::cout << "LValue processed: " << i << std::endl;
}

void process_value(int&& i) {
    std::cout << "RValue processed: " << i << std::endl;
}

void forward_value(int&& i) {
    //  在函数传递中i被认为是命名对象。
    process_value(i);
}

int main() {
    int a = 0;
    process_value(a);
    process_value(1);
    forward_value(2);
}

output:

LValue processed: 0
RValue processed: 1
LValue processed: 2
Program ended with exit code: 0

转移语义

右值引用是用来支持转移语义的。转移语义可以将资源 ( 堆,系统对象等 ) 从一个对象转移到另一个对象,这样能够减少不必要的临时对象的创建、拷贝以及销毁,能够大幅度提高 C++ 应用程序的性能。临时对象的维护 ( 创建和销毁 ) 对性能有严重影响。

转移语义是和拷贝语义相对的,可以类比文件的剪切与拷贝,当我们将文件从一个目录拷贝到另一个目录时,速度比剪切慢很多。

通过转移语义,临时对象中的资源能够转移其它的对象里。(注意是临时对象中的“资源”而不是临时对象本身!这里所谓的使用资源是指指针的指向问题,通过改变指针的指向可以直接使用临时对象中的资源。所以如果,临时对象中并不涉及动态分配内存的问题时,转移语义并不能起到作用,也不必起作用。)

在现有的 C++ 机制中,我们可以定义拷贝构造函数和赋值函数。要实现转移语义,需要定义转移构造函数,还可以定义转移赋值操作符。对于右值的拷贝和赋值会调用转移构造函数和转移赋值操作符。如果转移构造函数和转移拷贝操作符没有定义,那么就遵循现有的机制,拷贝构造函数和赋值操作符会被调用。

普通的函数和操作符也可以利用右值引用操作符实现转移语义。

实例:实现转移构造函数和转移赋值函数

以一个简单的 string 类为示例,实现拷贝构造函数和拷贝赋值操作符。
示例1:没有转移构造函数和转移copying函数

 class MyString { 
 private: 
  char* _data; 
  size_t   _len; 
  void _init_data(const char *s) { 
    _data = new char[_len+1]; 
    memcpy(_data, s, _len); 
    _data[_len] = '\0'; 
  } 
 public: 
  MyString() { 
    _data = NULL; 
    _len = 0; 
  } 

  MyString(const char* p) { 
    _len = strlen (p); 
    _init_data(p); 
  } 

  MyString(const MyString& str) { 
    _len = str._len; 
    _init_data(str._data); 
    std::cout << "Copy Constructor is called! source: " << str._data << std::endl; 
  } 

  MyString& operator=(const MyString& str) { 
    if (this != &str) { 
      _len = str._len; 
      _init_data(str._data); 
    } 
    std::cout << "Copy Assignment is called! source: " << str._data << std::endl; 
    return *this; 
  } 

  virtual ~MyString() { 
    if (_data) free(_data); 
  } 
 }; 

 int main() { 
  MyString a; 
  a = MyString("Hello"); 
  std::vector<MyString> vec; 
  vec.push_back(MyString("World")); 
 }

output:

 Copy Assignment is called! source: Hello 
 Copy Constructor is called! source: World

这个类基本满足我们的需求。但是实际上他的效率很低。因为每一次赋值操作符的调用都会先析构原有的对象的内存,临时对象构造,复制,析构等一系列操作。拷贝构造函数也是如此。非常地低效。于是有人想到,如果能够直接使用临时对象已经申请的资源,既能节省资源,有能节省资源申请和释放的时间。这正是定义转移语义的目的。

示例2:实现转移构造函数和转移copying函数

class MyString {
private:
    char* _data;
    size_t   _len;
    void _init_data(const char *s) {
        _data = new char[_len+1];
        memcpy(_data, s, _len);
        _data[_len] = '\0';
    }
public:
    MyString() {
        _data = NULL;
        _len = 0;
    }

    MyString(const char* p) {
        _len = strlen (p);
        _init_data(p);
    }

    MyString(const MyString& str) {
        _len = str._len;
        _init_data(str._data);
        std::cout << "Copy Constructor is called! source: " << str._data << std::endl;
    }

    MyString& operator=(const MyString& str) {
        if (this != &str) {
            _len = str._len;
            _init_data(str._data);
        }
        std::cout << "Copy Assignment is called! source: " << str._data << std::endl;
        return *this;
    }
    MyString(MyString&& str) {
        std::cout << "Move Constructor is called! source: " << str._data << std::endl;
        _len = str._len;
        _data = str._data;
        str._len = 0;
        str._data = NULL;
    }
    MyString& operator=(MyString&& str) {
        std::cout << "Move Assignment is called! source: " << str._data << std::endl;
        if (this != &str) {
            clear();
            _len = str._len;
            _data = str._data;
            str._len = 0;
            str._data = NULL;
        } 
        return *this; 
    }
    void clear() {
        delete _data;
        _data = NULL;
        _len = 0;
    }
    virtual ~MyString() {
        if (_data) free(_data);
    }
};

output:

Move Assignment is called! source: Hello 
Move Constructor is called! source: World

注意新添加的两个函数:

    MyString(MyString&& str) {
        std::cout << "Move Constructor is called! source: " << str._data << std::endl;
        _len = str._len;
        _data = str._data;
        str._len = 0;
        str._data = NULL;
    }
    MyString& operator=(MyString&& str) {
        std::cout << "Move Assignment is called! source: " << str._data << std::endl;
        if (this != &str) {
            clear();
            _len = str._len;
            _data = str._data;
            str._len = 0;
            str._data = NULL;
        } 
        return *this; 
    }

和拷贝构造函数类似,有几点需要注意:

    1. 参数(右值)的符号必须是右值引用符号,即“&&”。
    1. 参数(右值)不可以是常量,因为我们需要修改右值。
    1. 参数(右值)的资源链接和标记必须修改。否则,右值的析构函数就会释放资源。转移到新对象的资源也就无效了。

观察其源码,我们可以不难发现,实际上就是转移资源(指针),从而避免了临时对象的复制。

由此看出,编译器区分了左值和右值,对右值调用了转移构造函数和转移赋值操作符。节省了资源,提高了程序运行的效率。

有了右值引用和转移语义,我们在设计和实现类时,对于需要动态申请大量资源的类,应该设计转移构造函数和转移赋值函数,以提高应用程序的效率。

此处补充一点关于拷贝构造函数的小知识:
如果我们在main函数里面写下以下语句时:

MyString a(MyString("Hello")); 

什么都不会输出。为什么呢?

原来,在C++中,下面三种对象才需要调用拷贝构造函数(有时也称“复制构造函数”):

  • 1) 一个对象作为函数参数,以值传递的方式传入函数体;
  • 2) 一个对象作为函数返回值,以值传递的方式从函数返回;
  • 3) 一个对象用于给另外一个对象进行初始化(常称为赋值初始化);

此处用到了3)的道理。

但如果你写下:

MyString a(move(MyString("Hello"))); 

编译器会调用转移拷贝构造函数。因为move返回了一个右值引用。

标准库函数std::move

move的源码:

void move(basic_ios&& __rhs) {
    move(__rhs);
}
basic_ios<_CharT, _Traits>::move(basic_ios& __rhs) {
    ios_base::move(__rhs);
    __tie_ = __rhs.__tie_;
    __rhs.__tie_ = 0;
    __fill_ = __rhs.__fill_;
}

由于标准库其他相当复杂的内容不在本文的讨论范围,所以不share出来,我们大概分析move源码可以看出来,实际上就是把一个右值的指针传给了左值,然后右值的指针指向空。实现了交换指针。

由于编译器只对右值引用才能调用转移构造函数和转移赋值函数,而所有命名对象都只能是左值引用,如果已知一个命名对象不再被使用而想对它调用转移构造函数和转移赋值函数,也就是把一个左值引用当做右值引用来使用,怎么做呢?标准库提供了函数 std::move,这个函数以非常简单的方式将左值引用转换为右值引用。

 void ProcessValue(int& i) { 
  std::cout << "LValue processed: " << i << std::endl; 
 } 

 void ProcessValue(int&& i) { 
  std::cout << "RValue processed: " << i << std::endl; 
 } 

 int main() { 
  int a = 0; 
  ProcessValue(a); 
  ProcessValue(std::move(a)); 
 }
/* output:
 LValue processed: 0 
 RValue processed: 0
*/

std::move在提高 swap 函数的的性能上非常有帮助,一般来说,swap函数的通用定义如下:

template <class T> swap(T& a, T& b) 
{ 
    T tmp(a);   // copy a to tmp 
    a = b;      // copy b to a 
    b = tmp;    // copy tmp to b 
}

有了 std::move,swap 函数的定义变为 :

template <class T> swap(T& a, T& b) 
{ 
    T tmp(std::move(a)); // move a to tmp 
    a = std::move(b);    // move b to a 
    b = std::move(tmp);  // move tmp to b 
}

通过 std::move,一个简单的 swap 函数就避免了 3 次不必要的拷贝操作。

精确传递 (Perfect Forwarding)

本文采用精确传递表达这个意思。”Perfect Forwarding”也被翻译成完美转发,精准转发等,说的都是一个意思。

精确传递适用于这样的场景:需要将一组参数原封不动的传递给另一个函数。

“原封不动”不仅仅是参数的值不变,在 C++ 中,除了参数值之外,还有一下两组属性:左值/右值和 const/non-const。 精确传递就是在参数传递过程中,所有这些属性和参数值都不能改变。在泛型函数中,这样的需求非常普遍。

下面举例说明。函数 forward_value 是一个泛型函数,它将一个参数传递给另一个函数 process_value。
forward_value 的定义为:

 template <typename T> void forward_value(const T& val) { 
  process_value(val); 
 } 
 template <typename T> void forward_value(T& val) { 
  process_value(val); 
 }

函数 forward_value 为每一个参数必须重载两种类型,T& 和 const T&,否则,下面四种不同类型参数的调用中就不能同时满足:

  int a = 0; 
  const int &b = 1; 
  forward_value(a); // int& 
  forward_value(b); // const int& 
  forward_value(2); //  const int&

对于一个参数就要重载两次,也就是说如果函数有n个参数,就要重载2^n。这个函数的定义次数对于程序员来说,是非常低效的。我们看看右值引用如何帮助我们解决这个问题:

template <typename T>
void forward_value(T&& val) { 
    process_value(val); 
}

只需要定义一次,接受一个右值引用的参数,就能够将所有的参数类型原封不动的传递给目标函数。四种不用类型参数的调用都能满足,参数的左右值属性和 const/non-cosnt 属性完全传递给目标函数 process_value。这个解决方案不是简洁优雅吗?

  int a = 0; 
  const int &b = 1; 
  forward_value(a); // int& 
  forward_value(b); // const int& 
  forward_value(2); // const int&&

C++11 中定义的 T&& 的推导规则为:

右值实参为右值引用,左值实参仍然为左值引用。

一句话,就是参数的属性不变。这样也就完美的实现了参数的完整传递。
右值引用,表面上看只是增加了一个引用符号,但它对 C++ 软件设计和类库的设计有非常大的影响。它既能简化代码,又能提高程序运行效率。每一个 C++ 软件设计师和程序员都应该理解并能够应用它。我们在设计类的时候如果有动态申请的资源,也应该设计转移构造函数和转移拷贝函数。在设计类库时,还应该考虑 std::move 的使用场景并积极使用它。

测试程序

//
//  test
//
//  Created by 颜泽鑫 on 4/30/16.
//  Copyright © 2016 颜泽鑫. All rights reserved.
//

#include <iostream>
#include <vector>
using namespace std;
// just use to test
class test {
private:
    int* array;
    int size;
public:
    test() {
        array = NULL;
        size = 0;
    }
    test(int *arrays, int sizes) {
        array = new int [sizes];
        for (int i = 0; i != sizes; i++) {
            array[i] = arrays[i];
        }
        size = sizes;
    }
    test(test &orig) {
        cout << "L-value reference occur!" << endl;
        array = new int [orig.size];
        while (size != orig.size) {
            array[size] = orig.array[size];
            size++;
        }
    }
    test(test &&orig) {
        cout << "R-value reference occur!" << endl;
        array = orig.array;
        size = orig.size;
        orig.array = NULL;
    }
    test& operator = (const test &orig) {
        if (this != &orig) {
            cout << "L-value reference occur!" << endl;
            array = new int [orig.size];
            while (size != orig.size) {
                array[size] = orig.array[size];
                size++;
            }
        }
        return *this;
    }
    test& operator = (test &&orig) {
        if (this != &orig) {
            cout << "R-value reference occur!" << endl;
            array = orig.array;
            size = orig.size;
            orig.array = NULL;
        }
        return *this;
    }
};
test check() {
    int a[4] = {1, 2, 3, 4};
    test temp = test(a, 4);
    cout << &temp << endl;
    return temp;
}
// In this program, I try to explain how constructor works and how R-value reference works.
int main() {
    // test 1
    int a[4] = {1, 2, 3, 4};
    cout << "test 1" << endl;
    test temp_test1(check());
    cout << &temp_test1 << endl;
    // Through observe the address of the temporary pointer in func check()
    // and the address of temp_test1, we can see that they are the same object
    // It is clear that the IDE transfer the temporary object to global object in secret
    // without using r-value reference or copy constructor to save time.
    cout << endl;

    cout << "test 1 again" << endl;
    test temp = test(a, 4);
    test temp_test1_again(temp);
    // If we have created an object which is not temporary object, the IDE will use copy constructor.
    cout << endl;

    cout << "test 2" << endl;
    // Move function is used to transfer an object to R-value and obviously the IDE will use
    // r-value copy constructor.
    test temp_test2(move(test(a, 4)));
    cout << endl;

    cout << "test 2 again" << endl;
    test temp2 = test(a, 4);
    test temp_test2_again(move(temp2));
    // Move function can also transfer a l-value object to a r-value object
    // which means that if we will never use temp2 again, we can let it be a r-value object
    // and give it to temp_test2_again, saving time without copy constructor.
    cout << endl;

    cout << "test 3" << endl;
    test temp_test3;
    test temp_test3_s(a, 4);
    temp_test3 = temp_test3_s;
    //  just like the copy constructor.
    cout << endl;

    cout << "test 3 again" << endl;
    test temp_test3_again;
    test temp_test3_again_s(a, 4);
    temp_test3_again = move(temp_test3_again_s);
    cout << endl;

    cout << "test 4" << endl;
    test temp_test4;
    temp_test4 = test(a, 4);
    // If temp_test4 has been created and pass a temporary object to it,
    // the IDE will use r-value reference assignment operator.
    cout << endl;
    return 0;
}
// There is an another question we should know.
// R-value reference doesn't mean we can transfer an address of one object to another object,
// but we can transfer the "source" of one object to another object directly,
// saving much for avoid using copy/assignment operation.
// That is what I have mistaten before.
//

本文大部分知识点来自C++11 标准新特性: 右值引用与转移语义。少许解释与理解属于我个人拙劣见解。

阅读更多

没有更多推荐了,返回首页