PIMPL 习语真的在实践中使用吗?

我正在读 Herb Sutter 的书 “异常 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;
};

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

我应该到处使用它,还是小心使用?这种技术是否被推荐用于嵌入式系统(其性能非常重要) ?

35417 次浏览

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

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


使用 PIMPL 成语的理由:

二进制兼容性

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

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

数据隐藏

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

编译时间

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

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

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

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


是否推荐在嵌入式系统中使用这种技术(其中性能非常重要) ?

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

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

我在嵌入式项目(SetTopBox)中使用 PIMPL (以及来自 Exception C + + 的许多其他习惯用法)。

在我们的项目中,这个习惯用法的特殊用途是隐藏 XImpl 类使用的类型。 具体来说,我们使用它来隐藏不同硬件的实现细节,在这些细节中会引入不同的报头。我们对于一个平台有不同的 XImpl 类实现,对于另一个平台则有不同的实现。不管平台如何,类 X 的布局保持不变。

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

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

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

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

它给你的优势是:

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

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

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

  • 内存使用量增加,原因是分配的数量多于初始变量
  • 增加维护工作(至少要编写转发函数)
  • 性能损失(编译器可能不能内联东西,因为它是一个幼稚的实现您的类)

所以仔细地给每件事赋予一个价值,然后自己去评估它。对我来说,使用 PIMPL 惯用语几乎总是不值得付出努力。只有一种情况下我会亲自使用它(或者至少是类似的东西) :

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

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

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

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

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

首先,不要武断。如果 PIMPL 适合你的情况,那就使用它——不要仅仅因为“它更适合面向对象,因为它的 真的隐藏了实现”就使用它,等等。引用 C + + 常见问题解答:

封装是为了代码,而不是为了人(来源)

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

我同意所有其他人关于货物的看法,但是让我提出一个限额: 模板不能很好地工作

原因是模板实例化需要在实例化发生的地方可用的完整声明。(这就是没有定义模板方法的主要原因。Cpp 文件)

您仍然可以引用模板化的子类,但是因为您必须包含它们所有的子类,所以在编译时“实现解耦”的所有优势(避免到处包含所有平台特定的代码,缩短编译)都丧失了。

对于经典的 (基于继承)来说,这是一个很好的范例,但对于泛型(基于专门化)来说就不是了。

我过去经常使用这种方法,但后来发现自己正在远离它。

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

PImpl 的优点是:

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

  2. 如果您有一套类(一个模块) ,使得多个类访问相同的“ impl”,但是模块的用户将只使用“公开的”类。

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

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

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

  2. (最大的问题)。在 only _ ptr 和 move 出现之前,对于如何存储 pImpl,您的选择非常有限。一个原始指针,并且您的类是不可复制的。旧的 auto _ ptr 不适用于向前声明的类(至少不适用于所有编译器)。因此,人们开始使用 share _ ptr,这在使类具有可复制性方面很不错,但是当然,两个副本具有相同的底层 share _ ptr,这可能是您不希望看到的(修改一个副本,两个副本都被修改)。因此,解决方案通常是对内部类使用原始指针,并使类不可复制,然后返回一个 share _ ptr。打了两个电话给 New。(实际上3给出了旧的 share _ ptr 给出了第二个)。

  3. 从技术上讲,并不是真正的常量正确,因为常量并没有传播到成员指针。

总的来说,这些年我已经不再使用 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);
}

附言: 我希望我没有误解移动的语义。

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

引擎已经包装了大多数 DX 特性,因此没有直接使用任何 DX 接口; 它们只是在头中定义为私有成员。该引擎使用 DLL 文件作为扩展,添加键盘、鼠标、操纵杆和脚本支持,与其他许多扩展一样。虽然大多数 DLL 并不直接使用 DX,但是它们需要知识和与 DX 的联系,因为它们引入了暴露 DX 的头文件。 在添加 DX 11,这种复杂性是显着增加,但不必要的。将 DX 成员国迁移到一个只有源头定义的 PIMPL,就消除了这种强制性。

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

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

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

您有某种继承层次结构,如果要在层次结构中添加一个新的子类,就需要实现 N 个或 M 个新方法。

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

对现有观点的讨论

然而,几年前我遇到了这个问题,和类似的问题,我被给出的典型答案弄糊涂了。(据推测,我第一次了解 PIMPL 是在几年前,发现了这个问题和其他类似的问题。)

  1. 启用二进制兼容性(在编写库时)
  2. 减少编译时间
  3. 隐藏数据

考虑到上述“优势”,在我看来,它们都不是使用 PIMPL 的特别有说服力的理由。因此,我从来没有使用过它,我的程序设计也因此受到了影响,因为我放弃了 PIMPL 的实用性以及它真正可以用来实现的功能。

请允许我就每一个问题发表评论,以便解释:

1.

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

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

2.

我个人并不认为这在当今社会有任何意义,因为在编译时间至关重要的项目中工作是很少见的。也许这对谷歌 Chrome 的开发者来说很重要。相关的缺点可能会显著地增加开发时间,这些缺点可能大大抵消了这一优点。关于这一点,我可能是错的,但我发现这不太可能,特别是考虑到现代编译器和计算机的速度。

3.

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

PIMPL 实际上在做什么:

请原谅我的回答。虽然我不是软件设计这个特定领域的专家,但我至少可以告诉你一些关于它的事情。这个信息大部分是从 设计模式重复的。作者称之为“桥梁模式”又名手柄又名身体。

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

例如,一个可能有一个

  • 窗户
  • 图标窗口
  • 三维加速全屏窗口
  • 另一扇漂亮的窗户
  • 这些是可以呈现的窗口类型

还有

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

上面的列表类似于另一个答案中给出的,另一个用户描述写软件,应该与不同类型的硬件一起工作,比如 DVD 播放器。(我完全忘记了这个例子是什么。)

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

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

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

所以 PIMPL:

  • 解决了 N * M 问题
  • 将使用继承建模的两个根本不同的事物分离开来,这样就有了2个或更多的层次结构,而不仅仅是一个整体
  • 允许运行时交换确切的实现行为(通过更改指针)。这在某些情况下可能是有利的,而单一的整体实施静态(编译时)行为选择,而不是运行时行为选择

也许还有其他方法来实现这一点,例如多重继承,但这通常是一个更复杂和困难的方法,至少在我的经验。