[这是 GotW #24 的 C++11 更新版]
原文在这里 GotW #100: Compilation Firewalls (Difficulty: 6/10)
JG 问题
1. Pimpl 惯用法是什么?为什么要使用它?
Guru 问题
2. 怎样才是 Pimpl 惯用法在 C++11 中的最佳实现?
3. 类中的那些部分应该放到 impl 对象中?一些可能的选择包括:
它们的每一个的缺点和优点是什么?你怎么选择?
4. impl 对象是否需要一个指向公开对象的反向指针?如果需要的话,它的最佳实现是什么?如果不需要的话,为什么?
解决方案
1. Pimpl 惯用法是什么?为什么要使用它?
在 C++ 中,当头文件中的类定义改变时,所有使用了这个类的用户都必须重新编译,甚至仅仅更改了那些用户无法访问的类的私有成员,也是如此。这是因为 C++ 的构建模型是基于文本包含的,并且 C++ 假定调用者知道以下两件事,而这些是会受到私有成员影响的:
-
大小和布局:调用的代码必须知道一个类的大小和布局,包括私有数据成员。调用者总是知道被调用者的实现,这个约束导致了调用者和被调用者之间更紧密的耦合,但这是 C++ 对象模型的核心和哲学,因为保证编译器默认情况下可以直接访问对象,是允许编译器进行深度性能优化的重要组成部分。
-
函数:调用的代码必须决定调用类的哪个成员,包括那些不可访问的,重载了非私有函数的私有函数——如果私有函数时更好的匹配,那么调用代码将编译失败。(C++ 经过深思熟虑的设计决定,由于安全问题,重载决议将在访问控制权限之前进行。举个例子,这可以保证当改变访问权限,把私有函数改为公有时,它不会改变合法的已有的代码的行为。)
为了减小编译依赖,最通常的技术是使用一个不透明的指针,隐藏实现的细节,下面是基本思路:
// Pimpl idiom - basic idea
class widget {
// :::
private:
struct impl; // things to be hidden go here
impl* pimpl_; // opaque pointer to forward-declared class
};
类 widget 使用了 handle/body 惯用法的变体,参见 Coplien 的文章[1],handle/body 过去主要用途是引用计数共享的实体,但是它还有更通用的隐藏实现的功能。为了方便,下面我会把类 widget 叫做可见类,impl 叫做 "Pimpl class” [2]
这个惯用法最大的好处是打破了编译时的依赖。首先,由于使用 Pimpl 可以消除多余的 #include,这使得编译过程更快了。我曾经在一个项目中仅仅把一些使用广泛的可见类转换为使用 Pimpls 就使得编译生成时间减少了一半。第二,它使得代码更改对编译的影响减小了,因为在类的 Pimpl 中的这一部分可以自由更改了——成员可以自由的添加或者删除,而不用重新编译客户端的代码。因为它可以在仅仅更改隐藏的成员时避免重新编译客户代码,所以它又被称为“编译防火墙”。
但是这还留下了一些问题:pimpl 应当是裸指针吗?哪些东西应该放到 Pimpl class 中?让我们看看这些和其他重要的细节吧。
2. 怎样才是 Pimpl 惯用法在 C++11 中的最佳实现?
避免使用裸指针和显式的 delete。仅仅使用标准 C++ 设施,最适当的选择是使用只能指针 unique_ptr 持有这个 Pimpl 对象,因为可见类是 Pimpl 对象的唯一所有者。使用 unique_ptr 也可以是代码变得简单。[3]
// in header file
class widget {
public:
widget();
~widget();
private:
class impl;
unique_ptr<impl> pimpl;
};
// in implementation file
class widget::impl {
// :::
};
widget::widget() : pimpl{ new impl{ /*...*/ } } { }
widget::~widget() { } // or =default
注意这个模式的关键部分:
- 使用 unique_ptr 来持有 Pimpl 对象是更好的选择。它比使用 shared_ptr 更高效,而且更能正确表达意图,Pimpl 对象是不应该共享的。
- 在你自己的实现文件中定义和使用 Pimpl 对象。隐藏细节。
- 在可见类的非内联构造函数中分配 Pimpl 对象。
- 你仍然需要自己在可见类的实现文件中定义和实习它的析构函数,即使它和编译器生成的版本一模一样。这是因为虽然 unique_ptr 和 shared_ptr 可以实例化不完整的类型,但是 unique_ptr 的析构函数需要完整的类型,以便调用 delete(与 shared_ptr 不同,shared_ptr 可以在构造时获得更多的信息)。在实现 impl 的同时,自己实现它的析构函数,这可以有效的防止编译器自动生成析构函数。
- 上面的模式没有生成默认的拷贝和转移语义,因为 C++11 编译器没有那么热心为你生成拷贝和转移操作。由于我们已经自己定义了析构函数,这使得编译器不再为我们生成转移构造和转移赋值。如果你想要支持拷贝和/或转移语义,和析构函数一样,你需要在实现中定义拷贝和转移操作。
C++11 中 Pimpl 另一个优势是,它是转移友好的,因为它仅仅需要拷贝单个指针就可以了。so cool。
让我们考虑一下上面提出的选择,哪些东西应该放到隐藏的 Pimpl 中。
什么是 Pimpl ?[4]
3. 类中的那些部分应该放到 impl 对象中?一些可能的选择包括:
选择1(得分:6/10):这是一个好的开始,因为我们可以前向声明任何数据类,这比 #include 类的实际声明要好,它会造成代码依赖。
但是它也存在缺点:一个小问题,我们需要在可见类的实现中不停的写 pimpl-> 。还有个大问题,当增加和删除私有成员函数时,我们还是需要重新编译,而且在极少数情况下,如果重载了非私有函数的话,它会干扰重载决议。
有更好的选择吗?
选择2(得分:9/10):这(几乎)是我最近的一贯做法。毕竟,在 C++中,我们说的“外部代码不应该也不能关心的部分”就是私有 private。
下面是三条注意事项,第一个就是我上面所说的“几乎”的原因:
- 你不能隐藏虚函数到 Pimpl 中,即使虚函数是私有的。如果重写了继承来的虚函数,它必须出现在派生中,即使这个类是 final 类。如果虚函数是 new 函数,那么它必须出现在可见类中,这样才能让派生类重写。
- 如果你需要使用可见类中的函数,那么 Pimpl 类的函数就需要一个指向可见类对象的反向指针,这增加了间接层。
- 一个好的折中办法是,使用“选择2”的同时,把那些私有成员需要调用的非私有成员函数一起放到 Pimpl 中(参考下面的“反向指针”)。
选择3(得分:0/10):多余的把保护成员移动到 Pimpl 中式绝对错误的。虚成员和保护成员不应该移动到 Pimpl 中,因为这会让他们变得没有价值。毕竟,保护成员仅仅可以让派生类访问,如果让他们不可见或不可用那就没有任何价值了。派生类只能强制了解 Pimpl 的细节,然后再继承 Pimlp,然后维护一个并行的两个对象的继承体系。
但是,仍然有合理的原因把虚函数放到 Pimpl-like(body/implementation) 类中。但是它的动机和 Pimpl 惯用法是不同的,这是桥接模式[5],它把一个类分成两部分,这两部分都可以包含实现以及独立的可扩展的虚函数。但是这是和 Pimpl 动机不同的另外的模式。
选择4(得分:10/10):这是理想的情况。为了避免保存或者传递一个反向指针,你可能需要把那些私有函数调用的公有函数也放到 Pimpl 中,然后在可见类中提供一个接口转发。但是你不应该移动保护成员和虚函数到 Pimpl 中,就像上面提到的。
- 全部都放到 impl 对象中,然后实现公有接口,每个实现仅仅简单的做函数转发(handle/body 的变体)
选择5(得分:8/10某种情况下):这在某种情况下是有用的,并且它可以有效避免反向指针,因为所有东西都在 Pimpl 类中了。主要的缺点是多了一层封装,并且它使得可见类无法通过继承来扩展了。
它们的每一个的缺点和优点是什么?你怎么选择?
完整的答案事实上比上面我们讨论的要简单的多。抛开经验和具体的分析,我们需要回顾,然后从第一条原则开始回答。
深呼吸,放松,好的。
主要考察的是面向对象语言[6]中的三个部分。它们是:
- 调用者的接口=公有成员。挺合适所有外部调用者可见和可用的部分。
- 派生类的接口=保护和虚成员,这些是仅派生类可见和可用的。
- 其他=私有和非虚成员,根据定义,这些是属于实现的细节。
仅仅第3条,所有第3条涵盖的内容,可以隐藏也应该隐藏到 Pimpl 中。这样我们就能继承所有上面提到的其他东西;举个例子,我们不能把虚成员放到 Pimpl 中,因为我们在第2条中提到的,派生类需要它可见。
上面的表格描述了不同设计的选择。包括 Pimpl,以及引出的其他设计,Coplien 提出的 Handle/Body ,还有桥接模式,虽然某些地方和 Pimpl 类似,但是它的动机和结构是非常不同的。
4. impl 对象是否需要一个指向公开对象的反向指针?如果需要的话,它的最佳实现是什么?如果不需要的话,为什么?
答案是:有时候,很不幸,是的。毕竟,我们所做的就是(人为地,不自然的)把对象分为两部分,来隐藏其中一部分。
考虑下面的情况:当可见类的一个函数被调用的时候,通常隐藏部分的函数或者数据是需要协助完成请求的。这没问题也很合理。但是上面已经讨论过,有时候 Pimpl 中的函数必须调用可见类中的非私有或者虚函数。这时候,需要一个指向可见类的指针。
有两个选择:
- 在 Pimpl 中保存一个反向指针。这会带来轻微的开销,而且会一直保存着这个指针无论你用还是不用。此外,when you repeat yourself you can lie——如果你不小心维护指针的正确性让它指向正确的可见对象,反向指针会存在同步问题,举个例子,默认情况下转移操作之后,它就不再正确了。
- (推荐)通过参数传递 this 指针给 Pimpl 中的函数(例如:pimpl->func(this, params))。这仅仅会在函数调用(简单)时,带来栈上(廉价的)很小的空间开销,而且也不可能存在同步问题。但是,这意味着给每一个需要的隐藏函数增加个多余的参数。
感谢
感谢 Edd, pizer, and Howard Hinnant 阐明为什么 ~unique_ptr<T> 需要 T 是完整类型, 并需要用户自己在外部实现类的析构函数;感谢 Stephan Lavavej and Alisdair Meredith 提醒我在转移构造和转移赋值时使用 =default;感谢 Howard Hinnant 指出即使使用了 =default,转移赋值函数仍然需要在实现文件中以非内联函数形式实现,因为它需要类型完整(确保能够 delete)。
注解
[1] James O. Coplien. “C++ Idioms” (EuroPLoP98).
[2] 一开始我使用 impl_ 作为指针的变量名。pimpl 是从 1996 年开始使用的,分享给我这个名字的是同学兼好友 Jeff Sumner,使用字母 “p” 作为指针变量的前缀,同时我也发现了之前变量名的可怕的双关。
[3] 这是 C++ 11 中最简单的模型。最主要的替代方案是使用 shared_ptr 或者裸指针来代替 unique_ptr,这两个的正确实现都比它复杂,存在潜在的错误,编译器生成的函数会产生不正确的行为:如果你使用 shared_ptr,你可以默认得到正确的析构函数,转移构造函数转移赋值函数,但是编译器生成的拷贝操作将是错误的,所以你需要手工明确的实现,或者使用 =delete 禁止它们(如果你忘记了,那你就默默得到了错误的语义),一个无用但是却存在的引用计数。如果你使用裸指针,你需要手动实现5个操作,析构函数,拷贝构造函数,拷贝赋值函数,转移构造函数,还有转移赋值函数。
[4] 不要发关于这个标题的笑话给我,大部分答案我都能想到。
[5] Gamma et al. Design Patterns: Elements of Reusable Object-Oriented Software (Addison-Wesley, 1994).
[6] 一个类中不需要包含全部特性,举个例子,#2 不适用于那些不存在继承的值语义类。
主要更改记录
2011-11-27: 从模式中去除了转移操作,因为不是所有的 Pimpl 类需要转移语义,它不是模式真正的核心内容。