pImpl成语是否真的在实践中使用?

时间:2012-01-23 13:43:47

标签: c++ oop pimpl-idiom

我正在阅读Herb Sutter的“Exceptional C ++”一书,在那本书中我学到了关于pImpl的习语。基本上,我们的想法是为private的{​​{1}}个对象创建一个结构,并动态地将它们分配给减少编译时间(并且还将私有实现隐藏起来更好方式)。

例如:

class

可以更改为:

class X
{
private:
  C c;
  D d;  
} ;

,在CPP中,定义:

class X
{
private:
  struct XImpl;
  XImpl* pImpl;       
};

这看起来很有趣,但我以前从未见过这种方法,既没有在我工作的公司,也没有在我看过源代码的开源项目中。所以,我想知道这种技术真的在实践中使用了吗?

我应该在任何地方使用它,还是谨慎使用?这种技术是否建议用于嵌入式系统(性能非常重要)?

11 个答案:

答案 0 :(得分:120)

  

所以,我想知道这种技术是否真的在实践中使用?我应该在任何地方使用它,还是谨慎使用?

当然是使用它。我在我的项目中使用它,几乎在每个类中。


使用PIMPL习语的原因:

二进制兼容性

当您开发库时,可以向XImpl添加/修改字段,而不会破坏与客户端的二进制兼容性(这意味着崩溃!)。由于X类的二进制布局在向Ximpl类添加新字段时不会更改,因此可以安全地在次要版本更新中向库中添加新功能。

当然,您还可以在不破坏二进制兼容性的情况下向X / XImpl添加新的公共/私有非虚方法,但这与标准的头/实现技术相同。

数据隐藏

如果您正在开发库,尤其是专有库,则可能不希望披露使用其他库/实现技术来实现库的公共接口。无论是因为知识产权问题,还是因为您认为用户可能会对实施采取危险的假设,或者只是通过使用可怕的铸造技巧来打破封装。 PIMPL解决/减轻了这一点。

编译时间

编译时间减少,因为当您向X类添加/删除字段和/或方法时,只需要重建XImpl的源(实现)文件(映射到添加私有)标准技术中的字段/方法)。在实践中,这是一种常见的操作。

使用标准头/实现技术(没有PIMPL),当您向X添加新字段时,每个分配X的客户端(在堆栈上或堆上)都需要重新编译,因为它必须调整分配的大小。好吧,每个不分配X 的客户端都需要重新编译,但它只是开销(客户端的结果代码将是相同的)。

更重要的是,即使私有方法XClient1.cpp添加到X::foo()并且X已更改,标准标头/实现间隔X.h也需要重新编译,甚至虽然XClient1.cpp因封装原因无法调用此方法!如上所述,它是纯粹的开销,与现实生活中C ++构建系统的工作方式有关。

当然,只修改方法的实现时不需要重新编译(因为你没有触及标题),但这与标准的头文件/实现技术相同。


  

这种技术是否建议用于嵌入式系统(性能非常重要)?

这取决于目标的强大程度。然而,这个问题的唯一答案是:衡量和评估你获得和失去的东西。另外,请注意,如果您没有发布客户要在嵌入式系统中使用的库,则仅适用编译时优势!

答案 1 :(得分:45)

似乎很多库都使用它来保持API的稳定性,至少在某些版本中是这样。

但至于所有事情,你绝不应该谨慎使用任何地方。在使用它之前一定要考虑。评估它给你带来的好处,以及它们是否物有所值。

可能给你的好处是:

  • 有助于保持共享库的二进制兼容性
  • 隐藏某些内部细节
  • 减少重新编译周期

那些对您来说可能是也可能不是真正的优势。对我来说,我不关心几分钟的重新编译时间。最终用户通常也不这样做,因为他们总是从头开始编译它。

可能的缺点(在这里,取决于实施以及它们对您来说是否真正有缺点):

  • 由于分配比使用naïve变种而增加了内存使用量
  • 增加维护工作量(您必须至少编写转发功能)
  • 性能损失(编译器可能无法内联内容,因为它与您的类的天真实现一样)

小心地给所有东西一个价值,并为自己评估。对我来说,几乎总是证明使用pimpl成语并不值得付出努力。只有一个案例我个人使用它(或至少类似的东西):

我的C ++包装器用于linux stat调用。这里C头的结构可能不同,具体取决于#defines的设置。由于我的包装器标头无法控制所有这些,我只在#include <sys/stat.h>文件中.cxx并避免这些问题。

答案 2 :(得分:29)

同意所有其他有关商品的信息,但让我提出一个限制:不适用于模板

原因是模板实例化需要在实例化发生的地方提供完整的声明。 (这就是你没有看到CPP文件中定义的模板方法的主要原因)

您仍然可以参考模板化子类,但由于您必须将它们全部包含在内,因此编译时“实现解耦”的所有优点(避免在所有地方包含所有平台特定代码,缩短编译)都会丢失。

是经典OOP(基于继承)的良好范例,但不适用于通用编程(基于特殊化)。

答案 3 :(得分:20)

其他人已经提供了技术上/下行,但我认为以下值得注意:

首先,不要教条。如果pImpl适用于您的情况,请使用它 - 不要仅仅因为“它是更好的OO,因为它真的隐藏实现”等等。引用C ++ FAQ:

  

封装用于代码,而不是人(source

只是为您举例说明使用它的开源软件及其原因:OpenThreads,OpenSceneGraph使用的线程库。主要思想是从头部(例如<Thread.h>)中删除所有特定于平台的代码,因为内部状态变量(例如,线程句柄)因平台而异。通过这种方式,可以在不知道其他平台特性的情况下针对您的库编译代码,因为所有内容都是隐藏的。

答案 4 :(得分:12)

我主要考虑将PIMPL用于其他模块用作API的类。这有很多好处,因为它使重新编译PIMPL实现中所做的更改不会影响项目的其余部分。此外,对于API类,它们提升了二进制兼容性(模块实现中的更改不会影响这些模块的客户端,因为新实现具有相同的二进制接口,所以不必重新编译它们 - PIMPL公开的接口)。

至于在每个类中使用PIMPL,我会考虑谨慎,因为所有这些好处都需要付出代价:为了访问实现方法,需要额外的间接级别。

答案 5 :(得分:5)

我认为这是解耦的最基本工具之一。

我在嵌入式项目(SetTopBox)上使用了pimpl(和Exceptional C ++的许多其他成语)。

我们项目中这个idoim的特殊目的是隐藏XImpl类使用的类型。 具体来说,我们使用它来隐藏不同硬件的实现细节,其中将引入不同的标头。我们对一个平台有不同的XImpl类实现,而另一个平台有不同的实现。无论平台如何,X级的布局都保持不变。

答案 6 :(得分:4)

我过去经常使用这种技术但后来发现自己远离它。

当然,将实现细节隐藏在班级用户之外是个好主意。但是,您也可以通过让类的用户使用抽象接口并将实现细节作为具体类来实现。

pImpl的优点是:

  1. 假设这个接口只有一个实现,不使用抽象类/具体实现就更清楚了

  2. 如果你有一套类(一个模块),那么几个类可以访问相同的#34; impl&#34;但模块的用户只会使用&#34;暴露的&#34;类。

  3. 如果认为这是一件坏事,那就没有v-table。

  4. 我发现pImpl的缺点(抽象界面效果更好)

    1. 虽然你可能只有一个&#34;生产&#34;实现,通过使用抽象接口,您还可以创建一个&#34; mock&#34;实施单元测试的实现。

    2. (最大的问题)在unique_ptr和移动之前,您已经限制了如何存储pImpl的选择。一个原始指针,你有关于你的类是不可复制的问题。旧的auto_ptr不会使用向前声明的类(不管怎样都不在所有编译器上)。所以人们开始使用shared_ptr,这很好地使你的类可以复制,但当然两个副本都有相同的底层shared_ptr,你可能没想到(修改一个,两个都被修改)。因此,解决方案通常是使用原始指针作为内部指针,并使类不可复制并返回一个shared_ptr。所以两次打电话给新人。 (实际上,给定的旧的shared_ptr给了你第二个)。

    3. 技术上并不是真正的const-correct,因为constness没有传播到成员指针。

    4. 因此,一般来说,我已经从pImpl转移到了抽象的接口用法(以及用于创建实例的工厂方法)。

答案 7 :(得分:3)

正如许多其他人所说,Pimpl习语允许达到完整的信息隐藏和编译独立性,遗憾的是性能损失(附加指针间接)和额外的内存需求(成员指针本身)。额外的成本在嵌入式软件开发中至关重要,特别是在必须尽可能节省内存的情况下。 使用C ++抽象类作为接口将以相同的成本获得相同的好处。 这显示了C ++的一个重大缺陷,其中不再出现类似C的接口(带有不透明指针作为参数的全局方法),如果没有额外的资源缺陷,就不可能拥有真正的信息隐藏和编译独立性:这主要是因为类的声明,必须由其用户包含,不仅导出用户所需的类(公共方法)的接口,而且还导出用户不需要的内部(私有成员)。

答案 8 :(得分:3)

这是我遇到的一个实际场景,这个成语帮助很大。我最近决定在游戏引擎中支持DirectX 11以及我现有的DirectX 9支持。该引擎已经包含了大多数DX功能,因此没有直接使用DX接口;它们只是在标题中定义为私有成员。该引擎使用DLL作为扩展,添加键盘,鼠标,操纵杆和脚本支持,与其他许多扩展一样。虽然大多数这些DLL没有直接使用DX,但它们需要知识和与DX的联系,因为它们引入暴露DX的标题。 在添加DX 11时,这种复杂性会急剧增加,但不必要。将DX成员移动到仅在源中定义的Pimpl中消除了此拼版。 除了减少库依赖性之外,我的公开接口变得更加清晰,因为将私有成员函数移动到Pimpl中,只暴露出面向前的接口。

答案 9 :(得分:2)

它在许多项目中用于实践。它的实用性在很大程度上取决于项目的类型。使用它的一个更突出的项目是Qt,其基本思想是隐藏用户(使用Qt的其他开发人员)的实现或平台特定代码。

这是一个高尚的想法,但这有一个真正的缺点:调试 只要隐藏在私有实现中的代码具有高质量,这一切都很好,但如果存在错误,那么用户/开发人员就会遇到问题,因为它只是指向隐藏实现的愚蠢指针,即使他有实现源代码。

因此,在几乎所有的设计决策中都有利有弊。

答案 10 :(得分:1)

我可以看到的一个好处是它允许程序员以相当快的方式实现某些操作:

X( X && move_semantics_are_cool ) : pImpl(NULL) {
    this->swap(move_semantics_are_cool);
}
X& swap( X& rhs ) {
    std::swap( pImpl, rhs.pImpl );
    return *this;
}
X& operator=( X && move_semantics_are_cool ) {
    return this->swap(move_semantics_are_cool);
}
X& operator=( const X& rhs ) {
    X temporary_copy(rhs);
    return this->swap(temporary_copy);
}
PS:我希望我不会误解移动语义。