为什么要用“ PIMPL”这个成语?

背景资料:

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");
}
116594 次浏览

将对 impl-> Purr 的调用放在。Cpp 文件意味着将来您可以执行完全不同的操作,而不必更改头文件。

也许明年他们会发现一个可以调用的 helper 方法,这样他们就可以改变代码直接调用这个方法,而不用 impl-> Purr。(是的,它们也可以通过更新实际的 impl: : Purr 方法来实现同样的功能,但是在这种情况下,您将陷入一个额外的函数调用,这个函数调用除了依次调用下一个函数之外什么也没有实现。)

它还意味着头部只有定义,没有任何实现,这使得分离更加清晰,这就是习语的全部意义。

我认为大多数人把这称为 处理身体成语。参见 James Coplien 的书 高级 C + + 编程风格和习语。它也被称为 柴郡猫,因为 刘易斯 · 卡罗尔的字符淡出,直到只剩下咧嘴笑。

示例代码应该分布在两组源文件中。那么只有 凯西是随产品一起提供的文件。

H 包含在 Cat.cpp中,而 CatImpl.cpp包含 : Purr ()的实现。这对于使用您的产品的公众来说是不可见的。

基本上,这个想法是为了尽可能地隐藏实现,以免被窥探。

如果您有一个商业产品,该产品作为一系列库交付,通过编译客户代码并与之链接的 API 访问,那么这种方法非常有用。

我们在2000年通过重写 IONA 的 奥比克斯3.3产品做到了这一点。

正如其他人所提到的,使用他的技术可以将实现与对象的接口完全解耦。如果您只是想更改 咕噜()的实现,那么您就不必重新编译使用 凯特的所有内容。

这种技术在一种称为 合同外观设计的方法中使用。

通常,对于 主人类(这里是 凯特)的头部中的 PIMPL 类的唯一引用将是一个前向声明,就像您在这里所做的那样,因为这可以大大减少依赖性。

例如,如果您的 PIMPL 类将 复杂课程作为成员(而不仅仅是指向它的指针或引用) ,那么您需要在使用它之前完全定义 复杂课程。实际上,这意味着包含文件“ CompleplicatedClass.h”(它也将间接包含 复杂课程所依赖的任何内容)。这可能导致一个头填充拉入很多很多的东西,这对管理依赖关系(和编译时间)是不利的。

当您使用 PIMPL 习惯用法时,您只需要 # 包含 主人类型的公共接口中使用的内容(这里是 凯特)。这对使用你的库的人来说更好,并且意味着你不需要担心人们依赖于你库的某些内部部分——要么是出于错误,要么是因为他们想做一些你不允许的事情,所以他们 # 在包含你的文件之前定义了私有公共。

如果它是一个简单的类,通常没有任何理由使用 PIMPL,但是对于类型很大的时候,它可以是一个很大的帮助(特别是在避免长时间构建时)。

  • 因为您希望 Purr()能够使用 CatImpl的私有成员。如果没有 friend声明,Cat::Purr()将不被允许进行这样的访问。
  • 因为这样就不会混淆责任: 一个类实现,一个类转发。

如果您的类使用 PIMPL 习惯用法,则可以避免更改公共类上的头文件。

这允许您向 PIMPL 类添加/删除方法,而无需修改外部类的头文件。您也可以向 PIMPL 添加/移除 # include。

当您更改外部类的头文件时,必须重新编译所有 # 包含它的内容(如果其中任何一个是头文件,则必须重新编译所有 # 包含它的内容,以此类推)。

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

是否有可能将实现放在自己的名称空间中,并为用户看到的代码提供一个公共包装器/库名称空间:

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

这样,所有库代码都可以利用 cat 命名空间,并且由于需要向用户公开一个类,可以在 catlib 命名空间中创建一个包装器。

我发现,尽管 PIMPL 习语非常有名,但是在现实生活中(例如,在开源项目中)我并不经常见到它的出现。

我经常想知道这些“好处”是否被夸大了; 是的,你可以使你的一些实现细节更加隐藏,是的,你可以在不改变标题的情况下改变你的实现,但是这些在现实中并不明显是很大的好处。

也就是说,我们并不清楚是否需要将实现隐藏得很好,也许很少有人真的只修改实现; 一旦需要添加新方法,比如说,无论如何都需要修改头文件。

在过去的几天里,我刚刚实现了我的第一个 PIMPL 类。我用它来消除我遇到的问题,包括 file * winsock2。* h in Borland Builder.它似乎搞砸了结构对齐,而且因为我在类私有数据中有套接字,所以这些问题会蔓延到任何人。包含头的 cpp 文件。

通过使用 PIMPL,Winsock2.h只包含在一个。Cpp 文件,我可以把盖子上的问题,而不用担心它会回来咬我。

为了回答最初的问题,我发现将调用转发到 PIMPL 类的优势在于,PIMPL 类与原来的类在你插入它之前是一样的,而且你的实现不会以某种奇怪的方式分布在两个类上。实现公共成员以简单地转发到 PIMPL 类要清楚得多。

就像 Nodet 先生说,一个类,一个责任。

我不会用的,我有更好的选择:

文件

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 习语。

值得一提的是,它将实现与接口分离开来。这在小型项目中通常不是很重要。但是,在大型项目和库中,可以使用它来显著减少构建时间。

考虑到 Cat的实现可能包括许多头,可能涉及模板元编程,这需要时间来编译自己。为什么只想使用 Cat的用户必须包含所有这些内容?因此,所有必要的文件都使用 pimpl 成语隐藏起来(这就是 CatImpl的前向声明) ,并且使用该界面并不强迫用户包含它们。

我正在开发一个用于非线性优化的库(请阅读“大量令人讨厌的数学”) ,它是在模板中实现的,因此大部分代码都在标题中。编译大约需要5分钟(在一个像样的多核 CPU 上) ,而仅仅在一个空的 .cpp中解析标头就需要大约1分钟。所以任何使用这个库的人在编译他们的代码时都必须等待几分钟,这使得这个开发非常 乏味。但是,通过隐藏实现和头文件,只需包含一个简单的接口文件,即可进行编译。

它并不一定与保护实现免受其他公司复制有任何关系——这种情况不太可能发生,除非你的算法的内部工作可以从成员变量的定义中猜测出来(如果是这样的话,它可能不是很复杂,也不值得首先保护)。

我们使用 PIMPL 习惯用法来模拟 面向侧面的程序设计,其中在执行成员函数之前和之后调用 pre、 post 和 error 方面。

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


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

我们还使用指针到基类来在许多类之间共享不同的方面。

这种方法的缺点是库用户必须考虑将要执行的所有方面,但只能看到他/她的类。它需要浏览任何副作用的文档。