Pimpl成语vs Pure虚拟类接口


118

我想知道什么会使程序员选择Pimpl习惯用语或纯虚拟类和继承。

我知道pimpl习惯用法为每个公共方法和对象创建开销提供了一个显式的额外间接。

另一方面,Pure虚拟类带有用于继承实现的隐式indirect(vtable),据我了解,没有对象创建开销。
编辑:但是,如果您从外部创建对象,则需要工厂

是什么使纯虚拟类比pimpl惯用语更不受欢迎?


3
很好的问题,只是想问同样的事情。另请参阅boost.org/doc/libs/1_41_0/libs/smart_ptr/sp_techniques.html
Frank

Answers:


60

在编写C ++类时,考虑一下是否会

  1. 值类型

    按价值复制,身份永远都不重要。使其成为std :: map中的键是适当的。例如,“字符串”类,“日期”类或“复数”类。“复制”此类的实例很有意义。

  2. 实体类型

    身份很重要。始终通过引用传递,从不通过“值”传递。通常,根本根本没有“复制”该类的实例。如果确实有意义,通常使用多态“克隆”方法。示例:套接字类,数据库类,“策略”类,以及在功能语言中可能是“关闭”的任何类。

pImpl和纯抽象基类都是减少编译时间依赖性的技术。

但是,我只使用pImpl来实现Value类型(类型1),并且只有在我真的想最小化耦合和编译时依赖性时才使用。通常,这不值得打扰。正如您正确指出的那样,语法开销更大,因为您必须为所有公共方法编写转发方法。对于类型2类,我始终使用带有关联工厂方法的纯抽象基类。


6
请参阅Paul de Vrieze对这个答案的评论。如果您在库中并且想要交换.so / .dll而无需重建客户端,则Pimpl和Pure Virtual会有很大的不同。客户端按名称链接到pimpl前端,因此保留旧方法签名就足够了。在纯抽象情况下,OTOH通过vtable索引有效地链接,因此重新排序方法或在中间插入会破坏兼容性。
SnakE

1
您只能在Pimpl类前端中添加(或重新排序)方法,以保持二进制可比性。从逻辑上讲,您仍然更改了界面,这似乎有点狡猾。答案是合理的平衡,也可以通过“依赖注入”帮助进行单元测试。但是答案总是取决于要求。第三方图书馆作者(与在您自己的组织中使用图书馆不同)可能会更喜欢Pimpl。
Spacen Jasset

31

Pointer to implementation通常是关于隐藏结构实施细节。Interfaces关于实例化不同的实现。它们确实有两个不同的目的。


13
不一定,根据所需的实现,我见过存储多个pimpl的类。通常,这是说Win32 impl与linux impl之类的东西,每个平台需要以不同的方式实现。
Doug T.

14
但是您可以使用接口将实现细节解耦并隐藏起来
Arkaitz Jimenez 2009年

6
尽管可以使用接口来实现pimpl,但通常没有理由解耦实现细节。因此,没有理由进行多态处理。pimpl 的原因是使实现细节远离客户端(在C ++中,使它们不包含在标头中)。您可以使用抽象的基础/接口来执行此操作,但是通常这是不必要的。
迈克尔·伯

10
为什么会杀伤力大?我的意思是,接口方法是否比pimpl接口方法慢?可能有逻辑上的原因,但是从实际的角度来看,我会说使用抽象接口更容易做到这一点
Arkaitz Jimenez,2009年

1
我会说抽象基类/接口是做事的“正常”方式,并允许通过
模拟

28

pimpl习惯用法可以帮助您减少构建依赖关系和时间,尤其是在大型应用程序中,并且可以将类的实现细节的头暴露于一个编译单元中。您的类的用户甚至不需要知道是否存在粉刺(除非作为他们并不隐秘的隐秘指针!)。

客户必须注意抽象类(纯虚拟类):如果尝试使用它们来减少耦合和循环引用,则需要添加一些方法以允许它们创建对象(例如,通过工厂方法或类,依赖注入或其他机制)。


17

我正在寻找相同问题的答案。在阅读了一些文章和一些实践之后,我更喜欢使用“纯虚拟类接口”

  1. 他们更直接(这是一个主观意见)。Pimpl习惯用法使我觉得我是在为“编译器”而不是为将要读取我的代码的“下一个开发人员”编写代码。
  2. 一些测试框架直接支持模拟纯虚拟类
  3. 确实需要从外部访问工厂。但是,如果您想利用多态性:那也是“ pro”,而不是“ con”。...一个简单的工厂方法并没有真正造成太大的伤害

唯一的缺点(我正在对此进行调查)是pimpl惯用语可能会更快

  1. 内联代理调用时,继承时需要在运行时对对象VTABLE进行额外访问
  2. pimpl public-proxy-class的内存占用量较小(您可以轻松进行优化以实现更快的交换和其他类似的优化)

21
还请记住,通过使用继承,您会引入对vtable布局的依赖性。为了维护ABI,您不能再更改虚拟函数(如果没有子类添加自己的虚拟方法,则最后添加是安全的)。
Paul de Vrieze

1
^这里的评论应该是粘性的。
CodeAngry 2014年

10

我讨厌粉刺!他们的课丑陋且不可读。所有方法都重定向到pimple。您永远不会在标头中看到该类具有什么功能,因此您无法重构它(例如,仅更改方法的可见性)。全班感觉就像“怀孕”。我认为使用iterfaces更好,而且确实足以对客户端隐藏实现。您可以通过事件让一个类实现几个接口来简化它们。一个应该喜欢接口!注意:您不需要工厂类。相关的是,类客户端通过适当的接口与其实例通信。我发现私有方法的隐藏是一种奇怪的偏执狂,因为我们有接口,所以看不到这样做的原因。


1
在某些情况下,您不能使用纯虚拟接口。例如,当您有一些旧代码并且您有两个模块,需要分开而不接触它们。
AlexTheo 2014年

就像@Paul de Vrieze在下面指出的那样,在更改基类的方法时,您失去了ABI兼容性,因为您对类的vtable有隐式的依赖。这是否取决于问题取决于用例。
H. Rittich,

“隐藏私有方法,我发现这是一种奇怪的偏执狂”难道这不让您隐藏依赖项,从而在依赖项发生更改时将编译时间降至最短吗?
pooya13

我也不明白工厂比pImpl更容易重构。您是否在两种情况下都不离开“接口”并更改实现?在Factory中,您必须修改一个.h和一个.cpp文件,而在pImpl中则必须修改一个.h和两个.cpp文件,但是仅此而已,并且通常不需要修改pImpl界面的cpp文件。
pooya13

8

共享库有一个非常实际的问题,即pimpl惯用法巧妙地规避了纯虚拟机无法做到的事情:您不能安全地修改/删除类的数据成员,而不用强迫类的用户重新编译其代码。在某些情况下,这可能是可以接受的,但对于系统库而言,这是不可接受的。

要详细解释该问题,请考虑共享库/标题中的以下代码:

// header
struct A
{
public:
  A();
  // more public interface, some of which uses the int below
private:
  int a;
};

// library 
A::A()
  : a(0)
{}

编译器在共享库中发出代码,该代码计算要初始化的整数的地址,该地址将从指向它已知为A的对象的指针偏移到某个偏移量(在这种情况下,可能是零,因为它是唯一的成员)this

在代码的用户端,a new A将首先分配sizeof(A)内存字节,然后将指向该内存的指针传递给A::A()构造函数,如下所示:this

如果在您的库的更高版本中决定删除该整数,使其变大,变小或添加成员,则用户代码分配的内存量与构造函数代码期望的偏移量之间将不匹配。如果幸运的话,可能会导致崩溃-如果幸运的话,您的软件运行异常。

通过插入,您可以安全地在内部类中添加和删除数据成员,因为内存分配和构造函数调用发生在共享库中:

// header
struct A
{
public:
  A();
  // more public interface, all of which delegates to the impl
private:
  void * impl;
};

// library 
A::A()
  : impl(new A_impl())
{}

现在您需要做的就是使公共接口中没有指向实现对象的指针的数据成员,并且可以避免此类错误。

编辑:我也许应该补充一点,我在这里谈论构造函数的唯一原因是我不想提供更多代码-相同的论点适用于访问数据成员的所有函数。


4
代替void *,我认为提前声明实现类是更传统的:class A_impl *impl_;
Frank Krueger 2009年

9
我不明白,您不应在打算用作接口的虚拟纯类中声明私有成员,其想法是使该类保持抽象,无大小,仅纯虚拟方法,我什么都看不到您无法通过共享库进行操作
Arkaitz Jimenez,2009年

@弗兰克·克鲁格:你是对的,我很懒。@Arkaitz Jimenez:有点误会;如果您有一个只包含纯虚函数的类,那么谈论共享库没有什么意义。另一方面,如果要使用共享库,则出于上述原因,谨慎使用公共类是可行的。

10
这是不正确的。如果您将其他类设为“纯抽象基”类,则这两种方法都可以隐藏类的实现状态。
Paul Hollingsworth,2009年

10
分析器中的第一句话意味着具有相关工厂方法的纯虚拟机某种程度上不会让您隐藏类的内部状态。这不是真的。两种技术都可以让您隐藏类的内部状态。不同之处在于它对用户的外观。pImpl允许您仍然使用值语义表示一个类,同时还隐藏其内部状态。纯抽象基类+工厂方法允许您表示实体类型,还可以隐藏内部状态。后者正是COM的工作方式。“基本COM”的第1章对此进行了很大的讨论。
Paul Hollingsworth

6

我们决不能忘记,继承比授权更强大,更紧密。在决定解决特定问题时采用哪种设计习惯时,我还将考虑答案中提出的所有问题。


3

尽管在其他答案中都进行了广泛介绍,但也许我可以更加清楚地表明pimpl相对于虚拟基类的好处:

从用户角度看,简单的方法是透明的,这意味着您可以例如在堆栈上创建该类的对象,然后直接在容器中使用它们。如果尝试使用抽象的虚拟基类隐藏实现,则需要从工厂返回指向基类的共享指针,这会使使用复杂化。考虑以下等效的客户端代码:

// Pimpl
Object pi_obj(10);
std::cout << pi_obj.SomeFun1();

std::vector<Object> objs;
objs.emplace_back(3);
objs.emplace_back(4);
objs.emplace_back(5);
for (auto& o : objs)
    std::cout << o.SomeFun1();

// Abstract Base Class
auto abc_obj = ObjectABC::CreateObject(20);
std::cout << abc_obj->SomeFun1();

std::vector<std::shared_ptr<ObjectABC>> objs2;
objs2.push_back(ObjectABC::CreateObject(13));
objs2.push_back(ObjectABC::CreateObject(14));
objs2.push_back(ObjectABC::CreateObject(15));
for (auto& o : objs2)
    std::cout << o->SomeFun1();

2

以我的理解,这两件事有完全不同的目的。丘疹成语的目的基本上是为您提供实现的句柄,因此您可以进行诸如快速交换之类的操作。

虚拟类的用途更多地是在允许多态性的范围内,即,您有一个指向派生类型的对象的未知指针,并且当您调用函数x时,对于基本指针实际指向的任何类,您总是可以获得正确的函数。

苹果和橙子真的。


我同意苹果/橘子。但是似乎您将pImpl用于功能。我的目标主要是构建技术和信息隐藏。
xtofl,2009年

2

关于pimpl习惯用法,最烦人的问题是它使维护和分析现有代码变得极为困难。因此,使用pimpl时,您付出的开发人员时间和挫败感只是为了“减少构建依赖关系和时间,并使实现细节的标头暴露最小”。自己决定,是否真的值得。

尤其是“构建时间”是可以通过使用更好的硬件或使用Incredibuild(www.incredibuild.com,也已包含在Visual Studio 2017中)之类的工具解决的问题,因此不会影响您的软件设计。软件设计通常应独立于软件的构建方式。


当构建时间是20分钟而不是2分钟时,您还需要向开发人员支付时间,因此这有点平衡,一个真正的模块系统在这里会有所帮助。
Arkaitz Jimenez

恕我直言,软件的构建方式完全不应该影响内部设计。这是一个完全不同的问题。
Trantor

2
是什么使得难以分析?在实现文件中转发到Impl类的大量调用听起来并不困难。
mabraham

2
想象一下同时使用pimpl和interface的调试实现。从对用户代码A的调用开始,您将跟踪到接口B,跳转到Pimpled类C,最后开始调试实现类D。四个步骤,直到您可以分析实际发生​​的情况。而且,如果整个事情都在DLL中实现,则您可能会在...之间找到C接口。
Trantor

当pImpl也可以完成接口的工作时,为什么还要将接口与pImpl一起使用?(即它可以帮助您实现依赖倒置)
pooya13
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.