123

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

我知道 pimpl idiom 为每个公共方法和对象创建开销都提供了一个明确的额外间接。

另一方面,纯虚拟类带有用于继承实现的隐式间接(vtable),我知道没有对象创建开销。
编辑:但是如果你从外部创建对象,你需要一个工厂

是什么让纯虚拟类比 pimpl 成语更不受欢迎?

4

10 回答 10

60

在写一个C++类的时候,考虑一下是否会是合适的

  1. 值类型

    按价值复制,身份从来都不重要。它适合作为 std::map 中的键。例如,“字符串”类,或“日期”类,或“复数”类。“复制”此类的实例是有道理的。

  2. 实体类型

    身份很重要。始终通过引用传递,从不通过“值”。通常,“复制”类的实例根本没有意义。当它确实有意义时,多态“克隆”方法通常更合适。示例:Socket 类、Database 类、“策略”类、任何在函数式语言中可能是“闭包”的东西。

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

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

于 2009-05-05T15:15:03.857 回答
30

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

于 2009-05-05T14:09:00.927 回答
28

pimpl 习惯用法可帮助您减少构建依赖项和构建时间,尤其是在大型应用程序中,并最大限度地减少将类的实现细节的标头暴露给一个编译单元。您班级的用户甚至不需要知道疙瘩的存在(除了作为他们不知道的神秘指针!)。

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

于 2009-05-05T14:12:39.327 回答
17

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

  1. 他们更直接(这是一个主观意见)。Pimpl 成语让我觉得我是在“为编译器”编写代码,而不是为将阅读我的代码的“下一个开发人员”编写代码。
  2. 一些测试框架直接支持 Mocking 纯虚拟类
  3. 确实,您需要一个可以从外部访问的工厂。但是,如果您想利用多态性:那也是“赞成”,而不是“反对”。...而且一个简单的工厂方法并没有那么疼

唯一的缺点(我正在尝试对此进行调查)是 pimpl idiom 可能更快

  1. 当代理调用被内联时,继承必然需要在运行时额外访问对象 VTABLE
  2. pimpl public-proxy-class 的内存占用更小(您可以轻松地进行优化以实现更快的交换和其他类似的优化)
于 2010-02-25T00:28:54.197 回答
10

我讨厌痘痘!他们把课做得丑陋而且不可读。所有方法都重定向到疙瘩。您永远不会在标题中看到该类具有哪些功能,因此您无法重构它(例如,只需更改方法的可见性)。上课感觉就像“怀孕了”。我认为使用 iterfaces 更好,并且真的足以向客户端隐藏实现。您可以让一个类实现多个接口以保持它们的精简。应该更喜欢接口!注意:您不需要工厂类。相关的是类客户端通过适当的接口与其实例进行通信。我发现隐藏私有方法是一种奇怪的偏执狂,因为我们有接口,所以看不到这样做的原因。

于 2010-10-05T14:37:06.497 回答
9

共享库有一个非常现实的问题,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

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

如果在您的库的更高版本中您决定删除整数、使其更大、更小或添加成员,则用户代码分配的内存量与构造函数代码期望的偏移量之间将不匹配。可能的结果是崩溃,如果你幸运的话——如果你不那么幸运,你的软件就会表现得很奇怪。

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

// 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())
{}

你现在需要做的就是让你的公共接口没有数据成员,而不是指向实现对象的指针,这样你就不会出现这类错误。

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

于 2009-05-05T14:43:26.633 回答
6

我们不能忘记继承是比委托更强大、更紧密的耦合。在决定在解决特定问题时采用哪些设计习惯时,我还会考虑给出的答案中提出的所有问题。

于 2010-02-27T12:37:56.040 回答
3

尽管在其他答案中广泛涵盖了也许我可以更明确地说明 pimpl 对虚拟基类的一个好处:

从用户的角度来看,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();
于 2017-06-13T14:05:27.680 回答
2

据我了解,这两件事的目的完全不同。pimple 习惯用法的目的基本上是为您提供实现的句柄,以便您可以执行诸如快速交换之类的操作。

虚拟类的目的更接近于允许多态性,即您有一个指向派生类型对象的未知指针,并且当您调用函数 x 时,您总是会为基指针实际指向的任何类获得正确的函数。

苹果和橙子真的。

于 2009-05-05T14:37:02.450 回答
2

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

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

于 2018-01-18T09:27:55.627 回答