1. 程式人生 > >(轉)從信息隱藏的一個需求看C++接口與實現的分離

(轉)從信息隱藏的一個需求看C++接口與實現的分離

要求 member 不可 所有 stack log virtual overflow int

原文地址https://blog.csdn.net/tonywearme/article/details/6926649

讓我們從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.h
class A
{
public:
X getX();
Y getY();
Z getZ();
};

// A.cpp
class 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.h
class 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.h
class AImpl
{
public:
X getX();
Y getY();
Z getZ();
..
private:
X x;
Y y;
Z z;
};

// A.h
class 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.h
class A
{
public:
virtual ~A();
virtual X getX() = 0;
virtual Y getY() = 0;
virtual Z getZ() = 0;
..
};

class AConcrete: public A
{ ... };
小結:
盡量依賴對象的聲明而不是定義,這樣的松耦合可以有效降低編譯時的依賴。
能夠完全隱藏類的實現,並減少編譯依賴的兩種方法:Pimpl、Interface。

(轉)從信息隱藏的一個需求看C++接口與實現的分離