【问题标题】:Pimpl idiom vs Pure virtual class interfacePimpl 成语 vs 纯虚拟类接口
【发布时间】:2010-10-23 22:13:20
【问题描述】:

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

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

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

是什么让纯虚类不如 pimpl 成语那么受欢迎?

【问题讨论】:

标签: c++ abstract-class pimpl-idiom


【解决方案1】:

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

  1. 值类型

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

  2. 实体类型

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

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

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

【讨论】:

  • 请参阅 Paul de Vrieze 对this answer 的评论。如果您在一个库中并且想要在不重建客户端的情况下交换您的 .so/.dll ,那么 Pimpl 和 Pure Virtual 会有很大的不同。客户端通过名称链接到 pimpl 前端,因此保留旧方法签名就足够了。 OTOH 在纯抽象情况下,它们通过 vtable 索引有效链接,因此重新排序方法或在中间插入会破坏兼容性。
  • 您只能在 Pimpl 类前端添加(或重新排序)方法以保持二进制可比性。从逻辑上讲,您仍然更改了界面,并且看起来有些狡猾。这里的答案是一个合理的平衡,它也可能有助于通过“依赖注入”进行单元测试;但答案总是取决于要求。第三方库编写者(不同于在您自己的组织中使用库)可能更喜欢 Pimpl。
【解决方案2】:

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

【讨论】:

  • 不一定,我见过根据所需实现存储多个 pimpl 的类。这通常是说 win32 impl 与 linux impl 需要在每个平台上以不同方式实现的东西。
  • 但是你可以使用一个接口来解耦实现细节并隐藏它们
  • 虽然您可以使用接口实现 pimpl,但通常没有理由解耦实现细节。所以没有理由去多态。 pimpl 的 原因 是让实现细节远离客户端(在 C++ 中是为了让它们远离标头)。您可以使用抽象的基础/接口来做到这一点,但通常这是不必要的。
  • 为什么过分了?我的意思是,接口方法比 pimpl 方法慢吗?可能有合乎逻辑的原因,但从实际的角度来看,我会说使用抽象接口更容易做到这一点
  • 我会说抽象基类/接口是做事的“正常”方式,并允许通过模拟进行更轻松的测试
【解决方案3】:

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

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

【讨论】:

    【解决方案4】:

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

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

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

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

    【讨论】:

    • 还要记住,通过使用继承,您会引入对 vtable 布局的依赖。为了维护 ABI,您不能再更改虚函数(如果没有添加自己的虚方法的子类,则在末尾添加是安全的)。
    • ^这里的评论应该是置顶的。
    【解决方案5】:

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

    【讨论】:

    • 有些情况下你不能使用纯虚拟接口。例如,当您有一些遗留代码并且您有两个模块需要分开而不接触它们时。
    • 正如@Paul de Vrieze 下面指出的那样,在更改基类的方法时,您会失去 ABI 兼容性,因为您对类的 vtable 有隐式依赖。这取决于用例,这是否是一个问题。
    • “我觉得隐藏私有方法是一种奇怪的偏执狂”这是否允许您隐藏依赖关系,从而在依赖关系发生变化时最大限度地缩短编译时间?
    • 我也不明白为什么工厂比 pImpl 更容易重构。你不是在这两种情况下都离开“界面”并改变实现吗?在 Factory 中您必须修改一个 .h 和一个 .cpp 文件,而在 pImpl 中您必须修改一个 .h 和两个 .cpp 文件,仅此而已,您通常不需要修改 pImpl 接口的 cpp 文件。
    【解决方案6】:

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

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

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

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

    您现在需要做的就是让您的公共接口不包含指向实现对象的指针以外的数据成员,这样您就不会出现此类错误。

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

    【讨论】:

    • 而不是void *,我认为转发声明实现类更传统:class A_impl *impl_;
    • 我不明白,你不应该在你打算用作接口的虚拟纯类中声明私有成员,这个想法是保持类完全抽象,没有大小,只有纯虚拟方法,我没有看到任何你不能通过共享库做的事情
    • @Frank Krueger:你说得对,我很懒惰。 @Arkaitz Jimenez:有点误解;如果你有一个只包含纯虚函数的类,那么谈论共享库就没有多大意义了。另一方面,如果您正在处理共享库,则出于上述原因,pimpling 您的公共类可能是谨慎的。
    • 这是不正确的。如果您将其他类设为“纯抽象基”类,这两种方法都可以让您隐藏类的实现状态。
    • 您分析器中的第一句话暗示带有关联工厂方法的纯虚拟不会让您隐藏类的内部状态。这不是真的。这两种技术都可以让您隐藏类的内部状态。不同之处在于它对用户的看法。 pImpl 允许您仍然用值语义表示一个类,同时也隐藏内部状态。纯抽象基类+工厂方法可以让你表示实体类型,也可以让你隐藏内部状态。后者正是 COM 的工作方式。 《Essential COM》第 1 章对此进行了很好的讨论。
    【解决方案7】:

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

    【讨论】:

      【解决方案8】:

      虽然在其他答案中广泛涵盖,但也许我可以更明确地说明 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();
      

      【讨论】:

        【解决方案9】:

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

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

        真的是苹果和橘子。

        【讨论】:

        • 我同意苹果/橙子。但是您似乎将 pImpl 用于功能。我的目标主要是构建技术和信息隐藏。
        【解决方案10】:

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

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

        【讨论】:

        • 当构建时间是 20 分钟而不是 2 分钟时,您还需要支付开发人员时间,所以它有点平衡,真正的模块系统在这里会有很大帮助。
        • 是什么让它难以分析?实现文件中的一堆调用转发到 Impl 类听起来并不难。
        • 想象一下同时使用 pimpl 和接口的调试实现。从用户代码A中的一个调用开始,跟踪到接口B,跳转到pimpled类C,最后开始调试实现类D......四个步骤,直到你可以分析到底发生了什么。如果整个事情是在一个 DLL 中实现的,你可能会在中间的某个地方找到一个 C 接口...... .
        • 当 pImpl 也可以完成接口的工作时,为什么还要使用带有 pImpl 的接口? (即它可以帮助你实现依赖倒置)
        • 我不能向 10 年前做过的人问这个问题 ;-)
        猜你喜欢
        • 2012-05-09
        • 2011-03-06
        • 2016-10-21
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 2014-09-04
        • 2012-10-11
        • 1970-01-01
        相关资源
        最近更新 更多