是在实践中真正使用的PIMPL习语



我正在阅读Herb Sutter的"Exceptional c++ ">一书,在这本书中我了解了PIMPL习惯用法。基本上,这个想法是为classprivate对象创建一个结构,并动态地将它们分配给,从而减少编译时间(并且以更好的方式隐藏私有实现)。

例如:

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

可以改成:

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

和.cpp文件中的定义:

struct X::XImpl
{
C c;
D d;
};

这似乎很有趣,但我以前从未见过这种方法,无论是在我工作过的公司,还是在我看过源代码的开源项目中。所以,我想知道这种技术是否真的在实践中使用。

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

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

当然使用了。我在我的项目中使用它,几乎每节课都使用它。


使用PIMPL习惯用法的原因:

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

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

数据隐藏

如果你正在开发一个库,特别是一个专有的库,最好不要透露其他库/实现技术是用来实现你的库的公共接口的。要么是因为知识产权问题,要么是因为您认为用户可能会对实现做出危险的假设,或者只是通过使用糟糕的强制转换技巧来破坏封装。PIMPL解决/减轻了这个问题。

编译时间

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

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

更重要的是,标准头/实现分离XClient1.cpp需要重新编译,即使私有方法X::foo()被添加到XX.h改变,即使XClient1.cpp不可能调用这个方法的封装原因!像上面一样,它是纯粹的开销,并且与现实生活中的c++构建系统的工作方式有关。

当然,当您只是修改方法的实现时(因为您不触及头文件)不需要重新编译,但这与标准的头文件/实现技术是一样的。


是否建议在嵌入式系统(性能非常重要)中使用该技术?

这取决于你的目标有多强大。然而,这个问题的唯一答案是:衡量和评估你得到和失去了什么。另外,要考虑到,如果您发布的库不打算供客户端在嵌入式系统中使用,那么只有编译时间优势才适用!

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

但对于所有的东西,你不应该在任何地方使用任何东西而不小心。在使用之前一定要三思。评估它给你带来的好处,以及它们是否值得你付出的代价。

可能给你的好处是:

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

这些对你来说可能是也可能不是真正的优势。就像对我来说,我不在乎几分钟的重新编译时间。最终用户通常也不这样做,因为他们总是从头编译一次。

可能的缺点是(也在这里,取决于实现和它们是否对您来说是真正的缺点):

  • 由于分配比naïve变体
  • 更多而导致内存使用量增加
  • 增加了维护工作(你必须至少编写转发函数)
  • 性能损失(编译器可能无法内联的东西,因为它是与naïve实现你的类)

所以要小心地给每样东西一个值,并自己评估它。对我来说,使用PIMPL习惯用法几乎总是不值得付出努力。我个人只在一种情况下使用它(或者至少是类似的情况):

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

我同意其他人对货物的看法,但让我证明一个限制:不适合模板.

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

你仍然可以引用模板化的子类,但是因为你必须包含所有的子类,"实现解耦"的每一个优点在编译时(避免在任何地方包含所有平台特定的代码,缩短编译时间)丢失。

对于经典的OOP(基于继承)来说,它是一个很好的范例,但对于泛型编程(基于专门化)来说却不是。

其他人已经提供了技术上的优缺点,但我认为以下值得注意:

首先,不要教条。如果PIMPL适合您的情况,请使用它——不要仅仅因为"它是更好的OO"而使用它,因为它确实"/em>隐藏了实现"等等。引用c++ FAQ:

封装适用于代码,而不是人(源代码)

只是给你一个使用它的开源软件的例子:OpenThreads, OpenSceneGraph使用的线程库。主要思想是从头文件(例如,<Thread.h>)中删除所有平台特定的代码,因为内部状态变量(例如,线程句柄)因平台而异。这样就可以在不知道其他平台特性的情况下根据库编译代码,因为一切都是隐藏的。

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

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

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

我在嵌入式项目(SetTopBox)上使用了PIMPL(和许多其他来自例外c++的习惯用法)。

在我们的项目中,这种习惯用法的特殊目的是隐藏XImpl类使用的类型。具体来说,我们用它来隐藏不同硬件的实现细节,在这些细节中,不同的头文件将被拉入。我们对一个平台和另一个平台有不同的XImpl类实现。无论平台如何,类X的布局保持不变。

我过去经常使用这种方法,但后来发现自己逐渐放弃了。

当然,对类的用户隐藏实现细节是一个好主意。但是,您也可以通过让类的用户使用抽象接口并将实现细节作为具体类来做到这一点。

pImpl的优点是:

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

  2. 如果你有一组类(一个模块),这样几个类访问相同的"impl",但模块的用户将只使用"暴露"的类。

  3. 没有v-table,如果这被认为是不好的事情。

我发现pImpl的缺点(抽象接口工作得更好)

  1. 虽然您可能只有一个"生产"实现,但通过使用抽象接口,您还可以创建一个在单元测试中工作的"模拟"实现。

  2. (最大的问题)。在unique_ptr和move出现之前,关于如何存储pImpl,您的选择是有限的。一个原始指针,你有关于你的类是不可复制的问题。旧的auto_ptr不能与前向声明的类一起工作(至少不是在所有编译器上)。所以人们开始使用shared_ptr,这很好地使你的类可复制,但当然两个副本都有相同的底层shared_ptr,这可能是你意想不到的(修改一个,两个都被修改)。因此,解决方案通常是对内部指针使用原始指针,并使类不可复制,并返回shared_ptr。所以两次调用new。(实际上3给出旧的shared_ptr给你第二个)。

  3. 从技术上讲不是真正的const-correct,因为const不是通过成员指针传播的。

因此,总的来说,这些年我已经从pImpl转向抽象接口使用(以及创建实例的工厂方法)。

下面是我遇到的一个实际场景,在这个场景中,这个习语帮了大忙。我最近决定在游戏引擎中支持DirectX 11,以及我现有的DirectX 9支持。

引擎已经包装了大多数DX功能,所以没有任何DX接口被直接使用;它们只是在头文件中被定义为私有成员。该引擎使用DLL文件作为扩展,添加键盘、鼠标、操纵杆和脚本支持,以及许多其他扩展。虽然大多数dll不直接使用DX,但它们需要DX的知识和链接,因为它们引入了暴露DX的头文件。在添加DX 11时,这种复杂性急剧增加,但这是不必要的。将DX成员移动到仅在源中定义的PIMPL中,消除了这种强加。

在减少库依赖的基础上,当我将私有成员函数移到PIMPL中,只暴露面向前端的接口时,我公开的接口变得更干净了。

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

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

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:我希望我没有误解移动语义。

在很多项目中都有实际应用。它的有用性在很大程度上取决于项目的类型。Qt是使用这种方法的一个比较突出的项目,其基本思想是对用户(其他使用Qt的开发人员)隐藏实现或特定于平台的代码。

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

所以在几乎所有的设计决策中都有利弊。

我想我应该添加一个答案,因为尽管有些作者暗示了这一点,但我认为这一点还不够清楚。

PIMPL的主要目的是解决N*M问题。这个问题在其他文献中可能有其他名称,但这里是一个简短的总结。

你有某种继承层次结构,如果你要向你的层次结构中添加一个新的子类,它将要求你实现N或M个新方法。

这只是一个粗略的解释,因为我最近才意识到这一点,所以我自己承认我还不是这方面的专家。

对已有观点的讨论

然而,我在几年前遇到过这个问题,以及类似的问题,我对所给出的典型答案感到困惑。(大概是几年前我第一次了解PIMPL,并发现了这个问题和其他类似的问题。)

  1. 启用二进制兼容性(当编写库时)
  2. 减少编译时间
  3. <
  4. 隐藏数据/gh>

考虑到上述"优点",在我看来,它们都不是使用PIMPL的特别令人信服的理由。因此,我从未使用过它,我的程序设计因此而受到影响,因为我放弃了PIMPL的实用程序以及它真正可以用来完成的任务。

请允许我逐一解释:

1。二进制兼容性只有在编写库时才有意义。如果你正在编译一个最终的可执行程序,那么这是无关紧要的,除非你正在使用别人的(二进制)库。(换句话说,您没有原始源代码。)

这意味着该优势的范围和效用有限。只有那些编写以专有形式发布的库的人才感兴趣。

2。我个人不认为这与现代有任何关系,因为很少有人在编译时间至关重要的项目上工作。也许这对Google Chrome的开发者来说很重要。相关的缺点可能会显著增加开发时间,可能会抵消这个优点。我可能错了,但我觉得这不太可能,特别是考虑到现代编译器和计算机的速度。

3。我没有立即看到PIMPL带来的优势。通过提供头文件和二进制对象文件也可以实现相同的结果。如果没有一个具体的例子摆在我面前,很难看出为什么PIMPL在这里是相关的。相关的"事情"发送二进制目标文件,而不是原始源代码。

PIMPL的实际作用:

你将不得不原谅我稍微挥手的回答。虽然我不是软件设计这一特定领域的完全专家,但我至少可以告诉您一些关于它的事情。这些信息大多来自Design Patterns。作者称之为"桥梁模式"。

在这本书中,给出了编写一个窗口管理器的例子。这里的关键是窗口管理器可以实现不同类型的窗口以及不同类型的平台。

例如,可以有

<
  • 窗口/gh><
  • 图标窗口/gh>
  • 3d加速全屏窗口
  • 其他一些花哨的窗口
  • 这些是可以渲染的窗口类型

  • Microsoft Windows实现
  • OS X平台实现
  • Linux X窗口管理器
  • Linux韦兰
  • 这些是不同类型的渲染引擎,具有不同的操作系统调用,并且可能具有根本不同的功能

上面的列表类似于另一个回答,其中另一个用户描述了编写应该与不同类型的硬件一起工作的软件,例如DVD播放机。(我忘了具体是什么例子)

我在这里给出的例子与《设计模式》一书中所写的略有不同。

问题的关键在于,有两种不同类型的东西应该使用继承层次来实现,但是在这里使用单一继承层次是不够的。(N*M的问题,复杂性的规模就像每个项目符号列表中事物的平方,这对于开发人员来说是不可行的。)

因此,使用PIMPL可以分离出窗口的类型,并提供指向实现类实例的指针。

所以PIMPL:

  • 解决N*M问题
  • 将使用继承建模的两个根本不同的东西解耦,这样就有两个或更多的层次结构,而不仅仅是一个单体
  • 允许在运行时交换准确的实现行为(通过改变指针)。这在某些情况下可能是有利的,而单个单体强制执行静态(编译时)行为选择,而不是运行时行为选择

可能有其他方法来实现它,例如多重继承,但这通常是一种更复杂和困难的方法,至少在我的经验中是这样的。

最新更新