174

我正在阅读 Herb Sutter 的“Exceptional C++”一书,在那本书中我了解了 PIMPL 习语。基本上,这个想法是为 a 的private对象创建一个结构class并动态分配它们以减少编译时间(并以更好的方式隐藏私有实现)。

例如:

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

可以改为:

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

并且,在 .cpp 文件中,定义:

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

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

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

4

12 回答 12

141

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

当然是用的。我在我的项目中使用它,几乎在每一堂课中。


使用 PIMPL 成语的原因:

二进制兼容性

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

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

数据隐藏

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

编译时间

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

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

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

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


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

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

于 2012-01-23T13:56:37.790 回答
52

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

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

可能给您带来的好处是:

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

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

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

  • 由于分配比 naïve 变体更多,内存使用量增加
  • 增加维护工作(您必须至少编写转发功能)
  • 性能损失(编译器可能无法像您的类的幼稚实现那样内联内容)

所以小心地给每件事一个价值,并为自己评估它。对我来说,几乎总是证明使用 PIMPL 成语是不值得的。只有一种情况我个人使用它(或至少类似的东西):

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

于 2012-01-23T14:04:33.840 回答
33

我同意所有其他关于商品的观点,但让我证明一个限制:不适用于模板

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

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

它是经典OOP(基于继承)的良好范例,但不适用于泛型编程(基于专业化)。

于 2012-01-23T15:21:02.457 回答
25

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

首先,不要教条主义。如果 PIMPL 适用于您的情况,请使用它 - 不要仅仅因为“它是更好的 OO,因为它确实隐藏了实现”等而使用它。引用 C++ 常见问题解答:

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

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

于 2012-01-23T14:08:33.867 回答
12

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

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

于 2012-01-23T14:04:29.160 回答
6

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

我在嵌入式项目(SetTopBox)上使用了 PIMPL(以及来自 Exceptional C++ 的许多其他成语)。

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

于 2012-01-23T13:59:48.070 回答
5

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

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

pImpl 的优点是:

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

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

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

我发现 pImpl 的缺点(抽象接口效果更好)

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

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

  3. 技术上不是真正的 const 正确,因为 const 不会传播到成员指针。

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

于 2015-08-10T16:16:27.687 回答
3

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

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

除了减少库依赖之外,当我将私有成员函数移到 PIMPL 中时,我暴露的接口变得更清晰,只暴露了前端接口。

于 2016-11-03T19:46:59.903 回答
3

正如许多其他人所说,Pimpl 习惯用法允许达到完全的信息隐藏和编译独立性,不幸的是,以性能损失(额外的指针间接)和额外的内存需求(成员指针本身)为代价。额外成本在嵌入式软件开发中可能至关重要,尤其是在那些必须尽可能节省内存的情况下。使用 C++ 抽象类作为接口将以相同的成本带来相同的好处。这实际上表明了 C++ 的一个很大的缺陷,在没有递归到类 C 接口(以不透明指针作为参数的全局方法)的情况下,不可能在没有额外资源缺陷的情况下实现真正的信息隐藏和编译独立性:这主要是因为一个类的声明,它的用户必须包含它,

于 2015-09-04T11:13:58.230 回答
2

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

这是一个崇高的想法,但它有一个真正的缺点:调试,因为它只是一个指向隐藏实现的愚蠢指针,即使他/她拥有实现源代码。

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

于 2012-01-23T14:08:16.153 回答
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:我希望我没有误解移动语义。

于 2016-09-21T01:45:42.830 回答
0

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

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

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

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

现有观点的讨论

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

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

考虑到上述“优势”,我认为它们都不是使用 PIMPL 的特别令人信服的理由。因此我从来没有使用过它,我的程序设计也因此受到影响,因为我放弃了 PIMPL 的实用性以及它真正可以用来完成的事情。

请允许我对每个评论进行解释:

1.

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

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

2.

我个人认为这与现代无关,因为在编译时间至关重要的项目上工作很少见。也许这对 Google Chrome 的开发者很重要。可能会显着增加开发时间的相关缺点可能会抵消这一优势。我对此可能是错的,但我发现这不太可能,尤其是考虑到现代编译器和计算机的速度。

3.

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

PIMPL 的实际作用:

您将不得不原谅我略微挥手的回答。虽然我不是软件设计这一特定领域的完整专家,但我至少可以告诉你一些关于它的事情。这些信息大多是从设计模式中重复的。作者称其为“桥梁模式”,又名句柄,又名身体。

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

例如,一个人可能有一个

  • 窗户
  • 图标窗口
  • 具有 3d 加速功能的全屏窗口
  • 其他一些花哨的窗口
  • 这些是可以渲染的窗口类型

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

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

与《设计模式》一书中所写的内容相比,我在这里给出的示例略有不同。

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

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

所以PIMPL:

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

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

于 2021-12-28T18:14:19.733 回答