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

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

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

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

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

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

结语

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

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

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

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

你可能感兴趣的