Pimpl成语在实践中

时间:2009-05-09 14:10:56

标签: c++ pimpl-idiom

关于 pimpl成语的问题有几个问题,但我更加好奇它在实践中的使用频率。

我知道在性能和封装之间存在一些权衡,加上一些调试烦恼,因为额外的重定向。

有了这个,是应该采用每个类,还是全有或全无的方式?这是最佳做法还是个人偏好?

我意识到这有点主观,所以让我列出我的首要任务:

  • 代码清晰度
  • 代码可维护性
  • 性能

我总是假设我需要在某些时候将我的代码公开为库,所以这也是一个考虑因素。

编辑:任何其他完成相同操作的选项都是值得欢迎的建议。

8 个答案:

答案 0 :(得分:35)

我会说,无论你是按班级还是以全有或全无的方式进行,都取决于你为什么要首先选择pimpl成语。建立图书馆时,我的理由是:

  • 希望隐藏实施以避免泄露信息(是的,这不是FOSS项目:)。
  • 希望隐藏实现,以减少客户端代码的依赖。如果您构建共享库(DLL),则无需重新编译应用程序即可更改pimpl类。
  • 希望减少使用库编译类所需的时间。
  • 想要修复命名空间冲突(或类似)。

这些原因都没有提示采用全有或全无的方法。在第一个中,你只是隐藏你想要隐藏的内容,而在第二种情况下,对于你期望改变的类来说,它可能就足够了。同样出于第三和第四个原因,只有隐藏非平凡成员才有好处,而这些成员又需要额外的标题(例如,第三方库,甚至是STL)。

无论如何,我的观点是我通常不会发现这样的东西太有用了:

class Point {
  public:      
    Point(double x, double y);
    Point(const Point& src);
    ~Point();
    Point& operator= (const Point& rhs);

    void setX(double x);
    void setY(double y);
    double getX() const;
    double getY() const;

  private:
    class PointImpl;
    PointImpl* pimpl;
}

在这种情况下,权衡开始打击你,因为指针需要被解引用,并且方法不能内联。但是,如果你只为非平凡的类做,那么通常可以容忍轻微的开销而没有任何问题。

答案 1 :(得分:22)

pimpl ideom的最大用途之一是创建稳定的C ++ ABI。几乎每个 Qt类使用“D”指针,这是一种pimpl。这样可以在不破坏ABI的情况下执行更容易的更改。

答案 2 :(得分:11)

代码清晰度

代码清晰度非常主观,但在我看来,具有单个数据成员的标头比具有许多数据成员的标头更具可读性。然而,实现文件更嘈杂,因此清晰度降低。如果类是基类,那么这可能不是问题,主要由派生类使用而不是维护。

可维护性

对于pimpl'd类的可维护性,我个人发现在数据成员的每次访问中都有额外的取消引用。如果数据纯粹是私有的,那么访问者就无法帮助,因为无论如何你都不应该为它提供一个访问器或更改器,而且你总是不得不解除对它的解除引用。

对于派生类的可维护性,我发现在所有情况下,成语都是纯粹的胜利,因为头文件列出了更少的无关细节。所有客户端编译单元的编译时间也得到了改进。

效果

在许多情况下,性能损失很小,而且很少见。从长远来看,它是虚拟功能性能损失的数量级。我们谈论的是每个数据成员每次访问的额外解除引用,以及pimpl的动态内存分配,以及在销毁时释放内存。如果pimpl'd类经常不访问其数据成员,则pimpl'd类的对象经常被创建并且是短暂的,然后动态分配可以超出额外的解除引用。

决策

我认为性能至关重要的类,这样一个额外的解引用或内存分配会产生重大影响,不管怎么说都不应该使用pimpl。如果编译时间得到显着改善,那么性能降低无关紧要并且头文件广泛#include'd的基础classe可能应该使用pimpl。如果编译时间没有减少,那就是你的代码清晰度。

对于所有其他情况,这纯粹是一种品味问题。在做出决定之前,请尝试并测量运行时性能和编译时性能。

答案 3 :(得分:7)

当您使用强异常保证来实现std :: swap和operator =时,pImpl非常有用。我倾向于说,如果你的班级支持其中任何一个,并且有不止一个非平凡的领域,那么它通常不再是偏好。

否则,它是关于您希望客户端通过头文件绑定到实现的紧密程度。如果二进制不兼容的更改不是问题,那么您可能无法在可维护性方面获得太多好处,尽管如果编译速度成为问题,那么通常可以节省成本。

性能成本可能与内联的丢失有关,而不是间接性,但这是一个疯狂的猜测。

您可以随时添加pImpl,并声明从今天开始,客户端不必仅因为添加了私有字段而重新编译。

所以这些都没有暗示一种全有或全无的方法。你可以有选择地为那些给你带来好处的课程做这件事,而不是为那些没有给你带来好处的课程,并在以后改变你的想法。像pImpl一样实现迭代器听起来像Too Much Design ......

答案 4 :(得分:4)

这个习语很有助于大型项目的编译时间。

External link

This is good too

答案 5 :(得分:3)

当我想避免头文件污染我的代码库时,我通常会使用它。 Windows.h就是一个很好的例子。它的表现非常糟糕,我宁可自杀而不是随处可见。因此,假设您需要基于类的API,将其隐藏在pimpl类后面可以很好地解决问题。 (如果你满足于只展示单个函数,那么这些函数可以直接声明,当然,不会将它们放入pimpl类中)

我不会在任何地方使用pimpl ,部分原因是性能受到影响,部分原因仅仅是因为它通常会带来很多额外的工作。它给你的主要是实现和接口之间的隔离。通常,这不是一个非常高的优先级。

答案 6 :(得分:2)

我在自己的库中的几个地方使用这个习惯用法,在这两种情况下都是为了干净地将界面从实现中分离出来。例如,我有一个完全在.h文件中声明的XML阅读器类,它有一个PIMPL到RealXMLReader类,它被声明为&在非公共.h和.cpp文件中定义。反过来,RealXMlReader是我使用的XML解析器(目前为Expat)的便捷包装器。

这种安排允许我将来从Expat更改为另一个XML解析器,而无需重新编译所有客户端代码(当然我还需要重新链接)。

请注意,出于编译时性能原因,我不这样做,仅用于方便性。有一些PIMPL fabnatics坚持认为任何包含三个以上文件的项目都是不可编译的,除非您在整个过程中使用PIMPL。值得注意的是,这些人从未提供任何实际证据,只是对“Latkos”和“指数时间”的模糊引用。

答案 7 :(得分:2)

当我们有r值语义时,pImpl最有效。

pImpl的“替代”,也将实现隐藏实现细节,是使用抽象基类并将实现放在派生类中。用户调用某种“工厂”方法来创建实例,并且通常会使用指针(可能是共享的)来抽象类。

pImpl背后的基本原理可能是:

  • 保存在v-table上。是的,但是你的编译器会内联所有的转发,你真的会保存任何东西。
  • 如果您的模块包含多个相互了解的类,尽管对外界你隐藏了它。

pImpl的容器类的语义可以是: - 不可复制,不可分配......所以你在构造上“新”你的pImpl并在销毁时“删除” - 分享。所以你有shared_ptr而不是Impl *

使用shared_ptr,只要类在析构函数点完成,就可以使用前向声明。即使是默认值(也可能是默认值),也应该定义析构函数。

  • 交换。您可以实现“可能为空”并实现“交换”。用户可以创建一个实例,并向其传递非const引用,以便通过“交换”来填充它。

  • 两阶段施工。你构造一个空的,然后在其上调用“load()”来填充它。

shared是我唯一一个没有r值语义的远程喜欢的人。有了它们,我们也可以正确地实现不可复制的不可分配。我希望能够调用一个能给我一个的功能。

然而,我发现我现在更倾向于使用抽象基类而不是pImpl,即使只有一个实现。