Pimpl 的三分之二和一丝微笑






4.74/5 (13投票s)
一篇关于准 Pimpl 的文章,以及一个不错的全局对象管理器。
引言
本文介绍了一种减少编译依赖和构建时间的简便方法。这种方法本身不足以写成一篇文章,但它是一个有趣的全局对象管理方法的关键,而且我从未在别处见过这种方法。
历史
- 2005 年 10 月 11 日 - 添加了 附录 #1,概述了另一种全局组织者方法。
- 2013 年 6 月 3 日 - 添加了 附录 #2,展示了
grin_ptr
和shared_ptr
的比较。
背景
在看到关于单例(Singleton)使用方式的另一场宗教战争开始爆发时,我意识到我使用的方法在其他地方都没有被讨论过,包括 CP。它有一些优点,而且我没有见过它被提及,所以我想把它发出来。(当然,话说回来,我明天很可能会看到一篇关于完全相同东西的文章。)
该方法
正如我所说,方法本身并不那么有趣。只需使用智能指针来持有类接口中存储的所有非 POD 对象。
嗯,有一件有趣的事——如果你不在单元的 .cpp 文件中至少包含一个空的析构函数,你就不能使用 auto_ptr
或许多其他智能指针来存储你的对象。这就是为什么下面的例子使用了 Alan Griffith 的 grin_ptr
。你也可以使用 boost::shared_ptr
,以及我可能不知道的其他一些指针。(更多信息请参见 附录 #2。)
你不能为此目的使用 auto_ptr
的原因是,auto_ptr
在销毁时必须拥有前向声明类的完整定义,如果你依赖于默认析构函数,那么前向声明的类就是 auto_ptr
所拥有的唯一东西,因此它会为被持有的类生成一个“无操作”的默认析构函数。这很少是你想要的。
(如果你不确定某个智能指针是否可以在不创建持有类 .cpp 文件中的析构函数的情况下用于此目的,请查看该智能指针的文档,寻找类似“它可以持有并正确销毁不完整类型”的声明。如果它这样说,那么你就可以安全地使用它,而无需记住在 .cpp 文件中提供析构函数。)
作为我所说的模式的一个快速示例,这里是我钱包的理论实现
//Header file, include guards not shown #include "arg.h" //Only uses forward declarations: class CreditCard; class BusinessCard; class DollarBill; class Wallet { private: //Make it simple - just one of each arg::grin_ptr<CreditCard> masterCardC; arg::grin_ptr<BusinessCard> businessCardC; arg::grin_ptr<DollarBill> dollarBillC; //anything else, but if they are classes, //wrap them in pointers as above. public: Wallet(); BusinessCard & businessCard() { return *businessCardC.get(); } //I really don't want to //expose the following two, //but this is simply an example... CreditCard & masterCard() { return *masterCardC.get(); } DollarBill & dollarBill() { return *dollarBillC.get(); } //anything else... };
//Implementation file #include "Wallet.h" #include "CreditCard.h" #include "BusinessCard.h" #include "DollarBill.h" Wallet::Wallet() : masterCardC(new MasterCard(/*any args*/)), businessCardC(new BusinessCard(/*any args*/)), dollarBillC(new DollarBill()) { } //And anything else...
(随便给我‘新’一些更多的钞票。 :))
总之,正如你所见,没什么值得兴奋的,直到你思考片刻。我们刚刚完全消除了除了 Wallet
头文件中的 'arg.h' 文件之外的所有外部依赖。如果 CreditCard
、BusinessCard
或 DollarBill
的实现发生变化,项目中需要重新编译的唯一单元是 Wallet
单元和你更改的那个单元。这比在类的头文件中拥有“硬对象”要节省很多时间。在这种情况下,任何 #include
d Wallet.h 的单元都会在 CreditCard
、BusinessCard
或 DollarBill
的实现发生变化时重新编译。
使用上述方法的节省效果不如完整的 Pimpl 实现,因为完整的 Pimpl 实现允许你重新编译 CreditCard
、BusinessCard
或 DollarBill
,而 Wallet
单元或项目中的任何其他单元都无需重新编译。(当然,更改 Pimpl 的接口可能会很麻烦(Pain In The A__),并且届时需要重新编译更多东西。)
刚概述的方法比 Pimpl 模式更简单,因为它不需要你创建一个中间的 'PimplHolder
' 类。不过,你必须使用 '->
' 符号来访问类内存储的所有智能指针对象,除非你在使用它们的功能中创建它们的本地引用。
“有趣的用法”
上述方法可以用来轻松管理项目范围的对象。当用于全局时,这种方法通常可以用作准单例管理器。这样做通常会简化你整体设计的某些方面,而且不会增加编译依赖。
请不要因此认为我不喜欢单例。我将要展示的模式并不取代单例。这种模式中没有任何东西可以阻止你实例化容器中对象的多份副本。这种模式只是让管理和使用项目范围的对象变得非常容易,如果你不想费心去解决单例实例化顺序的依赖问题,这可能是一个有吸引力的选择。
同样,也不要因此认为我是一个极力主张全局变量的人。这种模式允许我将全局变量的使用量最小化到整个项目两三个,我对此很满意。我确实在全局对象中存储了不少变量,而且由于这些变量定义在全局变量的头文件中,一旦它们的接口发生变化,或者我向全局变量添加了另一个项,所有 #include
d Globals.h 的单元都会在那个时候重新编译。如果你的项目进行这种性质的重建需要大量时间,你将需要仔细地将全局变量原子化,甚至可以将其分解到使每个对象(如以下代码中的 'TextureManager
')成为其自己的全局项。我在本文末尾的第一个 附录 中概述了一个可以简化这一点的包装器。
让我举一个这个“有趣用法”的简单例子。与前一个示例相比,只需要两个更改:将它改为保存单例通常保存的内容,并将该类变为全局。我在宗教战争如火如荼时举的例子是以下内容
//Header file (minus include guards again) #include "arg.h" class TextureManager; class LoggingSystem; class ObjectManager; //... class Globals { private: arg::grin_ptr<TextureManager> textureManagerC; arg::grin_ptr<LoggingSystem> loggerC; arg::grin_ptr<ObjectManager> objectManagerC; //... public: Globals(); TextureManager & textureManager() { return *textureManagerC.get(); } LoggingSystem & logger() { return *loggerC.get(); } ObjectManager & objectManager() { return *objectManagerC.get(); } //... };
//Implementation file: #include "TextureManager.h" #include "LoggingSystem.h" #include "ObjectManager.h" Globals::Globals() : textureManagerC(new TextureManager()), loggerC(new LoggingSystem()), objectManagerC(new ObjectManager()) /* and any other stuff */ { }
//Here is a sample of a 'main' file: #include "Globals.h" Globals gGlobals; //The following #include is only //so we can access 'doSomething'. //We don't need it for the global creation. #include "TextureManager.h" int main() { gGlobals.textureManager().doSomething(); //... return 0; }
就是这样了,不过如果你需要传递初始化参数给你的某个类,你需要将 gGlobals
实现为一个指针,并在获取到依赖的参数之后再初始化它。最好的选择是通过使用 auto_ptr
来实现(在这种情况下 auto_ptr
没有问题)
Globals * gGlobals; int main() { //Do whatever in order to get your 'args' //... //... and finally std::auto_ptr<Globals> tGlobals(new Globals(/*args*/)); gGlobals = tGlobals.get(); //...
使用上述方法,你可以明确控制对象的创建顺序,并非常轻松地克服在尝试控制具有相互依赖创建顺序的多个单例时出现的问题。此外,这种方法比单例具有更简单的语法。单例在使用时需要类似 SingletonManager::getInstance().textureManager().doSomething()
的写法。而上述方法则简化为 gGlobals.textureManager().doSomething()
。
但真正有趣的部分是,使用这种技术,如果你只修改 TextureManager.cpp 文件,它将是唯一需要重新编译的文件。如果你修改 TextureManager.h 文件,只有显式 #include "TextureManager.h"
的单元需要重新编译。这包括 Globals
单元,但不包括所有 #include
d "Globals.h"
的文件。
值得再次阅读最后一段,并查看代码,直到你理解这个系统并没有将 Globals
单元管理的任何其他对象暴露给任何没有 #include
ing 你希望访问的子单元的单元。你可以将 #include "Globals.h"
添加到程序中的每个 .cpp 文件中,但它们不会链接到 TextureManager
,除非你在想要访问 TextureManager
的单元中显式地 #include "TextureManager.h"
以及 Globals.h。没有其他编译依赖需要注意,而且 Globals
单元除了几个前向声明、Globals
类本身声明以及 grin_ptr
内部的一些小部件之外,不会施加任何额外的开销。
整个技术秘密:只使用前向声明和一个能干的智能指针。
我希望你觉得这个方法有用,祝你编码愉快!
附录 #1 - 全局实例化
如果你将全局变量原子化,并且不希望使用单例,你可以修改上述方法,在 main
中实例化你的全局变量,并完全控制你的实例化和销毁顺序。
Globals * gGlobals; TextureManager * gTextureMan; //... int main() { std::auto_ptr<Globals> tGlobals(new Globals(/*args*/)); gGlobals = tGlobals.get(); std::auto_ptr<TextureManager> tTextureMan(new TextureManager()); gTextureMan = tTextureMan.get(); //... //And, if you want, you can even destroy them in any order. //Just manually call 'release' on the pointers in the order you want //at the end of 'main', rather than relying upon the auto_ptr's destructors.
你甚至可以创建一个类来管理这些原子化的全局变量。这样做可以克服之前关于构建时间长的反对意见。我设想类似下面的内容
//GlobalManager.h w/o include guards class TextureManager; class OtherGlobals; class GlobalManager { private: arg::grin_ptr<TextureManager> textureManagerC; arg::grin_ptr<OtherGlobals> otherGlobalsC; //... }; //GlobalManager.cpp #include "TextureManager.h" TextureManager * gTextureMan; #include "OtherGlobals.h" OtherGlobals * gOtherGlobals; GlobalManager::GlobalManager() { textureManagerC.reset(new TextureManager()); gTextureMan = textureManagerC.get(); otherGlobalsC.reset(new OtherGlobals()); gOtherGlobals = otherGlobalsC.get(); //... } //Main unit: #include "GlobalManager.h" int main() { //Automatically instantiate all //globals in one fell swoop: std::auto_ptr<GlobalManager> globals(new GlobalManager()); //...
使用这种方法,你所有的全局变量都会自动为你实例化,这样只有你的主单元在添加更多全局变量时才需要重新编译。你不再拥有我之前讨论的模式中的“隐藏”,但你拥有一个控制全局类实例化顺序的简单方法。如果你愿意,你甚至可以通过为全局组织者类创建一个析构函数,并按你希望对象释放的顺序调用智能指针上的“release”来显式控制销毁顺序。
希望以上讨论为你实现全局对象提供了更多选择。一如既往,使用对你有效的方法,并尽量减少宗教战争 :)
附录 #2 - 不完整类型持有器
在重构一些代码时,我想知道 C++11 是否有比 Allan Griffith 的 grin_ptr
更好的智能指针。根据 Howard Hinnant 的精彩概述,shared_ptr
似乎是最好的选择。它比 C++11 早出现,所以我的调查有点慢,但我的借口是忙!
无论如何,好奇心导致了工作,所以我把附加的 Visual Studio 解决方案组合在一起进行查找。如果你将其移植到 2013 之前的环境,你可能需要注释掉 unique_ptr
部分,但我认为这是唯一可能导致问题的 C++11 部分。
最关键的是,在我的系统上,grin_ptr
的速度大约是 shared_ptr
的 1.27 倍。
根据 Hinnant 的表格,unique_ptr
在销毁时需要一个完整类型。我的好奇心促使我添加了一个测试,使用了一个我认为是不完整类型的类型,并且它奏效了——与 grin_ptr
版本相比,时间缩短了 2/3。根据 Hinnant 的表格以及我的不完整类型确实调用了析构函数的事实,要么我做错了什么,Hinnant 是不正确的,要么微软的实现是非标准的。如此快的速度足以考虑在未来的代码中替换 grin_ptr
,甚至在出现的时候替换旧代码。但它不值得费周折去更改,因为我从未在时间关键的循环中使用过 grin_ptr
。