PIMPL技法在实践中真正被使用吗?

185

我正在阅读Herb Sutter所著的书《Exceptional C++》,在这本书中我学习到了PIMPL惯用语法。基本上,这个想法是为一个类的私有对象创建一个结构,并动态地分配它们,以减少编译时间(同时更好地隐藏私有实现)。

例如:

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

可以改为:

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

同时,在 .cpp 文件中,有如下定义:

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

这听起来很有趣,但我以前从未见过这种方法,无论是在我工作的公司还是在我看到源代码的开源项目中都没有出现过。因此,我想知道这种技术是否真的被实际应用。

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


3
pimpl技巧可以避免虚函数调用。它也更符合C++的风格(某种程度上)。你调用构造函数而不是工厂函数。我根据现有代码库使用两者中的其中一种。pimpl技巧(最初称为Cheshire cat技巧,并且比Herb对其的描述早至少5年)似乎具有更悠久的历史并且在C++中被广泛使用,但除此之外,两者都可以使用。 - James Kanze
38
在C++中,应该使用const unique_ptr<XImpl>而不是XImpl*来实现pimpl。 - Neil G
1
我以前从未见过这种方法,无论是在我工作的公司还是在开源项目中都没有。Qt 几乎从不使用它。 - ManuelSchneid3r
1
https://dev59.com/oXE95IYBdhLWcg3wXcnd - parasrish
2
@NeilG 现在我猜推荐的方式是使用 std::experimental::propagate_const<std::unique_ptr<impl>> pImpl - pratikpc
显示剩余17条评论
12个回答

147
所以,我想知道这种技术在实践中是否真的被使用?我应该到处使用它还是要谨慎使用?
当然会使用。我在我的项目中几乎每个类中都使用它。
PIMPL技巧的原因:
二进制兼容性 当你开发一个库时,可以向XImpl添加/修改字段而不会破坏与客户端的二进制兼容性(这意味着崩溃!)。由于在向Ximpl类添加新字段时,X类的二进制布局不会改变,因此在库的次要版本更新中添加新功能是安全的。
当然,您也可以向X / XImpl添加新的公共/私有非虚拟方法,而不会破坏二进制兼容性,但这与标准头文件/实现技术相当。
数据隐藏 如果您正在开发一个库,特别是专有库,可能不希望披露用于实现库的公共接口的其他库/实现技术。要么是因为知识产权问题,要么是因为您认为用户可能会对实现做出危险的假设或仅通过使用可怕的转换技巧来打破封装。 PIMPL解决/缓解了这个问题。
编译时间 编译时间减少了,因为只有需要重建X的源(实现)文件时,才需要添加/删除字段和/或方法到XImpl类(这映射到标准技术中的添加私有字段/方法)。在实践中,这是常见的操作。
使用标准的头文件/实现技术(没有PIMPL),当您向X添加新字段时,每个分配X的客户端(无论是在堆栈上还是在堆上)都需要重新编译,因为它必须调整分配的大小。好吧,永远不会分配X的每个客户端也需要重新编译,但这只是开销(客户端代码的结果将相同)。此外,使用标准的头文件/实现分离技术后,即使为了封装原因添加了一个私有方法X::foo()并更改了X.h,XClient1.cpp也需要重新编译,尽管XClient1.cpp不可能调用此方法!与上述情况类似,这纯粹是开销,并与实际的C ++构建系统的工作方式有关。
当然,如果您只修改方法的实现(因为您没有触及头文件),则无需重新编译。但这与标准的头文件/实现技术相当。

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

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

10
好的,我会尽力进行翻译,请提供需要翻译的具体上下文。"also, binary compatibility" 的翻译: 同时,二进制兼容性 - Ambroz Bizjak
11
在Qt库中,这种方法也用于智能指针的情况。因此,QString在内部将其内容保持为不可变类。当公共类被“复制”时,私有成员的指针被复制,而不是整个私有类。然后,这些私有类也使用智能指针,因此除了大多数类可以进行垃圾回收外,由于使用指针复制而不是完整类复制,性能也得到了极大的改进。 - Timothy Baldridge
9
使用pimpl惯用语法,Qt可以在同一主版本中保持前向和后向二进制兼容性(在大多数情况下)。在我看来,这无疑是使用它的最重要原因。 - whitequark
头文件中的命名空间冲突几率也较小,因为大多数用于数据成员的包含可以移动到实现定义中。 - Mark Borgerding
1
它还可以用于实现特定于平台的代码,因为您可以保留相同的API。 - mip

59

似乎许多库在API的稳定性方面使用它,至少在某些版本中是这样。

但正如所有的事情一样,在不加注意地到处使用之前,你应该谨慎。在使用之前要先思考,评估它给你带来了什么优势,以及它是否值得你付出的代价。

可能会给您带来以下优势:

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

那些可能对你有用,也可能没有。就像对我来说,我并不关心几分钟的重新编译时间。最终用户通常也不关心,因为他们只需要从一开始编译一次即可。

可能的缺点是(同样也要根据实现和它是否对你产生真正的不利影响):

  • 由于比单纯的实现方式需要更多的分配,所以会增加内存使用
  • 增加维护工作量(您必须编写至少转发函数)
  • 降低性能(编译器可能无法像使用类的单纯实现那样将东西内联)

因此,仔细给所有事情评估一个价值,并为自己进行评估。对我来说,几乎总是证明使用PIMPL惯用语不值得努力。只有一种情况,我个人会使用它(或至少类似的东西):

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


2
它几乎总是用于系统接口,以使接口代码与系统无关。例如,我的File类(在Unix下公开大部分stat返回的信息)在Windows和Unix下使用相同的接口。 - James Kanze
6
即使在这种情况下,我个人也会先坐一会儿,思考是否有必要使用几个#ifdef语句,使得包装器尽可能薄。但每个人的目标不同,重要的是要花时间去思考,而不是盲目地跟随某些东西。 - PlasmaHH

34

我同意其他人对商品的看法,但是我要提出一个限制:与模板不兼容

原因是模板实例化需要可用于实例化位置的完整声明。(这也是为什么你不会看到将模板方法定义在.cpp文件中的主要原因。)

虽然你仍然可以引用泛化子类,但由于必须包含它们,编译时避免在所有平台特定代码中包含所有内容,并缩短编译的优势的"实现解耦"消失了。

这是经典面向对象编程 (基于继承) 的良好范例,但不适用于泛型编程(基于特化)。


5
你需要更加精确:当使用 PIMPL 类作为模板类型参数时,绝对没有问题。只有在实现类本身需要基于外部类的模板参数进行参数化时,即使它是私有类,也不能再将其隐藏在接口头文件中了。如果你可以删除模板参数,你仍然可以做到“正规”的 PIMPL。通过类型删除,你也可以在一个非模板基类中实现 PIMPL,然后让模板类从它派生出来。 - Kuba hasn't forgotten Monica
1
你可以在 .cpp 文件中使用显式模板实例化来拥有大量的模板代码,详情请见 explicit template instanciation ;-) - Caduchon
@Caduchon 当你编写一个模板库时,你不知道用户会进行哪些实例化。 - Emilio Garavaglia
1
@EmilioGaravaglia,但是你经常知道它不能与每种类型一起使用。如果您的代码仅适用于有限且已知的类型列表,则不显示代码是一个好方法。 - Caduchon
我正在开发一个数独库,它可以实现这个功能。模板参数是一个非类型模板参数,用于指定要操作的数独网格的大小。整个库基本上是通过为每个编译大小显式实例化模板来复制的(所有编译大小都需要事先指定)。这是一个非常小众的用例,但我认为值得提及,因为这可能是实际上需要这样做的一个例子。 - starball

25

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

首先,不要教条地看待问题。如果PIMPL适用于你的情况,就使用它 - 不要仅仅因为“它更好的面向对象,因为它真正隐藏了实现”,等等。引用C++ FAQ的话:

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

举个例子,开源软件OpenThreads,这是OpenSceneGraph使用的线程库。主要思想是从头文件(例如<Thread.h>)中移除所有特定于平台的代码,因为内部状态变量(例如线程句柄)在不同平台上有所不同。这样一来,可以在不了解其他平台特殊性的情况下编译代码与你的库进行交互,因为一切都被隐藏起来。


12

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

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


需要额外的间接层才能访问实现方法。是这样吗? - xaxxon
2
@xaxxon 是的,没错。如果方法是低级别的,pimpl 会变慢。例如,永远不要将其用于在紧密循环中运行的内容。 - Erik Aronesty
1
@xaxxon 我会说一般情况下需要额外的层级。如果进行内联,则不需要。但是在编译为不同dll的代码中,内联将不是一个选项。 - Ghita

8

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

在嵌入式项目(机顶盒)中,我使用了 PIMPL(以及 Exceptional C++ 中的许多其他惯用语)。

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


2
这是使用PIMPL的主要原因。它解决了“N*M”问题。我不知道为什么其他答案没有将此列为优点。可能作者不知道这一点。但这是PIMPL的主要目的。其他方面只与编写需要二进制兼容性的库有关。我个人认为,“它加快了编译时间”并不是一个合理的观点,因为它也“减慢了开发时间,并给未来尝试维护您的代码的任何人带来了头痛”。 - FreelanceConsultant

6

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

当然,将实现细节隐藏在类的用户之外是一个好主意。但您也可以通过让类的用户使用抽象接口来实现此目的,而具体实现则是具体类。

pImpl的优点:

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

  2. 如果您有一组类(一个模块),这些类访问相同的“impl”,但模块的用户仅使用“公开”类。

  3. 如果假定这是不好的事情,则没有v表。

pImpl的缺点(其中抽象接口效果更好)

  1. 虽然您可能只有一个“生产”实现,但使用抽象接口,您也可以创建适用于单元测试的“mock”实现。

  2. (最大的问题)。在unique_ptr和moving出现之前,您的选择受到限制,以便存储pImpl。原始指针会导致类不可复制的问题。旧的auto_ptr无法与向前声明的类一起使用(在所有编译器上都是如此)。因此,人们开始使用shared_ptr,这使您的类能够复制,但两个副本都具有相同的底层shared_ptr,这可能是意外的(修改一个,两个都被修改)。因此,解决方案通常是使用内部原始指针使类不可复制,并返回指向它的shared_ptr。因此需要两个new调用。(实际上3个,因为旧的shared_ptr会给您第二个)

  3. 从技术上讲,不太符合const-correct,因为constness未传播到成员指针。

总的来说,我因此已经多年不再使用pImpl,而改用抽象接口(和工厂方法来创建实例)。


4
这是我遇到的一个实际情况,这个习语帮了很多忙。最近我决定在游戏引擎中支持DirectX 11以及我现有的DirectX 9支持。
引擎已经封装了大多数DX功能,因此没有直接使用DX接口;它们只是作为私有成员在头文件中定义。引擎使用DLL文件作为扩展,添加键盘、鼠标、操纵杆和脚本支持,以及许多其他扩展。虽然大多数DLL没有直接使用DX,但它们需要了解和链接到DX,因为它们拉入了暴露DX的头文件。
在添加DX 11时,这种复杂性将大大增加,但是并不必要。将DX成员移动到PIMPL中,在源代码中定义,消除了这种强制性。
除了减少库依赖之外,当我将私有成员函数移动到PIMPL中,仅公开前置接口时,我的公开接口变得更加清晰。

3
正如其他人所说,Pimpl惯用语允许实现完全的信息隐藏和编译独立性,但不幸的是需要付出性能损失(额外的指针间接)和额外的内存需求(成员指针本身)。在嵌入式软件开发中,这些额外成本可能非常关键,特别是在那些必须尽可能节约内存的场景中。 使用C++抽象类作为接口将以相同的代价带来相同的好处。 这实际上显示了C++的一个巨大缺陷,在没有使用类似于C的接口(具有不透明指针作为参数的全局方法)的情况下,无法实现真正的信息隐藏和编译独立性,而不会带来额外的资源缺陷:这主要是因为类的声明,必须被其用户包含,导出的不仅是用户需要的类接口(公共方法),还包括其内部细节(私有成员),这些对用户来说并不需要。

2

我认为这种方式的一个好处是它可以让程序员快速地实现某些操作:

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);
}

顺便说一下,我希望自己没有误解移动语义。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接