C++精要分析右值引用与完美转发的应用

 更新时间:2022年05月09日 10:29:45   作者:程序猿阿诺  
C++11标准为C++引入右值引用语法的同时,还解决了一个短板,即使用简单的方式即可在函数模板中实现参数的完美转发。那么,什么是完美转发?它为什么是C++98/03 标准存在的一个短板?C++11标准又是如何为C++弥补这一短板的?别急,本节将就这些问题给读者做一一讲解

区分左值与右值

在C++面试的时候,有一个看起来似乎挺简单的问题,却总可以挖出坑来,就是问:“如何区分左值与右值?”

如果面试者自信地回答:“简单来说,等号左边的就是左值,等号右边的就是右值。” 那么好了,手写一道面试题继续提问。

int a=1;
int b=a;

问:a和b各是左值还是右值?

b是左值没有疑问,但如果说a在上面是左值,在下面是右值的,那就要面壁思过了。C++从来就不是一门可以浅尝辄止的编程语言,要学好它真的需要不断地去探问。公布答案:上面代码中的a和b都是左值。所以在很多地方都能看到的区分左右值说法是并不准确的。

如果是给出描述性的说明,那么左值就是指向特定内存具有名称的值(具名对象),它有一个相对稳定的内存地址,并且有一段较长的生命周期。右值是不指向稳定内存地址的匿名值(不具名对象),它的生命周期很短,通常是暂时性的。

要是看着上面这段说明有些抽象,那还有一个好办法来帮助区分,那就是是否可以用取地址符“&”来获得地址。如果能取到地址的则为左值,否则编译期都报错的,那就是右值。

还是以上面的代码为例,&a; &b;这个一眼能看出来可以取地址成功,这是左值。而&1这样的写法编译器肯定会报错,所以1是右值。用这样的方法,目测也可以判断出来了。

右值引用

说到C++中的引用,相信大家都很熟悉其用法了。在函数调用时需要对变量进行修改,或者避免内存复制,就会使用引用的方式。当然,使用指针也能达到一样的效果,但引用相对来说更为安全可靠。这种使用方式就是左值引用。

那么好了,我们先从语法上来认识一下右值引用。

int i = 0;
int &j = i; //左值引用
int &&k = 10; //右值引用

我们看到,右值引用的写法就是在变量名前加上"&&"标识。它的作用是可以延长字面量数字10的生命周期。不过,这看起来似乎并没什么用,不像左值引用那样已经深入人心。那么,我们接下来看一段有意义的示例代码。

#include        <iostream>
using namespace std;
static const int DataSize = 1024;
class ActOne {
    public:
        ActOne() { cout << "ActOne default construct" << endl; }
        ActOne(const ActOne &one) { cout << "ActOne copy construct" << endl; }
        ~ActOne() { cout << "ActOne destructor" << endl;}
        void DoSomething() { cout << "ActOne work" << endl; }
};
ActOne make_one() {
    ActOne one;
    return one;
}
int main() {
    ActOne one = make_one();
    one.DoSomething();
    cout << "++++++++++" << endl;
    ActOne &&one2 = make_one();
    one2.DoSomething();
}

上述源码就是实现生成一个对象并返回的功能。需要注意的是,如果使用g++编译器,对这段代码进行编译的时候要加上-fno-elide-constructors以屏蔽编译器对构造函数的优化操作。

再来看下运行结果:

ActOne default construct
ActOne copy construct
ActOne destructor
ActOne copy construct
ActOne destructor
ActOne work
++++++++++
ActOne default construct
ActOne copy construct
ActOne destructor
ActOne work
ActOne destructor
ActOne destructor

经过对比,我们可以发现未使用右值引用的写法中,拷贝构造函数执行了两次,因为这是make_one()中的return one;会复制一次构造产生的临时对象,接着在ActOne one = make_one();语句中将临时对象复制到one变量,这是第二次拷贝构造的调用。

那么,使用了右值引用的方法中,拷贝构造函数只调用了一次,one2实际上指向的是一个临时存储的变量。因为这个临时变量被one2作为右值所引用,因此其生命期也延长到main函数结束才调用解析构造方法。

大家可以好好体会一下右值引用的作用,对于性能敏感的C++程序员来说,它不仅是降低了程序运行的开销,而且临时局部变量的可引用,也意味着可以减少动态分配内存所带来的管理复杂度。

移动语义

可能有同学出于对技术的追求,会继续提问:那我还想优化程序性能,再减少一次拷贝构造函数的开销行不行?应当对这样的提问给予积极的回应,答案是可以的,这就是C++11标准所引入的移动语义。

让我们将上一节的代码稍加改动,然后来体会一下移动语义的使用。main函数和make_one函数没有变化,所以仅列出ActOne类的源码。

class ActOne {
    public:
        ActOne():data_ptr(new uint8_t[DataSize]) { cout << "ActOne default construct" << endl; }
        ActOne(const ActOne &one) { cout << "ActOne copy construct" << endl; }
        ActOne(ActOne &&one) { // 移动构造方法
            cout << "ActOne move construct" << endl;
            data_ptr = one.data_ptr;
            one.data_ptr = nullptr;
        }
        ~ActOne() {
            cout << "ActOne destructor" << endl;
            if (data_ptr != nullptr) {
                delete []data_ptr;
            }
        }
        void DoSomething() { cout << "ActOne work" << endl; }
    private:
        uint8_t *data_ptr;
};

我想对于任何一名写C/C++的代码的程序员来说,最大的愿望就是动态内存的分配和释放次数越少越好。源码中的ActOne(ActOne &&one)就是一个移动构造方法,它接受的是一个右值作为参数,通过转移实参对象的数据以实现构造目标对象。如果是复制构造要怎么做?那就要先为data_ptr分配好内存,然后再调用内存拷贝函数memcpy进行一次DataSize字节数的复制。

相比于复制构造方法,移动构造只需要进行指针值的替换即可,其时空消耗是不可同日而语的。程序添加了一个移动构造方法运行之后的结果如下:

ActOne default construct
ActOne move construct
ActOne destructor
ActOne move construct
ActOne destructor
ActOne work
++++++++++
ActOne default construct
ActOne move construct
ActOne destructor
ActOne work
ActOne destructor
ActOne destructor

从上面的结果可以观察到,在右值引用和移动语义的配合下,内存的分配实际只发生了一次,移动构造也只有一次。大家可以往上翻到上一节的程序打印结果,对比一下纯拷贝式的构造,进行了三次内存的分配,两次内存深复制操作。这对于程序性能的影响已经不用多说了,各位可以进行benchmark测试以验证移动语义带来的提升了。

从构造函数的优先级来说,编译器对于右值会优先使用移动构造函数去生成目标对象,如果移动构造函数不存在,则是使用复制构造函数。那么赋值运算符能不能进行移动操作呢?答案是可以的,这个实现就留给各位自己去尝试吧。

提示一下,赋值运算符函数的声明:

ActOne & operator=(ActOne &&one) {……}

完美转发

我们再来学习C++11中的一个新特性,就是万能引用。何谓万能,这个名称很唬人,其实就是一种引用的实现方法,它既可以引用左值,也可以引用右值。不废话,还是直接上代码。

int get_param() { return 100;}
int &&a = get_param(); // a为右值引用
auto &&b = get_param(); // b为万能引用

可以看到,a和b的区别就在于b的类型是由auto推导而来,而a则是确定类型的。这是作为函数返回值的,再看一个模板参数的例子:

template <class T> 
void func1(T &&t){} // t为万能引用
int a = 100;
const int b = 200;
func1(a);
func1(b);
func1(get_param());

模板方法的参数t可以接受任何类型的数据,并推导出一个引用类型结果,是什么结果我们后面会说。所以我们会发现,万能引用本质上是发生了类型推导。auto &&T &&在初始化过程中都会发生类型推导。

那么推导结果的规则也很简单:

  1. 如果源对象是左值,则目标对象会被推导为左值引用;
  2. 如果源对象是右值,则目标对象会被推导为右值引用。

万能引用的概念大家已经了解,那么它的用途是什么呢?这就是本节标题所要说的完美转发。实话说,我不太喜欢C++术语中的某些翻译,在中文语境下很容易让人费解、误解或是产生不必要的期待。例如C++的万能引用可以实现完美转发,如果你向一名初学者来上这么一句,他是不是会觉得“这门语言也太牛X了吧,竟然有万能和完美的特性?” 窃以为换成“全值引用”和“任意转发”会不会低调和贴切一些呢。

让我们先从转发的一个局限性示例说起:

template<class T>
void show_info(T t) {
    cout << "type is: " << typeid(t).name() << endl;
}
template<class T>
void transform(T t) {
    show_info(t);
}
int main() {
    string tmp("test for forward");
    transform(tmp);
}

上述代码可以工作,但从性能上说string类对象作为参数传递时会发生一次临时对象复制。在实际工作中,它可能就是一个包含有大块内存变量的对象,显然不能这么干。那就给参数加上一个&符使之成为左值引用吧。下一个问题又来了,如果传的参数是个右值怎么?看到这里,大家就明白了,要想结束抬杠在这儿用上万能引用就好了。

最终版完美引用实现,仅列出有变动的代码:

template<class T>
void transform(T &&t) {
    show_info(std::forward<T>(t));
}

std::forward()是标准库中的模板方法,它的功能就是可以根据值的类型将其按左值引用或右值引用进行转发。这样,既避免了临时对象复制的开销,又可以支持任意类型的对象转发。某种意义上,将其称为“完美”似乎也并不为过。毕竟要让挑剔的C++程序员感到满意并不容易啊。

需要注意的是,标准库中的std::move()方法是将任意实参转换为右值引用,使用这个方法不需要指定模板实参。而std::forward()方法在使用的时候必须指定模板实参,也只有它才能按实际类型进行转发。

结语

右值引用说到这里,相信大家已经从一知半解的状态到可以理解并运用了。它对于苛求性能以及强调效率的场景有着非凡的意义,例如在基础库组件的实现中。虽然大多数程序员都不一定会参与到基础库的开发中,但这就看个人对于技术之道的追求了。即使是调用别人做好的库来组装一个应用,也会遇到性能调优的问题,那个时候你对老板有多大的价值就体现在这里了。

如果大家在工作中发现以前的代码在用支持C++11的编译器重新编译之后,运行效率居然有了提升,不用奇怪,这就是基于C++11的新特性做的编译期优化。例如今天学习的右值引用、移动语义、万能引用、完美转发等就在语法层面提供了良好的支持。

希望我们接下来在实践中不断练习,能够发挥出C++的最大威力来!

到此这篇关于C++精要分析右值引用与完美转发的应用的文章就介绍到这了,更多相关C++右值引用与完美转发内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!

相关文章

  • C语言实现通讯录小功能

    C语言实现通讯录小功能

    这篇文章主要为大家详细介绍了C语言实现通讯录小功能,文中示例代码介绍的非常详细,具有一定的参考价值,感兴趣的小伙伴们可以参考一下
    2021-09-09
  • C语言如何利用异或进行两个值的交换详解

    C语言如何利用异或进行两个值的交换详解

    最近在工作中遇到了两个值交换的需求,发现自己对异或有些忘记,所以索性写出来,方便以后需要的时候参考学习,下面这篇文章主要给大家介绍了关于C语言如何利用异或进行两个值的交换的相关资料,需要的朋友可以参考下。
    2017-09-09
  • OpenCV实现最小外接正矩形

    OpenCV实现最小外接正矩形

    这篇文章主要为大家详细介绍了OpenCV实现最小外接正矩形,文中示例代码介绍的非常详细,具有一定的参考价值,感兴趣的小伙伴们可以参考一下
    2020-07-07
  • c++ 判断奇数偶数实例介绍

    c++ 判断奇数偶数实例介绍

    下面通过判断一个数是偶数还是奇数来展示交互递归的应用,并且此题突出了递归跳跃的信任的重要性,需要的朋友可以参考下
    2012-11-11
  • C语言中“不受限制”的字符串函数总结

    C语言中“不受限制”的字符串函数总结

    这篇文章主要给大家总结介绍了C语言中一些“不受限制”的字符串函数,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧
    2021-03-03
  • 快速入门的一些C\C++书籍

    快速入门的一些C\C++书籍

    这篇文章为大家精心推荐了一些快速入门的一些C\C++书籍,希望大家可以喜欢,对这门语言可以产生兴趣,需要的朋友可以参考下
    2015-12-12
  • c++代码各种注释示例详解

    c++代码各种注释示例详解

    大家好,本篇文章主要讲的是c++代码各种注释示例详解,感兴趣的同学赶快来看一看吧,对你有帮助的话记得收藏一下,方便下次浏览
    2021-12-12
  • 好用的C++ string Format“函数”介绍

    好用的C++ string Format“函数”介绍

    大家好,本篇文章主要讲的是好用的C++ string Format“函数”介绍,感兴趣的同学赶快来看一看吧,对你有帮助的话记得收藏一下,方便下次浏览
    2021-12-12
  • C语言中的long型究竟占4个字节还是8个字节(遇到的坑)

    C语言中的long型究竟占4个字节还是8个字节(遇到的坑)

    小编在复习C语言的时候踩到了不少坑,纠结long类型究竟占4个字节还是8个字节呢?好,今天通过本文给大家分享下我的详细思路,感兴趣的朋友跟随小编一起看看吧
    2021-11-11
  • 详解QML 调用 C++ 中的内容

    详解QML 调用 C++ 中的内容

    这篇文章主要介绍了QML 怎么调用 C++ 中的内容,这里主要是总结一下,怎么在 QML 文件中引用 C ++ 文件里定义的内容,本文通过实例代码给大家介绍的非常详细,对大家的学习或工作具有一定的参考借鉴价值,需要的朋友可以参考下
    2022-10-10

最新评论