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++右值引用与完美转发内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!

相关文章

  • OpenCV图像几何变换之透视变换

    OpenCV图像几何变换之透视变换

    这篇文章主要为大家详细介绍了OpenCV图像几何变换之透视变换,文中示例代码介绍的非常详细,具有一定的参考价值,感兴趣的小伙伴们可以参考一下
    2019-05-05
  • 解析之C++的列表初始化语法

    解析之C++的列表初始化语法

    有朋友在使用std::array时发现一个奇怪的问题:当元素类型是复合类型时,编译通不过。按说std::array和原生数组的行为几乎是一样的,可为什么当元素类型不同时,初始化语法还会有差别?这篇文章会介绍这个问题的原理,以及正确的解决方式。
    2021-05-05
  • C++ 多重继承和虚拟继承对象模型、效率分析

    C++ 多重继承和虚拟继承对象模型、效率分析

    本文简单介绍多态和多重继承、虚拟继承的基本概念。随后重点分析了C++中对象模型之间的差异和运行效率
    2014-08-08
  • c++入门必学库函数sort的基本用法

    c++入门必学库函数sort的基本用法

    Sort函数包含在头文件为#include<algorithm>的c++标准库中,调用标准库里的排序方法可以不必知道其内部是如何实现的,只要出现我们想要的结果即可,下面这篇文章主要给大家介绍了关于c++入门必学库函数sort的基本用法,需要的朋友可以参考下
    2022-11-11
  • C语言strlen和sizeof在数组中的使用详解

    C语言strlen和sizeof在数组中的使用详解

    对于 strlen 和 sizeof,相信不少程序员会混淆其功能。虽然从表面上看它们都可以求字符串的长度,但二者却存在着许多不同之处及本质区别
    2021-10-10
  • opencv3机器学习之EM算法示例详解

    opencv3机器学习之EM算法示例详解

    这篇文章主要介绍了opencv3机器学习之EM算法的示例详解,有需要的朋友可以借鉴参考下,希望能够有所帮助,祝大家多多进步,早日升职加薪
    2022-06-06
  • 详解C语言之顺序表

    详解C语言之顺序表

    这篇文章主要为大家介绍了C语言的顺序表,具有一定的参考价值,感兴趣的小伙伴们可以参考一下,希望能够给你带来帮助
    2021-11-11
  • 使用C++实现Range序列生成器的示例代码

    使用C++实现Range序列生成器的示例代码

    在C++编程中,经常需要迭代一系列数字或其他可迭代对象,本文将使用C++来实现一个简单的Range封装,文中的示例代码讲解详细,感兴趣的可以了解下
    2023-11-11
  • C++11新特性之列表初始化的具体使用

    C++11新特性之列表初始化的具体使用

    在我们实际编程中,我们经常会碰到变量初始化的问题,本文主要介绍了C++11新特性之列表初始化的具体使用,文中通过示例代码介绍的非常详细,具有一定的参考价值,感兴趣的小伙伴们可以参考一下
    2022-01-01
  • C++实现Go的defer功能(示例代码)

    C++实现Go的defer功能(示例代码)

    defer和go一样都是Go语言提供的关键字。defer用于资源的释放,会在函数返回之前进行调用。接下来通过本文给大家介绍C++实现Go的defer功能,感兴趣的朋友跟随小编一起看看吧
    2021-07-07

最新评论