c/c++进阶之爱恨交织的临时对象: 二、天使与魔鬼

c/c++语言最让人称道的便是性能了,在大气科学、地球物理等等需要高性能计算方面c/c++语言都是不二之选。甚至在分布式领域,由于ssd固态硬盘和万兆网络的兴起,当IO不再成为分布式系统的瓶颈,CPU重新成为瓶颈之后,c++在分布式领域也有重新抬头的趋势。

但是我想问的是,你真的有这么了解C++的性能优势么?

邪恶的魔鬼 : 隐藏在背后的性能杀手

把你的心血之作(包括你的程序之性能在内)当成垃圾抛出窗外的罪人,往往是一些意想不到的临时对象。(Exceptional C++ 中文版)

来看看下面这段代码:

std::string findPerson(std::vector<std::string> person,string name)
{
    for(auto itr = person.begin();itr != person.end();itr++)
    {
        if(*itr == name)
            return *itr;
    }
    return " ";
}

在person的向量中寻找名字为name的成员,如果找到了就返回其名字,没找到就返回一个空的string。功能很简单,代码也很短,看起来确实是很美好。但是现实是残酷的,仔细分析下我们就能发现还有很多不尽人意的地方。

第一处和第二处要改进的地方很好找,那就是findPerson(std::vector<std::string> person,string name)中的参数都应该使用常量引用(const reference),而不是用传值(pass-by-value)的方式。

条款20:宁以pass-by-reference-to-const替换pass-by-value (Effective c++ 中文版第三版86页)

在上一章我们讲过,在函数按值传参,会调用拷贝构造函数构造出实参的一个副本(临时对象),想想person中的元素个数如果很多的话,拷贝这些元素的性能代价有多大。

函数传参时,请使用const reference而不是传值拷贝!重点标记!

相对于前两处比较明显的,第三处提升性能的位置就很隐蔽了。我们可以回忆下,我们平时写得最多的for循环

for(int i = 0; i < n; i++)
{
    //do something
}

真心没毛病啊。是么?那我说i++是右值表达式(rvalue expression)呢? 回忆下我们在学操作符重载的时候,对先增(preincrement)操作和后增(postincrement)操作是如何处理的?参考如下代码:

//pre-increment
self& operator++() {
    node = (link_type)((*node).next);
    return *this;
}
//post-increment
self operator++(int) {
    self tmp = *this;
    ++*this;
    return tmp;
}
//这段代码您可以在《STL源码剖析》(侯捷)131页找到

这是SGI STL中list中实现迭代器iterator一段代码。比较前++和后++操作,后++操作时不仅对象自己递增,还有两处调用拷贝构造函数的开销。所以前++操作要比后++操作要更高效。

在使用for loop循环时,请使用前++(pre-increment)操作,避免使用后++(post-increment)操作

下面让我们改进下代码,并尝试运行,来看看改进后性能是否会有很大提升。

#include <cstdio>
#include <cstring>
#include <ctime>
#include <vector>
#include <string>
using Person = std::vector<std::string>;
using String = std::string;
String findPerson1(Person _person, String _name);
String findPerson2(const Person &_person, const String &_name);
int main()
{
     const int num(1000000);
     char str[10] = { 0 };
     Person person(num);
     String name("999999");
     for (size_t i = 0; i<person.size(); i++)
     {
         memset(str, 0, sizeof(str));
         sprintf(str, "%ld", i);
         person[i] = String(str);
     }
     clock_t tbeg = clock();
     String rv = findPerson1(person, name);
     clock_t tmid = clock();
     printf("Version 1 takes %f seconds\n", static_cast<float>(tmid - tbeg) / CLOCKS_PER_SEC);
     rv = findPerson2(person, name);
     clock_t tend = clock();
     printf("Version 2 takes %f seconds\n", static_cast<float>(tend - tmid) / CLOCKS_PER_SEC);
     return 0;
}

String findPerson1(Person _person, String _name)
{
     for (auto itr = _person.begin(); itr != _person.end(); itr++)
     {
         if (*itr == _name)
             return *itr;
     }
     return " ";
}

String findPerson2(const Person &_person, const String &_name)
{
    String retval;
    for (auto itr = _person.begin(); itr != _person.end(); ++itr)
    {
        if (*itr == _name)
            retval = *itr;
    }
    return retval;
}

下图为在win7平台visual studio 2013 Release模式下执行结果:
这里写图片描述

下图是在Ubuntu下 g++ 5.3.1 O3优化模式下执行结果:
这里写图片描述

从执行结果上看,我们改进后的版本在性能上确实是有很大的提升。

善良的天使 : 让语义更清晰,让性能再起飞

看了上面的描述,你可能会觉得!我的天,临时对象这东西这么影响性能,怎么办?不要怕,我们只要避免那些不必要的临时对象的开销就OK了,在有些时候,临时对象反而会让我们有所收获。

来看下面的代码:

// sort algorithm example
#include <iostream>     // std::cout
#include <algorithm>    // std::sort
#include <vector>       // std::vector

bool myfunction (int i,int j) { return (i<j); }

struct myclass {
  bool operator() (int i,int j) { return (i<j);}
} myobject;

int main () {
  int myints[] = {32,71,12,45,26,80,53,33};
  std::vector<int> myvector (myints, myints+8);               // 32 71 12 45 26 80 53 33

  // using default comparison (operator <):
  std::sort (myvector.begin(), myvector.begin()+4);           //(12 32 45 71)26 80 53 33

  // using function as comp
  std::sort (myvector.begin()+4, myvector.end(), myfunction); // 12 32 45 71(26 33 53 80)

  // using object as comp
  std::sort (myvector.begin(), myvector.end(), myobject);     //(12 26 32 33 45 53 71 80)

  // print out content:
  std::cout << "myvector contains:";
  for (std::vector<int>::iterator it=myvector.begin(); it!=myvector.end(); ++it)
    std::cout << ' ' << *it;
  std::cout << '\n';

  return 0;
}
//这段代码来自于( http://www.cplusplus.com/reference/algorithm/sort/ )

在上面的代码中第20行,sort()函数传入的是一个函数指针,而在第23行中传入的是一个具名对象,从定义上看,这个对象只是重载()运算符,所以它的行为就类似于一个函数的行为。我们称这种对象为函数对象,也叫做仿函数。
但一般的来说,在第23行我们更好的做法是:

std::sort(myvector.begin(),myvector.end(),myclass());

也许你会说,不就是少了一个myclass对象的声明么,有那么玄乎么?请不要小看这里少了一句对象的声明,也许在自己平时工作或者写的一些小demo中,多一句声明看起来似乎没什么问题,但是在编写类似像庞大的STL标准库时一些基础的仿函数如(greater<>,less<>,identity<>)用到的频率那是相当高的,所以省下的工作量是相当可观的。更重要的是,使用仿函数更与更清晰、自然的语义(如less<>一看就知道是什么意思了),而且还不用关心它的生命周期。

临时对象还有一个更重要的用法,那就是返回值优化(return value optimization),简称RVO

我们在上一章讲到了如何函数按值返回的话就会产生一个临时对象,并调用拷贝构造函数,我们先将代码copy下来。

#include <iostream>
#include <cstdlib>
using namespace std;
class CTest
{
public:
    CTest(){cout << "call constructor"<< endl;}
    CTest(const CTest& other){cout << "call copy-constructor"<< endl;}
    ~CTest(){cout << "call destructor"<< endl;}
};
CTest func();
int main()
{
    func();
    system("pause");
    return 0;
}
CTest func()
{
    CTest test;
    return test;
}
// 在visual studio 2013 Debug模式下编译执行

这里写图片描述

可以看到,打印结果是符合我们的预期的,main()函数中执行到func()函数时,先是在func()中调用构造函数构造出test对象,然后调用拷贝构造函数拷贝func()函数中test对象的值构造出一个临时对象。所以我们可以看到两次析构函数的调用。

func()函数稍稍修改如下,会发生什么呢?

CTest func()
{
    return CTest();
}

这里写图片描述

我们惊奇地发现,返回一个临时对象时,只有一次构造函数和析构函数的开销了,怎么理解?
实际上,这些都是编译器在背后做的一些优化,当我们返回一个临时对象时,编译器会认为这个临时对象就只是用来返回的,所以它直接把对象创建在外部的返回值的内存单元内,因为不是真正的构造一个局部对象,因此从整个过程看,省下了一次拷贝构造函数和析构函数的开销(对于一个继承了很多层的子类来说,调用一次构造和析构开销是很大的)。

用下面的3个阶段再来分析下更清晰:

1、首先,程序执行到func()处,为func()函数要返回的临时对象准备一段内存空间。注意,临时对象还没有被构造出来,但是内存空间却已经存在了。为了方便起见,为临时对象起名叫temp。

2、编译器保存住temp的地址,并偷偷地改造func()函数(增加一个参数)将temp的地址传进func()函数内部。

3、在func()函数内部,编译器一看要返回一个临时对象?乐了,编译器告诉要返回的临时对象,反正你也不做什么别的事,也别另找块内存空间构造了,就直接构造在temp的内存空间内吧,这样对于temp我就不调用构造函数来构造了,省点事。所以,实际上temp的构造就在func()函数的内部完成了,而且确实只有一次构造函数的调用。

返回临时变量的做法不需要什么花费,因此效率是非常高的,程序员要理解并利用这样的做法。

有些读者可能注意到了,我在上面的代码中强调了是在visual studio 2013 Debug模式下完成的测试,这是因为除了RVO,还有具名返回值优化(named return value optimization),简称NRVO。在Release模式下执行上面的代码:
这里写图片描述

可以发现,这里同样省下了一次拷贝构造和析构的开销。

对于NRVO来说,不同的编译器或者编译选项都有可能是不同的情况。在g++中,默认是打开NRVO的(我的g++编译器为5.3.1),可以由-fno-elide-constructors编译选项来关闭,如下图所示:
这里写图片描述

关于NRVO更多的一些情况,读者可以自行查阅资料做了解,这里就不再多讲了。

总结:编码过程中,要尽量避免不必要的临时对象的开销。当编写按值返回的函数时,尽量要利用到返回值优化这一特性。同样,如果使用临时变量会有更清晰、更自然的语义的话就应该大胆的使用。

临时变量是个很大的题目,下一讲我会讲到临时对象在某些时候在不同编译器上截然不同的表现,以及由于历史原因遗留下来的一些奇奇怪怪的坑。我们应该尽量地规避这些坑,在编写一些跨平台的程序时,写出更健壮的程序。

ps:本人学识、水平有限,有错误处还望批评指正,谢谢!

参考资料:
《Effective c++ 中文版第三版》
《Effective STL 中文版》
《Exceptional C++中文版》
《STL源码剖析》
http://blog.csdn.net/jjw97_5/article/details/16806393
http://www.cnblogs.com/xkfz007/articles/2506022.html

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值