2017年5月25日星期四

将C++11新特性用于代码优化

关于C++11的科普,在这里就不详细进行了,可以参考维基百科页面。即使是中文页面,我认为写得足够详细和系统了。

总之,C++11对原始的C/C++作出了在我看来是不算小的改动。有一些概念,放在以前的时代是绝对真理,在C++11推出之后,可能需要重新了解一下了。VS2013对C++11的支持并不算“完美”,不过大部分“有用”的特性还是到位了。这里就以它为例,来谈谈如何把C++11的新特性应用到你的软件开发工作中来提升性能和开发效率。

本文提到的C++11的这些新特性,我大致把它们分为两类:一类是可以直接提升代码的性能表现的,我列在“性能优化”部分;另一类虽然不能直接提升代码的性能,但可以提升开发效率,便于更快地开发出可维护性更好的代码,我列在“非性能优化部分”。

另外,受作者水平所限,本文并不是对C++11在这些方面的完整的参考内容,仅仅作为一个引导来阅读吧。


性能优化部分

右值引用和move语义
C++11引入了右值引用,支持了move语义。在我看来,这个变化的意义可能是C++11里面最大的一个。右值引用和move语义是什么,这里不展开。通俗一点地讲,这个特性使得程序员可以在必要的时候自行决定到底是深拷贝还是浅拷贝。对于大量的数据“搬运”操作,可以节省下不少时间。对于性能优化来说,意义重大。

其实就算没有右值引用,在C++11之前的时代也可以做类似的优化。C++程序员只要对于自己的资源管理类显式地提供深/浅拷贝版本的函数即可。不过这样一来代码工作量会比较大,程序会变得比较复杂,并且始终不是一个规范。现在这一切都不是问题了。

对于STL自己的类/容器,VS2013已经做了足够的优化。例如,你可以通过:
string strA = std::move(strB);
来把strB的字符串动态内存部分直接给到strA,速度比简单的赋值要快不少。当然,strB就不再具有有意义的值了(这里例子中会变成空字符串)。当你push_back或insert一个string到容器里面的时候,如果string其实是一个临时变量,那么用move语义你也可以得到相当明显的性能提升。

如果例子里面string换成一个map<string, string>,那么提升会更明显。总之,内存的分配和释放,以及memcpy操作统统被避免了。所以,理论上需要传递的东西越多,你得到的性能提升就会越显著。

就地部署(emplace)
C++11对于常见的STL容器,都提供了一种能提升性能的数据置入方法,称之为“就地部署”。通过用就地部署取代原来的push_back或insert之类的操作,不再需要先构造再传递,而是由容器直接调用目标对象的构造函数来完成数据填充。

在某些情况下(T提供了对应的构造函数时),这样可以避免一次拷贝构造的开销。而最差的情况(T没有提供对应的构造函数),也最多不过就是与push_back和insert效果一模一样而已。所以我建议所有能用上就地部署的地方,都统统用上,无需太多考虑。

并且,就地部署与move语义相互并不冲突,而且是互有补充。move语义解决深拷贝慢的问题,就地部署试图减少哪怕是浅拷贝的执行次数。两者配合起来效果更加完美。

散列表
在C++11里,不再需要通过第三方库来引入散列表(或者叫哈希表)了。STL正式支持了四种散列表的实现,全部都冠以“unordered_”的前缀,以便与一些第三方实现相区别。

对于大多数用map/set实现的代码,只要简单替换容器就可以得到性能上的提升。map/set基于红黑树(自平衡二叉树),时间复杂度至少是log(N)。散列表版本的map/set提供常数级的时间复杂度,随着数据量的增大,无论是写入还是读取的性能都超过了红黑树版本的map/set。

我个人的测试结论是:同是set<string>,即使是小数据量,散列表版读取代价也只是红黑树版的约60%;小数据量下,红黑树版写入略快,但在容器内数据量达到“万”级别的时候,散列表的写入速度也开始超越红黑树版(Release版测试结论,Debug版在“百”级别即发生超越现象)。

所以我认为,只有在数据量很小,并且写入与读取的概率大致相当时,使用红黑树版map/set才在性能上可能有明显收益。其余情况,都建议采用散列表版本map/set。当然,如果T是自定义类,并且你不愿意为它写散列函数,那就算了。


非性能优化部分

完美转发
C++11中所谓“完美转发”的特性,其实是配合右值引用来使用的。如果为了支持右值引用,而不得不让自己的代码量变大一倍,那有些人可能就要望而却步了。完美转发其实是借用了模板技术,使得你可以只写一份代码,就可以兼顾(常量)左值引用与右值引用的情况。工作量更少,代码更简洁,出错的概率也就更低。

不过,采用模板技术的缺点就是:编译期展开。这一方面降低了编译器的效率,另一方面会导致头文件的包含关系变得不太容易整理。除此之外,还有一种我称之为“不完美转发”的替代解决方案,本质上是在性能上作出一定程度上还算可以接受牺牲,来换取代码简洁性,取得一个还算OK的平衡。我会另外写一篇Blog来介绍一下它。

类型推导
“类型推导”也就是所谓的auto类型。这个东西使用起来基本没有门槛。很多人可能最开始接触C++11就是通过它了。

这个的确是一个好东西,用来写STL的iterator类型再合适不过了。因为我们本来也不怎么关心iterator的具体类型。不过,仍然不建议滥用。如果到处都是auto,阅读你代码的人会经常性地需要回顾才能知道一个变量的类型,特别是在你没有用匈牙利命名法的时候更是如此。

所以,我的建议是:当你觉得一个变量的类型写起来很麻烦,而你其实并不关心它的时候,放心地用auto。并且,auto变量的作用域不要太大,if/for/while循环内的局部变量用它是最合适的。

基于范围的for循环
很多语言早就可以这样写了。而C++11现在也可以这样写了:
for (auto& stk : stocklist)
相比起:
for (auto pIter = stocklist.begin(); pIter != stocklist.end(); ++pIter)
孰优孰劣一目了然。何况后者通常还需要跟一句:
auto stk = (*pIter);
不过,如果是一个map,你可能经常要取pIter->first/second之类。或者你打算在循环里面对pIter做erase操作,那还是用传统方式比较好。

空指针
用nullptr取代NULL。我觉得最大的好处就是nullptr的颜色没有NULL扎眼。不过,由于NULL也表示0,有的时候也表示无效句柄。我觉得对于所有指针类型的NULL,置换成nullptr可能会对阅读代码有一定帮助。

角括号
C++11的编译器现在可以识别>>到底是两个模板类的嵌套,还是>>运算符。因此写代码的时候就不特意空上一格,写多层模板类嵌套的时候就更美观一点。

不过,多层模板类嵌套,本来就不可能“美观”到哪里去。起码我是不建议太多此类的代码实践的。

初始化列表
vector可用这样的方式来进行初始化:
vector<int> vecX = { 1, 2, 3, 4 };
的确是比以前省事了。也就是说,C-Style数组的存在意义又少了一层。

统一初始化
struct可以被这样初始化:
struct C
{
    int a;
    int b;
    int c;
};
C c{1, 2, 3};
class的public成员也可以。
在某些喜欢使用各种结构体的代码中,这个特性可以让你少写一大堆构造函数。

通用智能指针
std::shared_ptr<T>,强在可以指向任意对象,缺点也由此而生:由于引用计数保存在shared_ptr中,因此对智能指针的赋值操作是线程不安全的。这个问题,有一篇Blog论述,我觉得写得不错,就直接引用不细讲了。从原理和测试数据来看,我认为这篇Blog是靠谱的。

所以,虽然shared_ptr很强大,但使用场合需要注意:单线程随便用。多线程下,赋值过程要注意。单对单没啥问题,最好不要出现左值右值交叉的情况(一个线程在A=B,另一个线程在B=C)。若因业务需求无法避免的话,要考虑当作临界资源加锁保护。实在不行,就写一个专用智能指针,把引用计数放在T里面,就不会有问题了。

正则表达式
与散列表类似,不再需要第三方实现,现在C++11也直接支持正则表达式了。我以前要找一个Unicode支持得好的Regex库真的是苦水一堆,现在有了官方支持真的是太好了。