讓我們從stackoverflow上一個同學的問題來開始。問題的原型是這樣的(原問題見:class member privacy and headers in C++):
Portaljacker:“有一個類A, 有一些共有成員函數和私人資料,如下所示。”
class A{public: X getX(); Y getY(); Z getZ(); ..private: X god; Y damn; Z it;};
“可是我不想讓使用這個類的使用者看到我的私人資料,應該怎麼做?可能是因為擔心別人嘲笑我給變數起的名字太難聽!哎,可是這關他們什麼事呢!我試過把這三個成員變數放進另一個標頭檔中,就像下面那樣,可是編譯器報錯- - 我該怎麼辦?!”
// A.hclass A{public: X getX(); Y getY(); Z getZ();};// A.cppclass A{private: X god; Y damn; Z it;};
剛看到這個問題的時候,覺得它很幼稚。首先,C++一個類的定義必須包含該類所有的成員函數和變數,而不像一個名字空間裡的不同函數那樣可以自由分布在不同的源檔案中。其次,這些私人成員即使對調用者可見,又怎麼樣?反正它們是私人的,使用者怎麼也不可能直接存取它們。
然而,我錯了。
Portaljacker的這個需要實際上是很合情合理的。試想,調用者一般是這樣使用類A的。
// main.cpp#include "A.h"int main(){ A a; X x = a.getX(); Y y = a.getY(); Z z = a.getZ(); .. return 0;}
通常情況下調用者必須要包含A的定義所在的標頭檔才能順利通過編譯,也就是說建立了一個編譯依賴關係:main.cpp -> A.h。這樣,任何A.h檔案中的變化都將導致main.cpp重新編譯,即使改變的只是類A中的私人變數(比如名稱改變)。這非常糟糕,因為一個類的私人資料屬於它的實現細節(implementation details),理想情況下應該隱藏起來,它的變化對於調用者不可見。哦,不知道你是否曾經遇到過這樣一個工程,裡面有成百上千的源檔案。你只是改變了一個小小的標頭檔,結果發現項目中的大多數檔案都重新編譯,幾分鐘都沒有編譯完。
其實Portaljacker提出了一個很好的問題。問題的關鍵在於如何把實現的細節隱藏起來。這樣,調用者既不會看到任何類內部的實現,也不會因為實現的任何改變而被迫重新編譯。
在討論問題的解決方案之前,有必要回過頭來看看為什麼Portaljacker同學的方法行不通。他是把同一個類的共有成員和私人成員風的定義分別放到了兩個同名類的定義中(見上)。
我聽到了,你說肯定不行。沒錯,為什麼呢?”因為類的定義不能分割開。。“ 好吧,可是為什麼呢?”C++就是這樣的,常識!“ 資深一些的程式員甚至會翻到C++標準的某一頁說,”喏,這就是標準“。我們中的很多人(包括我),學習一門語言的時候都是書上(或者老師)說什麼就是什麼,只要掌握了正確使用就行,很少有人會去想一下這規則背後的原因是什麼。
回到正題。C++之所以不允許分割類定義的一大原因就是編譯期需要確定對象的大小。考慮上面的main函數,在類定義分割開的情況下,這段代碼將無法編譯。因為編譯器在編譯”A a"的時候需要知道對象a有多大,而這個資訊是通過查看A的定義得來的。而此時類的私人成員並不在其中,編譯器將無法確定a的大小。注意,Java中並不存在這樣的問題,因為Java所有的對象預設都是引用,類似於C++中的指標,編譯期並不需要知道對象的大小。
介面與實現的分離
好了,現在讓我們回到需要解決的問題上:
- 不希望使用者可以看到類內部的實現(比如有多少個私人資料,它們是什麼類型,名字是什麼等等)。
- 除了介面,任何類的改變不應引起調用者的重新編譯。
解決這些問題的方法就是恰當地將實現隱藏起來。為了完整性,我們來看看幾個常見的介面與實現分離的技術,它們對於資訊隱藏的支援力度是不一樣的,也不是都能解決以上所有的問題。
一、使用私人成員
類的介面作為共有,所有的實現細節作為私人。這也是C++物件導向思想的精髓。通過將所有實現封裝成私人,這樣當類發生改變時,調用者不需要改變任何代碼,除非類的公用介面發生了變化。然而,這樣的分離只是最初步的,因為它可能會導致調用者重新編譯,即使共有介面沒有發生變化。
#include "X.h"#include "Y.h"#include "Z.h"class A{// 介面部分公有public: X getX(); Y getY(); Z getZ(); ..// 實現部分私人private: X god; Y damn; Z it;};
二、依賴對象的聲明(declaration)而非定義(definition)
在前一種方法中,類A與X,Y,Z之間是緊耦合的關係。如果類A使用指標而非對象的話,類A並不需要包含X,Y,Z的定義,簡單的向前聲明(forward declaration)就可以。
// A.hclass X;class Y;class Z;class A{public: X getX(); Y getY(); Z getZ(); ..private: X* god; Y* damn; Z* it;};
這樣,當X,Y或者Z發生變化的時候,A的調用者(main.cpp)不需要重新編譯,這樣可以有效阻止級聯依賴的發生。在前一種方法中,若X改變,包含A.h的所有源檔案都需要重新編譯。注意,在聲明一個函數的時候,即使函數的參數或者傳回值中有傳值拷貝,也不需要對應類的定義(上例中,不需要包含X,Y,Z的標頭檔)。只有當函數實現的時候才需要。
三、Pimpl模式
一個更好的方法是把一個類所有的實現細節都“代理”給另一個類來完成,而自己只負責提供介面。介面的實現則是通過調用Impl類的對應函數來實現。Scott Meyers稱這是“真正意義上介面與實現的分離”。
// AImpl.hclass AImpl{public: X getX(); Y getY(); Z getZ(); ..private: X x; Y y; Z z;};// A.hclass X;class Y;class Z;class AImpl;class A{public: // 可能的實現: X getX() { return pImpl->getX(); } X getX() Y getY() Z getZ(); ..private: std::tr1::shared_ptr<AImpl> pImpl;};
我們來看一下,這種方法能否滿足我們的兩個要求。首先,因為任何實現細節都是封裝在AImpl類中,所以對於調用端來說是不可見的。其次,只要A的介面沒有變化,調用端都不需要重新編譯。很好!當然,天下沒有免費的午餐,這種方法也是需要付出代價的。代價就是多了一個AImpl類需要維護,並且每次調用A的介面都將導致對於AImpl相應介面的間接調用。所以,遇到這樣的問題,想一想,效率和資料的封裝,哪個對於你的代碼更重要。
四、Interface類
另一個能夠同時滿足兩個需求的方法是使用介面類,也就是不包含私人資料的抽象類別。調用端首先獲得一個AConcrete對象的指標,然後通過介面指標A*來進行操作。這種方法的代價是可能會多一個VPTR,指向虛表。
// A.hclass A{public: virtual ~A(); virtual X getX() = 0; virtual Y getY() = 0; virtual Z getZ() = 0; ..};class AConcrete: public A{ ... };
小結:
- 盡量依賴對象的聲明而不是定義,這樣的松耦合可以有效降低編譯時間的依賴。
- 能夠完全隱藏類的實現,並減少編譯依賴的兩種方法:Pimpl、Interface。