最好的朋友:C++11移动语义和Pimpl手法

简介:


当编译器可以用廉价的挪动操作替换昂贵的复制操作时,也就是当它可以用一个指向一个大对象的指针的浅层复制来替换对这个大对象的深层复制的时候,挪动语义要比复制语义更快速。因此,在类中利用 PIMPL方法 结合挪动语义,应该能预见到有相当大的速度提升。由于QT对于每个非常规类都采用PIMPL方法,因此通过简单地使用Qt类而不是与它们对应的STL,我们应该可以看到速度有很大的提升。我将会对使用了挪动语义,应用和没有应用PIMPL方法的Qt和STL类进行比较。

使用了挪动语义和 PIMPL 方法的一个类

在我的文章《通过 C++11 挪动语义提升性能》中我们将PIMPL方法应用到了CTeam这个类。

 
  1. // cteam.h 
  2.   
  3. #ifndef CTEAM_H 
  4. #define CTEAM_H 
  5.   
  6. #include <memory> 
  7.   
  8. class CTeam 
  9. public
  10.     ~CTeam();                                      // dtor 
  11.     CTeam();                                       // default ctor 
  12.     CTeam(const std::string &n, int p, int gd);    // name ctor 
  13.   
  14.     CTeam(const CTeam &t);                         // copy ctor 
  15.     CTeam &operator=(const CTeam &t);              // copy assign 
  16.   
  17.     CTeam(CTeam &&t);                              // move ctor 
  18.     CTeam &operator=(CTeam &&t);                   // move assign 
  19.   
  20.     std::string name() const; 
  21.     int points() const; 
  22.     int goalDifference() const; 
  23.   
  24. private: 
  25.     struct Impl; 
  26.     std::unique_ptr<Impl> m_impl; 
  27. }; 
  28.   
  29. #endif // CTEAM_H 

CTeam 的公共接口跟之前一样。我们将私有数据成员用一个独立的指针替换并将它们移动到是有实现类 CTeam::Impl 中去。CTeam::Impl 的声明和定义被放在了源代码文件 cteam.cpp 中。这就是 pimpl 方法巨大的优势之一: 头文件不包含任何实现细节。因此我们就能够修改pimpl方法化的类而不用修改接口(见Marc Mutz的文章《Pimpl方法Pimpl化》以了解更多pimpl方法的优势。

 
  1. // cteam.cpp 
  2. #include ... 
  3.   
  4. using namespace std; 
  5.   
  6. struct CTeam::Impl 
  7.     ~Impl() = default
  8.     Impl(const std::string &n, int p, int gd); 
  9.     Impl(const Impl &t) = default
  10.     Impl &operator=(const Impl &t) = default
  11.   
  12.     std::string m_name; 
  13.     int m_points; 
  14.     int m_goalDifference; 
  15.     static constexpr int statisticsSize = 100; 
  16.     std::vector m_statistics; 
  17. }; 
  18.   
  19.   
  20. CTeam::Impl::Impl(const std::string &n, int p, int gd) 
  21.     : m_name(n) 
  22.     , m_points(p) 
  23.     , m_goalDifference(gd) 
  24.     m_statistics.reserve(statisticsSize); 
  25.     srand(p); 
  26.     for (int i = 0; i < statisticsSize; ++i) { 
  27.         m_statistics[i] = static_cast(rand() % 10000) / 100.0; 
  28.     } 

注意 C++11 关键词 default如何帮助我们节省实现析构器、复制构造器以及复制实现类 CTeam::Impl 这些琐碎的代码。我们必须只为特殊命名的构造器编写代码。剩下来的由编译器来生成。

我们将使用CTeam::Impl来实现面向使用者的CTeam类的构造函数与赋值运算符:

 
  1. // cteam.cpp (续) 
  2.   
  3. CTeam::~CTeam() = default
  4.   
  5. CTeam::CTeam() : CTeam("", 0, 0) {} 
  6.   
  7. CTeam::CTeam(const std::string &n, int p, int gd) 
  8.     : m_impl(new Impl(n, p, gd)) 
  9. {} 
  10.   
  11. CTeam::CTeam(const CTeam &t) 
  12.     : m_impl(new Impl(*t.m_impl)) 
  13. {} 
  14.   
  15. CTeam &CTeam::operator=(const CTeam &t) 
  16.     *m_impl = *t.m_impl; 
  17.     return *this; 
  18.   
  19. CTeam::CTeam(CTeam &&t) = default
  20.   
  21. CTeam &CTeam::operator=(CTeam &&t) = default
  22.   
  23. std::string CTeam::name() const 
  24.     return m_impl ? m_impl->m_name : ""

我们让编译器自己来合成析构函数。默认构造函数委派了具名构造函数。具名构造函数以给定的参数创建了一个Team::Impl的对象,一切符合我们所预想的情况。

复制构造函数和复制赋值运算符必须进行深拷贝。而编译器自动合成的版本只会复制m_impl这个指针,也就是说进行的是浅拷贝。因为这是错误的,我们必须手动书写自己的复制构造函数与赋值运算符,这段代码调用了实现类(Impl)的复制构造函数和赋值运算符。

移动构造函数与移动赋值运算符只需要浅拷贝就行了。默认的实现就复制了m_impl指针(浅拷贝),然后将被移动的对象中的m_impl置为nullptr。移动操作将Impl对象的所有权从被移动的位置转移到了目的地的CTeam对象之中。这项行为是由std::unique_ptr实现的,其仅支持移动而不支持拷贝。

因为m_impl这个指向实现类的指针可以为空,例如CTeam::name之类的函数应该在使用这个指针前检查合法性。

基准

我们使用ShuffleAndSort(混乱并排序)和尾部压入为测试基准。我们不使用EmplaceBack作为测试基准,因为Qt5.7(写这篇文章时候的最新版本QT)不支持在Qt容器中使用emplace操作。如此处所示: 通过C++11的移动语义带来性能提升

我运行了不同的实验,我用以下标签标记。

  • C++98 – 用C++98编译器内置的示例代码
  • C++11 – 用C++11编译器内置的示例代码
  • Copy – 类CTeam只有拷贝构造,但没有重载移动构造
  • Move – 类CTeam同时具有拷贝和移动构造
  • STL – 使用std::string和std::vector在示例代码中
  • Qt – 使用QString和QVector在示例代码中
  • Pimpl – 使用pimpl 手法在类CTeam中
  • Opt – 使用lambdas去排序,并使用C++11的随机数生成器

我们每次实验通过callgrind 计数读取指令的个数进行性能测定。由于相对性能比的读取指令绝对数值更能说明问题,所以我们把C++11/Moves的测试结果转为1.000来参考。

以下是测试结果。

                实验                 ShuffleAndSort                 PushBack
                C++98/STL/Copy                 1.693                 1.006
                C++98/Qt/Copy                 1.335                 1.048
C++11/STL/Move 1.000 1.000
                C++11/Qt/Move                 0.773                 1.049
                C++11/STL/Move/Pimpl                 0.730                 1.011
                C++11/Qt/Move/Pimpl                 0.724                 1.071
                C++11/STL/Move/Pimpl/Opt                 0.597                 0.308
                C++11/Qt/Move/Pimpl/Opt                 0.589                 0.399
                C++11/STL/Move/Opt                 0.867                 0.296
                C++11/Qt/Move/Opt                 0.638                 0.378

在`ShuffleAndSort`基准方面,QT的实验结果(绿色部分)比STL的实验结果(红色部分)要快(大概快1.01-1.36倍)。原因很简单。QT在像`QVector`和`QString`这样的重要的类中都使用了PIMPL手法。拷贝一个QT的隐式共享类意味着仅拷贝指针。使用PIMPL的类执行的是浅拷贝而不是深拷贝。在这种情况下,移动主义的性能比复制语义的快。

但是使用PILPL手法本身也是需要开销的。当我们使用PIMPL创建一个对象时,我们创建了一个“接口”对象(比如CTeam),“接口”对象再从堆中动态地返回一个“实现”对象(例如CTeam::Impl)。这就是为什么在`PushBack`基准方面,QT的实验结果普遍比STL的实验结果慢(大约慢1.04-1.30倍)。在任何时候调用一个自定义的构造函数(例如构造函数CTeam())、拷贝构造函数或拷贝赋值操作符以及所有需要执行深拷贝的地方,PIMPL都会产生一定的花销。

如果仅考察STL实验也会得到类似的结论。在ShuffleAndSort,使用PIMPL手法的STL实验,其运行结果比没有使用PIMPL的要快。在PushBack,情况则相反。使用PIMPL手法的STL实验,其运行结果比没有使用PIMPL的要慢。

ShuffleAndSort是使用移动语义和PIMPL的最佳案例。它先执行20遍拷贝操作来填teams容器。然后在打乱和排序的过程中执行810,000次移动。同样地,PushBack是使用移动语义反面代表。它调用CTeam的同名构造函数、复制构造函数、析构函数100,000次。在调用同名构造函数的过程中,需要从堆中动态地创建实现对象,这很明显需要时间开销。

当我们比较使用Pimpl与那些不使用pimpl实验(C++11/STL/Move vs. C++11/STL/Move/Pimpl,C++11/STL/Move/Opt vs. C++11/STL/Move/Pimpl/Opt),我们看到的是一个加速因子1.370到1.452 和缓慢的因素1.011到1.041。使用移动语义和Pimpl增速的幅度比慢造成的pimpl开销命令。如果我们的代码更倾向于shuffleandsort,其中浅拷贝支配深拷贝,我们的代码将最有可能从与Pimpl惯用法组合使用移动语义看全面提速。

幸运的是,在大多数情况下,在真正的代码中,浅副本占主导地位的深层副本。这个观察是必要的当QT项目决定在一开始使用Pimpl惯用法的所有非平凡的类。

如果我们比较使用STL和QT实验之间的Pimpl惯用法的开销(C++11/STL/Move/Pimpl vs. C++11/Qt/Move/Pimpl,C++11/STL/Move/Pimpl/Opt vs. C++11/Qt/Move/Pimpl/Opt)以下的图片出现。为阻挠,QT比STL慢1.06到1.30倍。原因是,cteam纯Qt版本使用pimpl的字符串m_name和双打m_statistics矢量。ShuffleAndSort,QT只是稍微快点(因子:1.008–1.014)比纯C++ 11 / STL。这个小的增速很可能是吃了更大的减速的pimpl开销造成的。

在前C + +的11倍,使用Qt的课给了我们一个速度的优势超过STL类大多数时候。事情有C++ 11的到来改变了。STL类现在看齐的Qt类–感谢移动语义的组合和Pimpl惯用法。纯C++ 11实施给我们更好地控制何时使用Pimpl惯用法时。用QT,我们一直用它–无论产量增速与否。

结论

移动语义给了我们一个加速复制的语义,编译器可以通过移动操作取代昂贵的复制操作。所以,结合移动语义和 pimpl idiom 应该是非常适合的,随着 pimpl idiom 代替昂贵的深拷贝大对象,代价更低的浅拷贝对象的指针直接指向这些大对象。我们的研究结果也证实了这一现象。我们可以看到加速系数是2.319,它是以 ShuffleAndSort 为基准的移动语义和 pimpl idiom 。使用 pimpl idiom 也不是免费的,因为我们必须动态地创建指向对象的指针,这在堆上会有一个额外的步骤。PushBack 基准显示使用 pimpl 会放慢1.005倍的速度。

ShuffleAndSort 基准是一种最好的 pimpl idiom ,因为几乎所有的操作都是移动操作(洗牌并排序)。PushBack 基准则是相反的,它明显倾向于 ShuffleAndSort 的极致。针对这类情况,我们会看到一个速度的提升,因为速度的提升来自于移动对复制的替换,这超过了 pimpl 减慢造成的开销。

对每个有意义的 Qt 类使用 pimpl idiom ,最可能让 Qt 开发者变得容易。使用 pimpl idiom 在绝大部分时间可以产生一个运行时加速——除了提供稳定的接口(二进制兼容!)还提供快速构建。在移动语义不可用的时候,Qt 是相当快的(系数: ~1.25),这超过纯 C++ 。 因此,对于所有预C++11编译器(例如: C++98, C++03),Qt 是一个不错的选择。这种优势遇到移动语义,且 pimpl idiom 进入 C++11。即使是最好的情况的 ShuffleAndSort 基准,Qt 也就只是略高于纯 C++(系数:~1.01)了。这种轻微的优势,容易被 pimpl 的开销吃掉,这样 Qt 就慢于纯 C++ (系数:~1.17)了。

这篇文章已发布。在大部分的情况下,我们将会看到结合 C++11 的移动语义和 pimpl idiom 的速度有提升。C++11 的新 unique_ptr 很容易实现 pimpl idiom 。使用 Qt 类可以替代对应的 STL (例如:QVector 和 QString 代替 std::vector 和 std::string),这样就不用给予我们任何有优势的移动语义和pimpl idiom 的组合。Qt 也有轻微的缺陷,当我们明确决定使用 pimpl idiom 的时候,每一个 Qt 类的出现,我们的代码都会引发 pimpl 的开销。


作者:leoxu, 无若, 乌合之众, htfy96, 数星星, yinahe

来源:51CTO


相关文章
|
4月前
|
编译器 C++ 容器
【C++11特性篇】探究【右值引用(移动语义)】是如何大大提高效率?——对比【拷贝构造&左值引用】
【C++11特性篇】探究【右值引用(移动语义)】是如何大大提高效率?——对比【拷贝构造&左值引用】
|
25天前
|
设计模式 算法 编译器
【C/C++ PIMPL模式 】 深入探索C++中的PIMPL模式
【C/C++ PIMPL模式 】 深入探索C++中的PIMPL模式
50 0
|
1月前
|
存储 编译器 C++
【C++】—— C++11新特性之 “右值引用和移动语义”
【C++】—— C++11新特性之 “右值引用和移动语义”
|
6月前
|
存储 安全 编译器
【C++11新特性】右值引用和移动语义(移动构造,移动赋值)
【C++11新特性】右值引用和移动语义(移动构造,移动赋值)
|
3月前
|
存储 编译器
C++11(左值(引用),右值(引用),移动语义,完美转发)
C++11(左值(引用),右值(引用),移动语义,完美转发)
31 0
|
3月前
|
编译器 C++
c++左值、右值引用和移动语义
c++左值、右值引用和移动语义
21 0
|
3月前
|
安全 编译器 C++
c++11新特性——右值引用和move语义
c++11新特性——右值引用和move语义
|
4月前
|
编译器 C++
深入理解 C++ 右值引用和移动语义:全面解析
C++11引入了右值引用,它也是C++11最重要的新特性之一。原因在于它解决了C++的一大历史遗留问题,即消除了很多场景下的不必要的额外开销。即使你的代码中并不直接使用右值引用,也可以通过标准库,间接地从这一特性中收益。为了更好地理解该特性带来的优化,以及帮助我们实现更高效的程序,我们有必要了解一下有关右值引用的意义。
57 0
|
5月前
|
C++ 容器
C++新特性:右值引用,移动语义,完美转发
C++新特性:右值引用,移动语义,完美转发
37 0

相关实验场景

更多