为什么应该使用“PIMPL”习惯用语?

151

背景:

PIMPL(指向实现的指针)是一种实现隐藏技术,其中一个公共类包装了一个在公共类所属的库之外看不到的结构或类。

这样可以隐藏用户库的内部实现细节和数据。

在实现此惯用法时,为什么要将公共方法放在 pimpl 类上而不是公共类上,因为公共类的方法实现会编译到库中,用户只有头文件?

为了说明,这段代码将 Purr() 实现放在 impl 类上并将其包装。

为什么不直接在公共类上实现 Purr ?

// header file:
class Cat {
    private:
        class CatImpl;  // Not defined here
        CatImpl *cat_;  // Handle

    public:
        Cat();            // Constructor
        ~Cat();           // Destructor
        // Other operations...
        Purr();
};


// CPP file:
#include "cat.h"

class Cat::CatImpl {
    Purr();
...     // The actual implementation can be anything
};

Cat::Cat() {
    cat_ = new CatImpl;
}

Cat::~Cat() {
    delete cat_;
}

Cat::Purr(){ cat_->Purr(); }
CatImpl::Purr(){
   printf("purrrrrr");
}

35
为什么应该避免使用“PIMP”这个习语? - mlvljr
4
非常好的回答,我发现这个链接也包含了全面的信息:http://marcmutz.wordpress.com/translated-articles/pimp-my-pimpl/ - zhanxw
17
如果你想为接替你的维护编码者提供帮助,请记住这是一个“接口”模式,不要将其用于每个内部类。引用《银翼杀手》中的一句话,“我见过你们无法想象的东西”。 - DevSolar
2
小心,PIMPL 在更大的项目中可以有很多好处,但可能会使本来简单的小程序变得非常复杂。在这个问题的某个地方,列出了在项目中使用 PIMPL 的最小“先决条件”清单。并非每个人都应该遵循相同的清单,应该自己制定清单并坚持遵守。在我看来,这可能是最好的方式。 - osirisgothra
8
我个人的经验是,使用Pimpl的人更倾向于制作大型未记录的框架,然后离开公司,这样他们以前的同事就必须处理那些变得特别难以分析的类。 - Trantor
显示剩余3条评论
11个回答

75
我认为大多数人将此称为"Handle Body"习语。请参阅James Coplien的书《高级C++编程风格和习惯》。它也被称为Cheshire Cat,因为Lewis Caroll's 的角色会逐渐消失,只剩下咧嘴笑容。
示例代码应分布在两组源文件中。然后只有Cat.h是与产品一起发布的文件。 CatImpl.hCat.cpp包含,而CatImpl.cpp包含CatImpl::Purr()的实现。这对使用您的产品的公众不可见。
基本思路是尽可能隐藏实现细节,以免被他人窥探。
这在您有一个商业产品作为一系列库进行发布,并通过客户端的API进行访问和链接时最为有用。
我们在2000年使用此方法重写了IONA的 Orbix 3.3产品。
如其他人所述,使用他的技术完全解耦了对象的实现和接口。这样,如果您只想更改Purr()的实现,就不必重新编译使用Cat的所有内容。
这种技术是一种称为契约式设计的方法论中使用的。

1
@Rob,我猜这个几乎没有额外的开销。只需要多一个类,但它们非常简单。只是对现有类进行了轻微的包装。如果我错了,请有人纠正我,但是内存使用量只是在RAM中多了一个函数表、一个指向pimpl的指针和每个codespace方法的重定向函数。但维护和调试会比较困难。 - JeffV
4
在设计契约中如何使用pimpl习语(或者您所称的句柄-体习语)? - andreas buykx
嗨,安德烈亚斯,你与API用户的接口点仅在公开的合同(处理)中,而不在提供所宣传的功能的实现方式上。只要您不更改已经宣传的API的语义,您可以自由更改实现方式。 - Rob Wells
@RobWells 我踩了这个答案的赞,因为它也是错误的(虽然不像被接受的答案那么错;这个答案可能是可以修复的)。问题在于:a)“基本上,想法是尽可能地隐藏实现细节,以防止他人窥探。”这与简单的.h/.cpp类声明/定义分离和将库作为.h/(.a|.lib)进行分发有何不同?显然,它也会将实现细节从客户端隐藏起来。OP在问题中明确提到了这一点! - cubuspl42
1
FFR:基本上是指在 https://dev59.com/v2ox5IYBdhLWcg3wyHPc 中接受的答案。 - cubuspl42
显示剩余2条评论

42
  • 之所以需要使用friend声明,是因为你希望Purr()方法能够访问CatImpl类的私有成员。如果没有friend声明,Cat::Purr()将无法进行这样的访问。
  • 因为这样可以清晰地区分职责:一个类实现,一个类转发。

7
尽管维护很麻烦,但如果这是一个库类,方法本来就不应该经常改变。我看的代码似乎都在走保险路线,到处使用pimpl。 - JeffV
1
这行代码不合法,因为所有成员都是私有的:cat_->Purr(); Purr() 是私有的,因此无法从外部访问。我错过了什么? - binaryguy
2
这两个观点都没有任何意义。如果你只有一个类 - Cat,它也能够访问其成员,并且不会“混合职责”,因为它是“实现”类。使用PIMPL的原因是不同的。 - mip
2
这个答案是完全错误的,正如@doc所提到的那样。仅仅为了使用它的私有成员而引入一个新类是荒谬的,而且仅仅转发一些东西并不是一种“责任”! - cubuspl42

24

就其价值而言,它将实现与接口分离。在小型项目中通常不是很重要,但在大型项目和库中,可以用于显著减少构建时间。

考虑到 Cat 的实现可能包含许多头文件,可能涉及需要自行编译的模板元编程。为什么用户只想使用 Cat 的人必须包含所有这些内容呢?因此,使用 pimpl 惯用语隐藏了所有必要的文件(因此前向声明了 CatImpl),并且使用接口不会强制用户包含它们。

我正在开发一个非线性优化库(读作“很多恶心的数学”),它是用模板实现的,因此大部分代码都在头文件中。它需要约五分钟的编译时间(在一个体面的多核 CPU 上),仅解析空的 .cpp 中的头文件就需要大约一分钟。因此,任何使用该库的人每次编译代码时都必须等待几分钟,这使得开发相当繁琐。然而,通过隐藏实现和头文件,只需包含一个简单的接口文件,即可立即编译。

这并不一定与保护实现不被其他公司复制有关 - 除非从成员变量的定义中可以猜测出算法的内部工作方式(如果是这样,那么它可能并不是非常复杂,也没有必要首先进行保护)。


15

如果你的类使用了PIMPL习惯用法,你可以避免修改公共类的头文件。

这样就能添加/删除PIMPL类的方法,而不需要修改外部类的头文件。你也可以向 PIMPL 添加/移除 #includes。

当你修改外部类的头文件时,必须重新编译所有包含它的代码(如果其中任何一个是头文件,则必须重新编译所有包含它们的代码,以此类推)。


7
通常,在所有者类(在此示例中为Cat)的头文件中,对于PIMPL类的唯一引用将是前向声明,就像您在这里所做的那样,因为这可以极大地减少依赖关系。
例如,如果您的PIMPL类具有ComplicatedClass作为成员(而不仅仅是指针或引用),则需要在使用它之前完全定义ComplicatedClass。在实践中,这意味着包括文件"ComplicatedClass.h"(这也会间接包含任何ComplicatedClass依赖的内容)。这可能导致单个头文件拉入大量的内容,这对于管理依赖关系(以及编译时间)是不好的。
当使用PIMPL习语时,您只需要#include在您的所有者类型(在这里是Cat)的公共接口中使用的内容。这使得使用您的库的人更容易,也意味着您不必担心人们依赖于您的库的某个内部部分-无论是出于错误还是因为他们想要执行您不允许的操作,所以他们#define private public,然后再包含您的文件。
如果是一个简单的类,通常没有使用PIMPL的原因,但是对于大型类型,它可能会有很大帮助(特别是避免长时间的构建时间)。

4

嗯,我不会使用它。我有一个更好的替代方案:

文件foo.h

class Foo {
public:
    virtual ~Foo() { }
    virtual void someMethod() = 0;

    // This "replaces" the constructor
    static Foo *create();
}

文件 foo.cpp

namespace {
    class FooImpl: virtual public Foo {

    public:
        void someMethod() {
            //....
        }
    };
}

Foo *Foo::create() {
    return new FooImpl;
}

这种模式有名称吗?

作为一名同时精通Python和Java的程序员,我更喜欢这种模式,而不是PIMPL惯用语。


4
如果您已经将自己限制在工厂方法创建对象的范围内,那么这是可以接受的。但是这完全消除了值语义,而传统的pImpl则可与任一方法配合使用。 - Dennis Zickefoose
2
pImpl基本上只是封装了指针。你需要做的就是让上面的create()返回一个PointerWrapperWithCopySemantics<Foo> :-)。我通常会相反地返回一个std::auto_ptr<Foo>。 - Esben Nielsen
14
为什么选择继承而不是组合?为什么要增加虚函数表的开销?为什么使用虚继承? - derpface
2
这不适用于模板方法。 - smac89

3

我们使用PIMPL惯用语法来模拟面向方面编程,其中pre、post和error方面在成员函数执行之前和之后被调用。

struct Omg{
   void purr(){ cout<< "purr\n"; }
};

struct Lol{
  Omg* omg;
  /*...*/
  void purr(){ try{ pre(); omg-> purr(); post(); }catch(...){ error(); } }
};

我们还使用指向基类的指针来在许多类之间共享不同的方面。
这种方法的缺点是库用户必须考虑所有要执行的方面,但只能看到自己的类。它需要浏览文档以查找任何副作用。

抛开编译速度的争议,依我之见,PIMPL提供的最大好处就是这个。 - John Z. Li

3
在.cpp文件中调用impl->Purr的意思是,将来你可以完全不改变头文件就能够实现完全不同的功能。也许明年他们会发现一个可用的帮助方法,直接调用它而不使用impl->Purr。这样做可以达到相同的效果,但如果更新实际的impl::Purr方法,则会多出一个函数调用,没有任何作用,只是依次调用下一个函数。同时,这也意味着头文件只包含定义,不包含任何实现,这使得分离更加清晰,这也是这种惯用法的整个目的所在。

1

我在过去的几天里实现了我的第一个PIMPL类。我用它来解决我遇到的问题,包括Borland Builder中的文件*winsock2.*h。它似乎搞乱了结构体对齐方式,由于我在类私有数据中有套接字内容,这些问题会传播到任何包含该头文件的.cpp文件中。

通过使用PIMPL,winsock2.h仅在一个.cpp文件中被包含,我可以控制问题,不必担心它会回来咬我。

回答最初的问题,我发现将调用转发到PIMPL类的优点是,PIMPL类与您的原始类相同,在您进行pimpl之前,您的实现不会以某种奇怪的方式分散在两个类中。实现公共成员只需简单地转发到PIMPL类更加清晰。

就像Mr Nodet said所说的那样,一个类,一个职责。


0

我不知道这是否值得一提,但...

是否可以将实现放在它自己的命名空间中,并为用户所见的代码添加一个公共包装器/库命名空间:

catlib::Cat::Purr(){ cat_->Purr(); }
cat::Cat::Purr(){
   printf("purrrrrr");
}

这样所有的库代码都可以使用cat命名空间,当需要将一个类暴露给用户时,可以在catlib命名空间中创建一个包装器。


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