当我们可以在C ++中将接口用于相同目的时,PImpl模式有什么意义?


49

我看到很多在C ++中使用PImpl惯用语的源代码。我假设它的目的是隐藏私有数据/类型/实现,因此它可以消除依赖关系,然后减少编译时间和标头包含问题。

但是C ++中的interface / pure-abstract类也具有此功能,它们也可以用于隐藏数据/类型/实现。为了让调用者在创建对象时只看到接口,我们可以在接口的头中声明一个工厂方法。

比较为:

  1. 费用

    接口方式的成本较低,因为您甚至不需要重复公共包装器函数的实现void Bar::doWork() { return m_impl->doWork(); },您只需要在接口中定义签名即可。

  2. 很好理解

    每个C ++开发人员都可以更好地理解接口技术。

  3. 性能

    接口方式的性能并不比PImpl成语差,两者都需要额外的内存访问。我认为性能是一样的。

以下是伪代码来说明我的问题:

// Forward declaration can help you avoid include BarImpl header, and those included in BarImpl header.
class BarImpl;
class Bar
{
public:
    // public functions
    void doWork();
private:
    // You don't need to compile Bar.cpp after changing the implementation in BarImpl.cpp
    BarImpl* m_impl;
};

可以使用接口实现相同的目的:

// Bar.h
class IBar
{
public:
    virtual ~IBar(){}
    // public functions
    virtual void doWork() = 0;
};

// to only expose the interface instead of class name to caller
IBar* createObject();

那么PImpl有什么意义呢?

Answers:


50

首先,PImpl通常用于非多态类。而且当多态类具有PImpl时,它通常仍然是多态的,即仍然实现接口并覆盖基类中的虚方法,依此类推。所以PImpl的简单实现不是接口,它是一个直接包含成员的简单类!

使用PImpl的三个原因:

  1. 使二进制接口(ABI)独立于私有成员。可以在不重新编译从属代码的情况下更新共享库,但前提是二进制接口保持不变。现在,除了添加非成员函数和添加非虚拟成员函数以外,标头中的几乎所有更改都会更改ABI。PImpl习惯用法将私有成员的定义移到源中,从而使ABI与他们的定义脱钩。请参阅脆弱的二进制接口问题

  2. 当标题更改时,包括它的所有源都必须重新编译。C ++编译速度很慢。因此,通过将私有成员的定义移至源中,PImpl惯用语减少了编译时间,因为需要在标头中提取较少的依赖项,并减少了修改后的编译时间,因为不需要重新编译依赖项(好的,这也适用于带有隐藏的具体类的interface + factory函数)。

  3. 对于C ++中的许多类,安全性是重要的属性。通常,您需要在一个类中组合多个类,以便在对一个以上成员进行抛出操作时,如果没有一个成员被修改,或者您进行的操作会导致该成员抛出异常而处于不一致状态,并且需要保留包含对象一致的。在这种情况下,您可以通过创建PImpl的新实例来实现该操作,并在操作成功时交换它们。

实际上,接口也可以仅用于实现隐藏,但是具有以下缺点:

  1. 添加非虚拟方法不会破坏ABI,但是添加虚拟方法却可以。因此,接口根本不允许添加方法,PImpl允许。

  2. 接口只能通过指针/引用使用,因此用户必须注意适当的资源管理。另一方面,使用PImpl的类仍然是值类型,并在内部处理资源。

  3. 隐藏的实现不能被继承,带有PImpl的类可以被继承。

当然,界面对异常安全性无济于事。为此,您需要在类内部进行间接调用。


好点子。在Impl类中添加public函数不会中断ABI,但是在接口中添加public函数会中断ABI
ZijingWu

1
增加公共函数/方法不具备打破ABI; 严格来说,加性变更不会破坏变更。但是,您必须非常小心。在前端和后端之间进行调解的代码必须应对版本控制带来的种种乐趣。这一切都变得棘手。(这就是为什么许多软件项目更喜欢C而不是C ++的原因;它使他们可以更精确地掌握ABI是什么。)
Donal Fellows 2014年

3
@DonalFellows:添加公共功能/方法不会破坏ABI。除非阻止用户继承该对象(PImpl实现),否则添加虚拟方法会这样做,而且总是这样做。
Jan Hudec 2014年

4
为了弄清楚为什么添加虚拟方法会破坏ABI。它与链接有关。无论库是静态还是动态的,都按名称链接到非虚拟方法。添加另一个非虚拟方法就是添加另一个名称链接。但是,虚拟方法是vtable中的索引,并且外部代码通过索引有效地链接。添加虚拟方法可以在vtable中移动其他方法,而无需外部代码知道。
SnakE

1
PImpl还减少了初始编译时间。例如,如果我的实现具有某个模板类型的字段(例如unordered_set),而没有PImpl,则需要#include <unordered_set>in MyClass.h。使用PImpl,我只需要在.cpp文件中包括它,而不是.h文件,因此包括它的所有其他内容……
Martin C. Martin

7

我只想解决您的性能问题。如果使用接口,则必须创建虚拟函数,编译器的优化器不会内联这些虚函数。可以(而且可能会因为它们太短)内联PIMPL函数(如果IMPL函数也很小,则可能不止一次)。除非您使用需要很长时间才能完成的完整程序分析优化,否则无法优化虚拟函数调用。

如果您的PIMPL类不是以对性能至关重要的方式使用的,那么这一点并不重要,但是您认为性能相同的假设仅在某些情况下成立,而并非在所有情况下都成立。


1
通过使用链接时间优化,可以消除使用pimpl习惯用法的大部分开销。外部包装函数和实现函数都可以内联,从而使函数调用像在标头中实现的普通类一样有效。
goji 2013年

仅当您使用链接时优化时才如此。PImpl的要点是编译器没有实现,甚至没有转发功能。链接器可以。
Martin C. Martin

5

此页面回答您的问题。许多人同意您的主张。与私有字段/成员相比,使用Pimpl具有以下优点。

  1. 更改类的私有成员变量不需要重新编译依赖于该类的类,因此使生成时间更快,并且减少了FragileBinaryInterfaceProblem
  2. 头文件不需要#include私有成员变量中“按值”使用的类,因此编译时间更快。

Pimpl习惯用法是一种编译时优化,一种打破依赖的技术。减少大型头文件。它将您的标头减少为仅公共接口。考虑C ++的工作原理,标头长度很重要。#include有效地将头文件连接到源文件-因此请记住,经过预处理的C ++单元可能非常大。使用Pimpl可以缩短编译时间。

还有其他更好的依赖打破技术-涉及改进您的设计。私有方法代表什么?什么是单一责任?他们应该是另一类吗?


PImpl根本不是运行时方面的优化。分配并非易事,而pimpl意味着额外的分配。它是一种打破依赖的技术,并且仅是编译时间的优化。
Jan Hudec

@JanHudec澄清了。
戴夫·希利尔

@ DaveHillier,+ 1提及FragileBinaryInterfaceProblem
ZijingWu
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.