cppreference-PImpl
C++ API设计 第三章 模式 - Pimpl惯用法
Jeff Sumner最先引入了Pimpl这个术语,将其作为”pointer to implementation”(指向实现的指针)的缩写(Sutter,1999)。该技巧可以避免在头文件中暴露私有细节(见图3-1)。因此它是促进API接口和实现保持完全分离的重要机制(Sutter and Alexandrescu, 2004)。但是Pimpl并不是严格意义上的设计模式(它是受制于C++特定限制的变通方案),这种惯用法可以看作是桥接设计模式的一种特例。
如果你在阅读本书后改变了一种编程习惯,那我希望是在更多的API代码中选择使用Pimpl惯用法
使用Pimpl
Pimpl利用了C++的一个特点,即可以将类的数据成员定义为指向某个已经声明过的类型的指针。这里的类型仅仅作为名字引入,并没有被完整地定义,因此我们就可以将该类型的定义隐藏在.cpp文件中。这通常称为不透明指针,因为用户无法看到它所指向的对象细节。本质上,Pimpl是一种同时在逻辑上和物理上隐藏私有数据成员与函数的办法。
下面举例说明,考虑以下”自动定时器”API。它是一个具名对象,当被销毁时打印出其生存时间。
// autotimer.h#ifdef _WIN32#include <windows.h>#else#include <sys/time.h>#endif#include <string>class AutoTimer{public:/// Create a new timer object with a human-readable nameexplicit AutoTimer(const std::string &name);/// On destruction, the timer reports how long it was aliveAutoTimer();private:// Return how long the object has been alivedouble GetElapsed() const;std::string mName;#ifdef _WIN32DWORD mStartTime;#elsestruct timeval mStartTime;#endif};
这个API违反了前面章节讲述的许多重要特征。例如,它包含与平台相关的定义,暴露了定时器在不同平台上存储的底层细节,任何人都可以从头文件中看到这些平台定义。但此API也有可取之处,它仅仅将必要的方法暴露为公有(即构造函数和析构函数)。并将其余的方法和数据成员标记为私有。然而C++要求将这些私有成员声明在公有头文件中,这就是为什么该API包含平台相关的#if指令的原因。
设计者的真正目的是将所有私有成员隐藏在,.cpp文件中,这样就不再需要包含任何繁琐的平台相关项了。Pimpl惯用法将所有私有成员放置在一个类(或结构体)中,这个类在头文件中前置声明,在.cpp文件中定义,以此达到这一目的。例如,可以像下面这样使用Pimpl重构之前的头文件。
// autotimer.h#include <string>class AutoTimer{public:explicit AutoTimer(const std::string &name);~AutoTimer();private:class Impl;Impl *mImpl;};
在API更简洁了!不再需要与平台相关的预处理指令,他人也不能通过检查头文件了解类的任何私有成员了。在实现方面,AutoTimer的构造函数现在必须分配AutoTimer::Imp1类型的变量,并要在析构函数中销毁它。所有私有成员必须通过mlmpl指针访问。在大部分实际案例中,使用这种简洁的、不包含实现的API利远大于弊。为了把这个例子补充完整,接下来看看与采用Pimpl的类协同工作的底层实现。由于与平台相关的并ifdef语句的存在,最终的.cpp文件看起来有些凌乱,但重要的是,现在这种凌乱完全包含在了.cpp文件中。
// autotimer.cpp#include "autotimer.h"#include <iostream>#if _WIN32#include <windows.h>#else#include <sys/time.h>#endifclass AutoTimer::Impl{public:double GetElapsed() const{#ifdef _WIN32return (GetTickCount() - mStartTime) / 1e3;#elsestruct timeval end_time;gettimeofday(&end_time, NULL);double t1 = mStartTime.tv_usec / 1e6 + mStartTime.tv_sec;double t2 = end_time.tv_usec / 1e6 + end_time.tv_sec;return t2 - t1;#endif}std::string mName;#ifdef _WIN32DWORD mStartTime;#elsestruct timeval mStartTime;#endif};AutoTimer::AutoTimer(const std::string &name) :mImpl(new AutoTimer::Impl()){mImpl->mName = name;#ifdef _WIN32mImpl->mStartTime = GetTickCount();#elsegettimeofday(&mImpl->mStartTime, NULL);#endif}AutoTimer::~AutoTimer(){std::cout << mImpl->mName << ": took " << mImpl->GetElapsed()<< " secs" << std::endl;delete mImpl;mImpl = NULL;}
以上是AutoTimer::Impl类的定义。包含了暴露在原有头文件中的所有私有方法和变量。AutoTimer的构造函数分配了一个新的AutoTimer::Impl对象并初始化其成员,而析构函数负责销毁该对象。
在上面的设计中,Impl类声明为AutoTimer类的私有内嵌类。将其声明为内嵌类是为了避免与该实现相关的符号污染全局命名空间,而将其声明为私有的表示它不会污染类的公有API。将其声明为私有会带来限制,即只有AutoTimer的方法可以访问Impl的成员. .cpp文件中的其他类或自由函数不能访问Impl。
如果此举确实带来较大的限制,还可以考虑将其声明为公有的内嵌类,如下例所示∶
// autotimer.hclass AutoTimer{public:explicit AutoTimer(const std::string &name);~AutoTimer();// allow access from other classes/functions in autotimer.cppclass Impl;private:Impl *mImpl;};
一个值得考虑的设计问题是在Impl类中放置多少逻辑。可以选择以下方法∶
(1)仅私有成员变量;
(2)私有成员变量和方法;
(3)公有类的所有方法,其中公有方法只是对lmpl类中的等价方法进行简单包装。
每种选项有可能适用于不同的情形。通常情况下推荐选项2,将所有私有成员和私有方法放置在Impl类中。这样可以保持数据和操作这些数据的方法的封装性,从而避免在公有头文件中声明私有方法。注意,之前给出的例子采用了此设计方式,它将GetElapsed()方法放置在Impl类中。Herb Sutter提出了这种方式的两个注意事项(Sutter,1999)。
(1)不能在实现类中隐藏私有虚方法。它们必须出现在公有类中,以保证任何派生类都能够覆盖它们。
(2)虽然可以将公有类传递给需要使用它的实现类的方法,但必要时可以在实现类中增加指回公有类的指针,以便Impl类调用公有方法。
