【C++11】三大神器之——右值、移动语义、完美转发-创新互联

前言

如果你还不知道C++11引入的右值、移动语义、完美转发是什么,可以阅读这篇文章;如果你已经对这些知识了如指掌,也可以看看有什么可以补充~😏

创新互联公司2013年成立,是专业互联网技术服务公司,拥有项目成都网站制作、网站建设、外贸网站建设网站策划,项目实施与项目整合能力。我们以让每一个梦想脱颖而出为使命,1280元新乐做网站,已为上家服务,为新乐各地企业和个人服务,联系电话:18982081108一、右值 值类别vs变量类型

在正式认识右值之前,我们要先区分值的类别和变量类型:

  • 值 (value)变量 (variable)是两个独立的概念。值不一定拥有变量名(如表达式:i + j + k)。
  • 值只有类别(category)之分,而变量只有类型(type)之分。

值类别可以被划分左值右值

那什么是左值和右值呢?左值是能被取地址、不能被移动的值。右值是表达式中间结果/函数返回值(可能拥有变量名,也可能没有)。

有一个可以区分左值和右值的便捷方法:看能不能对表达式取地址,如果能,则为左值,否则为右值。

C++11扩展了右值的概念,将右值分为了纯右值和将亡值,但本文不作讨论。

如下的示例将帮助我们区分左值和右值:

int i = 3;       // i是左值,3是右值
int j = i+8;    // j是左值,i+8是右值
char a = getCh();   // a是左值 ,getCh()的返回值是右值(临时变量)
左值引用、右值引用、常引用

在以前的文章中,我们曾经讨论过左值引用和常引用的区别。在本篇文章中,我们需要进一步系统的了解它们三者之间的关系。

引用类型 可以分为两种:

  • 左值引用:用&符号引用左值(但不能引用右值),
  • 右值引用:用&&符号引用右值(可以移动左值)。

在C++11中,因为增加了右值引用(rvalue reference)的概念,所以C++98中的引用都称为了左值引用(lvalue reference)。

使用方法如下所示:

int&& a = 3;         // 3是右值,a是右值引用
int b = 8;               // b是左值
int& bb = b;			//bb是左值引用
int&& c = b + 5;   //  b+5是右值,c是右值引用
AA&& aa = getTemp();   // getTemp()的返回值是右值(临时变量)

左值引用十分常见,我们知道是给变量取个别名,但是引入右值引用的意义是什么呢?(将在下文中解答)

在上述的代码中,getTemp()的返回值本来在表达式语句结束后,其生命也就该终结了(因为是临时变量),而通过右值引用重获了新生,其生命周期将与右值引用类型变量aa的生命周期一样,只要aa还活着,该右值临时变量将会一直存活下去。

在下面的代码中将帮助我们区分左值引用和右值引用:

void func(T& a);//1,参数是左值
void func(T&& a);//2,参数是右值
//T类型的变量
T var;
T& rvar1 = var;//正确,rvar1是左值
T& rvar1 = T{};//错误,左值引用不能引用右值
T&& rvar2 = T{};//正确,rvar2是右值
T&& rvar2 = var;//错误,右值引用不能引用左值
T&& rvar2 = std::move(var);//正确,可以通过std::move()将左值转为右值引用

func(var);//进入1,a是左值
func(T{});//进入2,a是右值
func(rvar1);//进入1,a是左值
func(rvar2);//进入1,rvar2是右值引用但a是左值

可以看出:

  • 当左值引用变量rvar1在初始化时,不能绑定右值T{}
  • 当右值引用变量rvar2在初始化时,不能绑定左值var,但是可以通过std::move()将左值转为右值引用。
  • 在代码的最后,右值引用变量rvar2作为实参传入func中时,在作用域内是左值(已命名的右值引用是左值)。

另外,C++还支持了常引用,能够同时接受左值和右值(作为常引用)。

void func(const T& a);//a是常引用

常引用和右值引用 都能接受右值的绑定,有什么区别呢?

  • 常引用可以像右值引用一样将右值的生命期延长,但它有一个缺点是,只能读不能改。

现在回到我们的问题:引入右值引用的意义是什么?

如果函数重载能够同时接受:右值引用/常引用参数,则编译器将优先重载:右值引用参数,即引入右值引用的主要目的是实现移动语义。

下面是不同值作为实参传入形参时,函数重载优先级(数字越小优先级越高):

实参/形参T&const T&T&&const T&&
左值12
常左值1
右值312
常右值21
引用折叠

在正式学习移动语义(move semantic)完美转发std::forward()之前,我们还要提一嘴引用折叠(reference collapsing),它是移动语义和完美转发的实现基础。

using Lref = Data&;
using Rref = Data&&;
Data data;

Lref&  r1 = data;    // r1是左值
Lref&& r2 = data;    // r2是左值
Rref&  r3 = data;    // r3是左值
Rref&& r4 = Data{};  // r4是右值

总之,只有右值引用折叠到右值引用上仍然是一个右值引用,而其他所有的引用类型之间的折叠都将变成左值引用。

二、移动语义 为什么需要移动语义

我们知道,如果一个对象中有堆区资源,需要编写拷贝构造函数和赋值函数,实现深拷贝。如果被拷贝的对象是临时的,拷贝完就没什么用了,这样会造成没有意义的资源申请和释放操作。如果能将对象包含的资源,直接从旧对象移动到新对象,就可以节省资源申请和释放的时间。C++11新增加的移动语义(move semantic)就是为了做到这一点(基本类型不包含资源,其移动和拷贝相同。)。

还有另一种情况:如果资源对象本身不可拷贝(如智能指针std::unique_ptr)需要定义移动构造/移动赋值函数,其原理类似。

实现移动语义要增加两个函数:移动构造函数和移动赋值函数。我们通过实现一个简单的string类对象来说明:

class String
{public:
    String()
    {cout<< "String类"<< this<< "的构造函数"<< endl;// 显示自己被调用的日志
        const char* s = "Hello C++";
        int len = strlen(s);
        _str = new char[len + 1];
        strcpy(_str, s);
    }

    String(const String& another)
    {cout<< "String类"<< this<< "的拷贝构造"<< endl;// 显示自己被调用的日志
        int len = strlen(another._str);//获取源对象中字符串的长度
        _str = new char[len + 1];
        strcpy(_str, another._str);// 把数据从源对象中拷贝过来
    }

    String& operator=(String& another)
    {cout<< "String类"<< this<< "的拷贝赋值"<< endl;// 显示自己被调用的日志
        if (this == &another)
            return *this;// 避免自我赋值
        int len = strlen(another._str);//获取源对象中字符串的长度
        _str = new char[len + 1];
        strcpy(_str, another._str);// 把数据从源对象中拷贝过来
        return *this;
    }

    String(String&& another)noexcept
    {cout<< "String类"<< this<< "的移动构造"<< endl;// 显示自己被调用的日志
        if(_str != nullptr)
            delete[] _str;  //如果已分配内存,先释放掉
        this->_str = another._str;// 把资源从源对象中转移过来
        another._str = nullptr;// 把源对象中的指针置空
    }

    String& operator=(String&& another)
    {cout<< "String类"<< this<< "的移动赋值"<< endl;// 显示自己被调用的日志
        if (this == &another)
            return *this;// 避免自我赋值
        if(_str != nullptr)
            delete[] _str; //如果已分配内存,先释放掉
        this->_str = another._str;// 把资源从源对象中转移过来
        another._str = nullptr;// 把源对象中的指针置空
        return *this;
    }

    friend ostream& operator<<(ostream& out, String& str)
    {out<< str._str;
        return out;
    }

    ~String()
    {cout<< "String类"<< this<< "的析构函数"<< endl;// 显示自己被调用的日志
        if(_str != nullptr)
        {delete[] _str;
            _str = nullptr;
        }
    }

private:
    char* _str;
};

上述代码中,编译器会根据传入的实参的优先级(详见上文表格),来决定重载的构造函数。

  • 当实参是左值时,使用拷贝构造,拷贝源对象所有的元素。
  • 当实参是右值时,使用移动构造,将指向源对象的内存空间的指针“移动”到新对象,并将源对象的指针置空。
  • 拷贝/移动赋值函数的原理相同,在此不再过多描述。

测试用例及输出的结果:

int main()
{{String str1{};//String类0x751f9ffd60的构造函数
        cout<< "str1 = "<< str1<< endl;//str1 = Hello C++

        String str2{str1};//String类0x751f9ffd58的拷贝构造
        cout<< "str2 = "<< str2<< endl;//str2 = Hello C++
		
		//返回一个右值(临时对象)的lambda函数
        auto f = [] {String aa; return aa;}; 

        // String str3 = f();//拷贝省略
        // cout<< "str3 = "<< str3<< endl;

        String str{};//String类0x751f9ffd48的构造函数
        String str3{move(str)};//String类0x751f9ffd40的移动构造
        cout<< "str3 = "<< str3<< endl;//str3 = Hello C++

        String str4{};//String类0x751f9ffd38的构造函数
		//String类0x751f9ffd68的构造函数
        str4 = f();//String类0x751f9ffd38的移动赋值
        //String类0x751f9ffd68的析构函数
        cout<< "str4 = "<< str4<< endl;//str4 = Hello C++
    }
    //String类0x751f9ffd38的析构函数
    //String类0x751f9ffd40的析构函数
    //String类0x751f9ffd48的析构函数
    //String类0x751f9ffd58的析构函数
    //String类0x751f9ffd60的析构函数
    system("pause");
    return 0;
}

尽管C++11引入了移动语义,但是仍有优化的空间——与其调用一次没有意义的移动构造函数,不如让编译器直接跳过这个过程——于是就有了拷贝省略(copy elision)

移动语义和拷贝省略的区别:

  • 移动语义是语言标准提出的概念。是通过编写遵守移动语义的移动构造函数、右值限定成员函数,在逻辑上优化对象内资源的转移流程。
  • 拷贝省略是非标准(C++ 17 前)的编译器优化。跳过移动/拷贝构造函数,让编译器直接在移动后的对象内存上,构造被移动的对象。

由于拷贝省略的存在,在上述代码中,String str3 = f();会被编译器优化,为了方便演示移动构造函数,我们使用了std::move()的方法移动返回值,当然这会造成不必要的开销。

三、完美转发

阅读本节需要读者有一定的模板编程基础。

通用引用

C++11中引入了变长模板的概念,允许向模板参数里传入不同类型的不定长引用参数。由于每个类型可能是左值引用或右值引用,针对所有可能的左右值引用组合,特化所有模板 是不现实的。

如果没用通用引用的概念,那么对于一个变长模板函数,至少需要两个重载:

templatevoid func(T& arg, Args&...args)
{func(args...);//左值直接展开
}

templatevoid func(T&& arg, Args&&...args)
{func(std::move(args...));//右值需要std::move()转发
}

Scott Meyers(Effective Modern C++的作者)指出“有时候符号&&并不一定代表右值引用,它也可能是左值引用。”

事实上,如果一个引用符号需要通过推导才能得出左右值的类型(如模板参数类型或者auto),那么这个符号就可以是左值引用或右值引用——这就是通用引用 (universal reference)

完美转发

这一点我们通过上文中的引用折叠的示例也可以得出。

基于通用引用,我们可以对上述的代码进行改进:

templatevoid func(T&& arg, Args&&...args)
{func(std::forward(args)... );
}

其中std::forward实现了针对左右值的参数,能保证被转发参数的左、右值属性不变,即完美转发(perfect forwarding)

在C++11中,完美转发支持:

  • 如果模板中(包括类模板和函数模板)函数的参数书写成为T&& 参数名,那么,函数既可以接受左值引用,又可以接受右值引用。
  • 提供了模板函数std::forward(参数),用于转发参数,如果 参数是一个右值,转发之后仍是右值引用;如果参数是一个左值,转发之后仍是左值引用。

使用示例如下:

void fun1(int& i)//如果参数是左值
{cout<< "左值 = "<< i<< endl;
}

void fun1(int&& i)//如果参数是右值
{cout<< "右值 = "<< i<< endl;
}
templatevoid func(T&& ii)
{fun1(std::forward(ii));
}

完美转发的语法比较简单,至于其原理本文暂时不深入研究。😝

最后

本文部分参考自文章

你是否还在寻找稳定的海外服务器提供商?创新互联www.cdcxhl.cn海外机房具备T级流量清洗系统配攻击溯源,准确流量调度确保服务器高可用性,企业级服务器适合批量采购,新人活动首月15元起,快前往官网查看详情吧


文章题目:【C++11】三大神器之——右值、移动语义、完美转发-创新互联
文章转载:http://cdiso.cn/article/cdessi.html

其他资讯