2016年1月11日 星期一

C++: 善用 PIMPL 技巧

PIMPL (Pointer to Implementation ) 技巧已經出現十幾年了,可是小弟的職業生涯中卻很少看到有人使用,決定來寫篇文章推廣一下。這個手法可以解決/改善 C++ coding 常碰到的 2 大問題:
  1. class 增加 private/protected member,使用此 class 的相關 .cpp(s) 需要重新編譯
  2. 定義衝突與跨平台編譯問題

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 的人就已經不多了。

補充:參考資料

C++ Coding Standard

這本書的中文版不知道是不是翻譯問題,還是原作就有這種傾向,有些咬文嚼字的很不好懂。

Exceptional C++

這本比上面那本容易理解的多,有提到 PIMPL 實作上需要注意的地方,可惜的是已經絕版了,有趣的是前面那本作者之一也是本書作者。

上面這兩本其實缺乏的是實務面的範例,如果缺乏實務面的說明,PIMPL 對初學者來講就只是炫技罷了,本文多少做了一些補充。

Luke, use the PIMPL!

1 則留言: