References
C++ Coding Standard
這本書的中文版不知道是不是翻譯問題,還是原作就有這種傾向,有些咬文嚼字的很不好懂。
Exceptional C++
這本比上面那本容易理解的多,有提到 PIMPL 實作上需要注意的地方,可惜的是已經絕版了,有趣的是前面那本作者之一也是本書作者。
上面這兩本其實缺乏的是實務面的範例,如果缺乏實務面的說明,PIMPL 對初學者來講就只是炫技罷了,本文多少做了一些補充。
PIMPL (Pointer to Implementation ) 技巧已經出現十幾年了,可是小弟的職業生涯中卻很少看到有人使用,決定來寫篇文章推廣一下。這個手法可以解決/改善 C++ coding 常碰到的 2 大問題:
class 增加 private/protected member,使用此 class 的相關 .cpp(s) 需要重新編譯
定義衝突與跨平台編譯問題
Q1. class 增加 private/protected member,使用此 class 的相關 .cpp(s) 全部需要重新編譯
假設我們有一個 A.h(class A),並且有 A/B/C/D 4 個 .cpp 引用他,他們的關係如下圖:
假如 A class 增加了 private/protected member,A/B/C/D.cpp 全部都要重新編譯。因為 make 是用檔案的時間戳記來判斷是否要重新編譯,當 make 發現 A.h 比 A/B/C/D.cpp 4個檔案新,就會呼叫 compiler 重新編譯他們,就算你的 C++ compiler 非常聰明,知道這 B/C/D 檔案只能存取 A class public member,make 還是要把 compiler 叫起來檢查。三個檔案也許還好,那五十個,一百個呢?
//a.h
#ifndef A_H
#define A_H
#include <memory>
class A
{
public:
A();
~A();
void doSomething();
private:
struct Impl;
std::auto_ptr<impl> m_impl;
};
#endif
C++ 有一定程度的人都知道,在尚未取用指標指向的實體前,我們可以用 forward declaration 的技巧告訴 compiler 這是個「指向 class/struct 的指標」,而不用顯露 struct/class 的佈局。
在這裡我們把原本的 private member 封裝到 struct A::Impl 裡,用一個不透明的指標(m_impl) 指向他,auto_ptr 是個 smart pointer(from STL),會在 A class object 銷毀時連帶將資源銷毀還給系統。
a.cpp 如下
//a.cpp
#include <stdio.h>
#include "a.h"
struct A::Impl {
int m_count;
Impl();
~Impl();
void doPrivateThing();
};
A::Impl::Impl() : m_count(0) {
}
A::Impl::~Impl() {}
void A::Impl::doPrivateThing() {
printf("count = %d\n", ++m_count);
}
A::A() : m_impl(new Impl) {}
A::~A() {}
void A::doSomething() {
m_impl->doPrivateThing();
}
上面我們可以看到 A private data/function member 全部被封裝到 struct A::Impl 裡,如此一來無論 private member 如何改變都只會重新編譯 A.cpp,而不會影響 B/C/D.cpp,當然有時候還是會有例外,不過大部分情況下可以替你省下大把的編譯時間,越大的專案越能感受到效果。
Q2. 定義衝突與跨平台編譯問題
如果你運氣很好公司配給你 8 Cores CPU、SSD、32G DDRAM,大概會覺得 PIMPL 是脫褲子放屁。
但定義衝突/跨平台問題不是高速電腦能夠解決的,甚至會讓你的專案卡住。舉個例子,你想在 Windows 上開啟 framework(例如 Qt) 沒有支援的特殊裝置或檔案,你大概會這樣做:
//foo.h
#ifndef FOO_H
#define FOO_H
#include <windows.h>
class Foo {
public:
Foo();
~Foo();
void doSomething();
private:
HANDLE m_handle;
};
#endif
Foo private data member: m_handle 對應到某個特別的檔案或裝置,某天你想把 Foo 移植到Linux,因為 Linux 是用 int 作為 file descriptor,為了與 Windows 的定義區隔,最直接的手段就是用巨集:
//foo.h
#ifndef FOO_H
#define FOO_H
#ifdef _WIN32
#include <windows.h>
#else
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#endif
class Foo {
public:
Foo();
~Foo();
void doSomething();
private:
#ifdef _WIN32
HANDLE m_handle;
#else
int m_handle;
#endif
};
#endif
這樣做會有什麼問題?
windows.h 是個巨大的 header file,有可能會增加引用此 header file 的其他 .cpp(s) 編譯時間,而實際上這些 .cpp 並不需要 windows.h 裡面的資訊。
windows.h 會與 framework 衝突,雖然大部分的 framework 極力避免發生這種事,但往往專案成長變大後常常出現這類編譯錯誤(Linux 也可能發生)。
對於 Linux 用戶,Windows 那些 header file 是多餘的,對於 Windows 用戶 Linux header files 是多餘的,沒必要也不該知道這些細節。
Please minimize your header file!
「請最小化你的 header file」,這是站長 10 幾年來最重要的工作心得之一。不過有些人會質疑那我不是不能用 inline function?也許是站長孤陋寡聞,站長 10 幾年來還沒碰過非得用 inline 才能解決的問題,而且實務經驗中如 C++ Builder 甚至用 inline 還會發生奇怪的 bug(改成一般的 member function 就好了),事實上很多認為用 inline 會加快的程式都是自由心證缺乏嚴格測試。目前非得放在 header file 大概只有 template,但實務上的很少看過有人自己實作 template,善用 STL 的人就已經不多了。
Yunfei Zu 前辈对 Pimpl 的解释
C++的Pimpl惯用法或者说Pimpl模式,又被称为编译防火墙,是一种在头文件中隐藏实现的方式。Pimpl很古老,可能在标准C++诞生之前就有了这种用法,其间争论也早已尘埃落定,用和不用各有利弊,主要还是看组织内部的规范和项目的需要。最近Team一直同时在两个subsystem下工作,两个subsystem的code base一个用了Pimpl一个没有用,是以在Team中产生了到底要不要用的争论。虽然SA的决定是维持现状,但还是总结下Pimpl的相关知识,以备参考。
Pimpl 没有固定的形式,有的很复杂,如Qt中的private class和D-Pointer的结构。而Team在项目中用到的相对很简单,只是一个智能指针加一个Inner Class, 基本结构如下。
// Foo.h
class Foo {
public:
Foo();
virtual ~Foo();
private:
class Pimpl;
boost::scoped_ptr<Pimpl> _pimpl;
};
// Foo.cpp
class Foo::Pimpl {
public:
// data or functions
}
Foo::Foo() : _pimpl(new Pimpl) {}
Foo::~Foo() {}
使用这种结构的好处:
成员变量的修改不会影响类的头文件,避免重新编译所有inclue类头文件的模块
类的头文件不需要include 成员变量的头文件,减少编译依赖,加快编译速度
更好的封装类的实现细节
而相应的缺点:
增加了代码复杂度
造成代码可读性下降
由于指针间接调用造成的性能下降
至于要不要使用Pimpl, 要视情况而定。如果你的工程更注重减少依赖,隐藏实现,Pimpl正适合你。相反如果你的工程中的类含有很多虚函数,又会被大量调用,最好考虑下Pimpl会不会带来性能问题。最近就遇到一个例子,一个库和Qt有冲突,在moc class的头文件中inclue这个库的头文件就会有编译问题。最后就是用Pimpl的方法解决了头文件的依赖。