关于c ++:Pimpl idiom vs Pure虚拟类接口

Pimpl idiom vs Pure virtual class interface

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

我理解PIMPL习惯用法为每个公共方法和对象创建开销提供了一个显式的额外间接寻址。

另一方面,纯虚拟类为继承实现提供了隐式间接寻址(vtable),我理解没有对象创建开销。编辑:但如果从外部创建对象,则需要一个工厂

什么使得纯虚拟类比PIMPL习惯用法更不受欢迎?


在编写C++类时,考虑它是否会是合适的。

  • 值类型

    按价值复制,身份从来都不重要。在STD:它是一个关键::地图。例如,"字符串"类、"日期"类或"复数"类。"复制"此类的实例是有意义的。

  • 实体类型

    身份很重要。总是通过引用传递,而不是通过"value"。通常,完全"复制"类的实例是没有意义的。当它确实有意义时,多态"克隆"方法通常更合适。示例:一个套接字类、一个数据库类、一个"策略"类,以及任何在函数语言中可能是"闭包"的类。

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

    然而,我只使用PIMPL实现值类型(类型1),有时也只在我真正想要最小化耦合和编译时依赖性时使用。通常,这不值得麻烦。正如您正确地指出的,有更多的语法开销,因为您必须为所有公共方法编写转发方法。对于类型2类,我总是使用带有关联工厂方法的纯抽象基类。


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


    PIMPL习惯用法有助于减少构建依赖项和时间,特别是在大型应用程序中,并将类的实现细节的头暴露最小化到一个编译单元中。你的类的用户甚至不需要知道一个疙瘩的存在(除了作为一个神秘的指针,他们不是秘密的!).

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


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

  • 他们更直截了当(这是一种主观意见)。pimpl这个成语让我觉得我在写"编译器代码",而不是为"下一个开发人员"来读我的代码。
  • 一些测试框架直接支持模拟纯虚拟类
  • 的确,你需要一个能从外面进入的工厂。但如果你想利用多态性:这也是"亲",而不是"骗局"。…一个简单的工厂方法不会有太大的伤害。
  • 唯一的缺点(我正试图对此进行研究)是pimpl习惯用法可能更快

  • 当代理调用是内联的,而继承必然需要在运行时对对象vtable进行额外访问。
  • pimpl公共代理类的内存占用较小(您可以轻松地进行优化以更快地交换和其他类似的优化)

  • 共享库有一个非常实际的问题,PIMPL习惯用法巧妙地避开了纯虚拟无法解决的问题:在不强制类的用户重新编译代码的情况下,您无法安全地修改/删除类的数据成员。在某些情况下,这可能是可以接受的,但不适用于系统库。

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

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    // header
    struct A
    {
    public:
      A();
      // more public interface, some of which uses the int below
    private:
      int a;
    };

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

    编译器在共享库中发出代码,计算要初始化为某个偏移量的整数的地址(在本例中可能为零,因为它是唯一的成员),该偏移量从指向它知道是this的对象的指针开始。

    在代码的用户端,new A首先分配sizeof(A)字节的内存,然后将指向该内存的指针作为this交给A::A()构造函数。

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

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

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    // 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())
    {}

    现在您需要做的就是保持公共接口不包含除指向实现对象的指针之外的数据成员,并且您可以安全地避免此类错误。

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


    我讨厌粉刺!他们做的班级丑陋和不可读。所有方法都被重定向到丘疹。您永远不会在头中看到类具有哪些功能,因此无法重构它(例如,只需更改方法的可见性)。这节课感觉像"怀孕了"。我认为使用ITrfaces更好,而且实际上足以将实现隐藏在客户机中。您可以通过事件让一个类实现多个接口来保持它们的精简。一个人应该更喜欢接口!注意:您不需要工厂类。相关的是类客户机通过适当的接口与它的实例通信。我发现隐藏的私有方法是一种奇怪的偏执狂,因为我们有接口,所以看不到原因。


    我们不能忘记继承是比委托更强大、更紧密的耦合。我还将考虑到在决定在解决某个特定问题时使用什么设计惯例时给出的答案中提出的所有问题。


    虽然在其他答案中有广泛的介绍,但我可以更明确地说明PIMPL相对于虚拟基类的一个好处:

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

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    // 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();

    在我看来,这两件事的目的完全不同。pumple习惯用法的目的基本上是为您提供实现的一个句柄,这样您就可以执行诸如快速交换之类的操作。

    虚拟类的目的更多的是为了实现多态性,例如,您有一个指向派生类型的对象的未知指针,当您调用函数x时,总是为基指针实际指向的任何类获得正确的函数。

    苹果和桔子真的。


    关于PIMPL习语最令人恼火的问题是它使得维护和分析现有代码极其困难。因此,使用PIMPL,您只需花费开发人员的时间和精力来"减少构建依赖性和时间,并尽量减少实现细节的头部暴露"。决定你自己,如果它真的值得的话。

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