Pimpl 习语与纯虚拟类接口

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

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

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

是什么让纯粹的虚拟职业不如皮普尔这个成语吸引人呢?

27082 次浏览

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

Pimpl 习惯用法可以帮助您减少构建依赖项和时间,特别是在大型应用程序中,并将类的实现细节的头部暴露最小化到一个编译单元。您的类的用户甚至不需要知道一个粉刺的存在(除非是一个他们不知道的神秘指针!).

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

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

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

其实是苹果和橘子。

共享库存在一个非常现实的问题,pimpl 这个习惯用法巧妙地规避了纯虚拟不能解决的问题: 如果不强制类的用户重新编译代码,就无法安全地修改/删除类的数据成员。这在某些情况下可能是可以接受的,但是对于系统库来说就不行了。

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

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


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

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

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

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

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

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

现在您需要做的就是保持您的公共接口中除了实现对象的指针之外没有其他数据成员,这样您就不会出现这类错误。

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

在编写 C + + 类时,考虑它是否是

  1. 值类型

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

  2. 实体类型

    身份很重要。总是通过引用传递,而不是通过“值”传递。通常,“复制”类的实例毫无意义。当它确实有意义时,多态的“克隆”方法通常更合适。例如: Socket 类、 Database 类、“ policy”类,以及函数式语言中的任何“闭包”。

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

但是,我只使用 pImpl 来实现 Value 类型(类型1) ,而且只有在我真正想最小化耦合和编译时依赖性时才使用。通常情况下,不值得这么麻烦。正如您正确地指出的那样,由于您必须为所有公共方法编写转发方法,因此存在更多的语法开销。对于类型2的类,我总是使用一个纯抽象的基类和相关的工厂方法。

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

  1. 他们更加直截了当(这是一个主观的看法)。Pimpl 习语让我觉得我写代码是为了“编译器”,而不是为了“下一个开发人员”,他们会读我的代码。
  2. 一些测试框架直接支持 Mocking 纯虚拟类
  3. 这是真的,你 需要一个工厂是可以从外面访问。 但是,如果你想利用多态性: 这也是“赞成”,而不是“反对”。... 一个简单的工厂方法并不真的伤害这么多

唯一的缺点(我正在调查这件事)是,皮普尔习语可以更快

  1. 当代理调用是内联的时候,继承需要在运行时额外访问对象 VTABLE
  2. 内存占用 pimpl public-xy-class 更小(您可以很容易地为更快的交换和其他类似的优化进行优化)

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

我讨厌青春痘!他们把课上得又丑又难看。所有的方法都被重定向到粉刺。你从来没有在头部看到什么功能有类,所以你不能重构它(例如,简单地改变一个方法的可见性)。这堂课感觉像“怀孕”了。我认为使用迭代更好,而且真的足以向客户机隐藏实现。您可以事件化地让一个类实现多个接口来保持它们很薄。人们应该更喜欢接口! 注意: 您不需要工厂类。相关的是,类客户端通过适当的接口与其实例进行通信。 我发现隐藏私有方法是一种奇怪的妄想症,因为我们有接口,所以没有理由这样做。

尽管在其他答案中已经有了大致的介绍,但我还是可以更明确地说明 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();

Pimpl 习惯用法最恼人的问题是,它使得维护和分析现有代码变得极其困难。因此,使用 pimpl 你付出了开发人员的时间和挫折,只是为了“减少构建依赖性和时间,并尽量减少实现细节的头部暴露”。如果真的值得,你自己决定。

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