一、類嵌套的疑問
C++頭文件重復包含實在是一個令人頭痛的問題,假設我們有兩個類A和B,分別定義在各自的頭文件A.h和B.h中,但是在A中要用到B,B中也要用到A,但是這樣的寫法當然是錯誤的:
class B;
class A{
public:
B b;
};
class B{
public:
A a;
};
因為在A對象中要開辟一塊屬於B的空間,而B中又有A的空間,是一個邏輯錯誤,無法實現的,在這里我們只需要把其中的一個A類中的B類型成員改成指針形式就可以避免這個無限延伸的怪圈了,為什么要更改A而不是B?因為就算你在B中做了類似的動作,也仍然會編譯錯誤,表面上這僅僅上一個先后順序的問題
為什么會這樣呢?因為C++編譯器自上而下編譯源文件的時候,對每一個數據的定義,總是需要知道定義的數據類型的大小。在預先聲明語句class B;之后,編譯器已經知道B是一個類,但是其中的數據卻是未知的,因此B類型的大小也不知道,這樣就造成了編譯失敗,VC++6.0下會得到如下編譯錯誤:
error C2079: 'b' uses undefined class 'B'
將A中的b更改為B指針類型之后,由於在特定的平台上,指針所占的空間是一定的(在Win32平台上是4字節),這樣可以通過編譯
二、不同頭文件中的類的嵌套
在實際編程中,不同的類一般是放在不同的相互獨立的頭文件中的,這樣兩個類在相互引用時又會有不一樣的問題,重復編譯是問題出現的根本原因。為了保證頭文件僅被編譯一次,在C++中常用的辦法是使用條件編譯命令在頭文件中我們常常會看到以下語句段(以VC++6.0自動生成的頭文件為例):
#IFNDEF TESTSTR
#define TESTSTR
//很多語句
#endif
意思是如果沒有定義過這個宏,那么就定義它,然后執行直到#endif的所有語句如果下次在與要這段代碼,由於已經定義了那個宏,因此重復的代碼不會被再次執行這實在是一個巧妙而高效的辦法在高版本的VC++上,還可以使用這個命令來代替以上的所有:
#pragma once
它的意思是,本文件內的代碼只被使用一次
但是不要以為使用了這種機制就全部搞定了,比如在以下的代碼中:
//文件A.h中的代碼
#pragma once
#include "B.h"
class A{
public:
B* b;
};
//文件B.h中的代碼
#pragma once
#include "A.h"
class B{
public:
A* a;
};
這里兩者都使用了指針成員,因此嵌套本身不會有什么問題,在主函數前面使用#include "A.h"之后,主要編譯錯誤如下:
error C2501: 'A' : missing storage-class or type specifiers
仍然是類型不能找到的錯誤。其實這里仍然需要前置聲明,分別添加前置聲明之后,可以成功編譯了。代碼形式如下:
//文件A.h中的代碼
#pragma once
#include "B.h"
class B;
class A{
public:
B* b;
};
//文件B.h中的代碼
#pragma once
#include "A.h"
class A;
class B{
public:
A* a;
};
這樣至少可以說明,頭文件包含代替不了前置聲明,有的時候只能依靠前置聲明來解決問題,我們還要思考一下,有了前置聲明的時候頭文件包含還是必要的嗎?我們嘗試去掉A.h和B.h中的#include行,發現沒有出現新的錯誤那么究竟什么時候需要前置聲明,什么時候需要頭文件包含呢?
三、兩點原則
頭文件包含其實是一想很煩瑣的工作,不但我們看着累,編譯器編譯的時候也很累,再加上頭文件中常常出現的宏定義感覺各種宏定義的展開是非常耗時間的,遠不如自定義函數來得速度我僅就不同頭文件源文件間的句則結構問題提出兩點原則,僅供參考:
第一個原則: 如果可以不包含頭文件,那就不要包含,這時候前置聲明可以解決問題,如果使用的僅僅是一個類的指針,沒有使用這個類的具體對象(非指針),也沒有訪問到類的具體成員,那么前置聲明就可以了,因為指針這一數據類型的大小是特定的,編譯器可以獲知.
第二個原則: 盡量在CPP文件中包含頭文件,而不要在頭文件中包含。假設類A的一個成員是一個指向類B的指針,在類A的頭文件中使用了類B的前置聲明,那么在A的實現中我們需要訪問B的具體成員,因此需要包含頭文件,那么我們應該在類A的實現部分(CPP文件)包含類B的頭文件而非聲明部分(H文件)。
四、C++的前置聲明
剛開始學習c++的人都會遇到這樣的問題:
定義一個類 class A,這個類里面使用了類B的對象b,然后定義了一個類B,里面也包含了一個類A的對象a,就成了這樣:
- //a.h
- #include "b.h"
- class A
- {
- ....
- private:
- B b;
- };
- //b.h
- #include "a.h"
- class B
- {
- ....
- private:
- A a;
- };
一編譯,就出現了一個互包含的問題了,這時就有人跳出來說,這個問題的解決辦法可以這樣,在a.h文件中聲明類B,然后使用B的指針。
- //a.h
- //#include "b.h"
- class B;
- class A
- {
- ....
- private:
- B *b;
- };
- //b.h
- #include "a.h"
- class B
- {
- ....
- private:
- A a;
- };
然后,問題就解決了。
但是,有人知道問題是為什么就被解決的嗎,也就是說,加了個前置聲明為什么就解決了這樣的問題。下面,讓我來探討一下這個前置聲明。
類的前置聲明是有許多的好處的。
我們使用前置聲明的一個好處是,從上面看到,當我們在類A使用類B的前置聲明時,我們修改類B時,只需要重新編譯類B,而不需要重新編譯a.h的(當然,在真正使用類B時,必須包含b.h)。
另外一個好處是減小類A的大小,上面的代碼沒有體現,那么我們來看下:
- //a.h
- class B;
- class A
- {
- ....
- private:
- B *b;
- ....
- };
- //b.h
- class B
- {
- ....
- private:
- int a;
- int b;
- int c;
- };
我們看上面的代碼,類B的大小是12(在32位機子上)。
如果我們在類A中包含的是B的對象,那么類A的大小就是12(假設沒有其它成員變量和虛函數)。如果包含的是類B的指針*b變量,那么類A的大小就是4,所以這樣是可以減少類A的大小的,特別是對於在STL的容器里包含的是類的對象而不是指針的時候,這個就特別有用了。
在前置聲明時,我們只能使用的就是類的指針和引用(因為引用也是居於指針的實現的)。
那么,我問你一個問題,為什么我們前置聲明時,只能使用類型的指針和引用呢?
如果你回答到:那是因為指針是固定大小,並且可以表示任意的類型,那么可以給你80分了。為什么只有80分,因為還沒有完全回答到。
想要更詳細的答案,我們看下下面這個類:
- class A
- {
- public:
- A(int a):_a(a),_b(_a){} // _b is new add
- int get_a() const {return _a;}
- int get_b() const {return _b;} // new add
- private:
- int _b; // new add
- int _a;
- };
我們看下上面定義的這個類A,其中_b變量和get_b()函數是新增加進這個類的。
那么我問你,在增加進_b變量和get_b()成員函數后這個類發生了什么改變,思考一下再回答。
好了,我們來列舉這些改變:
第一個改變當然是增加了_b變量和get_b()成員函數;
第二個改變是這個類的大小改變了,原來是4,現在是8。
第三個改變是成員_a的偏移地址改變了,原來相對於類的偏移是0,現在是4了。
上面的改變都是我們顯式的、看得到的改變。還有一個隱藏的改變,想想是什么。。。
這個隱藏的改變是類A的默認構造函數和默認拷貝構造函數發生了改變。
由上面的改變可以看到,任何調用類A的成員變量或成員函數的行為都需要改變,因此,我們的a.h需要重新編譯。
如果我們的b.h是這樣的:
- //b.h
- #include "a.h"
- class B
- {
- ...
- private:
- A a;
- };
那么我們的b.h也需要重新編譯。
如果是這樣的:
- //b.h
- class A;
- class B
- {
- ...
- private:
- A *a;
- };
那么我們的b.h就不需要重新編譯。
像我們這樣前置聲明類A:
class A;
是一種不完整的聲明,只要類B中沒有執行需要了解類A的大小或者成員的操作,則這樣的不完整聲明允許聲明指向A的指針和引用。
而在前一個代碼中的語句
A a;
是需要了解A的大小的,不然是不可能知道如果給類B分配內存大小的,因此不完整的前置聲明就不行,必須要包含a.h來獲得類A的大小,同時也要重新編譯類B。
再回到前面的問題,使用前置聲明只允許的聲明是指針或引用的一個原因是只要這個聲明沒有執行需要了解類A的大小或者成員的操作就可以了,所以聲明成指針或引用是沒有執行需要了解類A的大小或者成員的操作的。
這篇文章很大程度是受到Exceptional C++ (Hurb99)書中第四章 Compiler Firewalls and the Pimpl Idiom (編譯器防火牆和Pimpl慣用法) 的啟發,這一章講述了減少編譯時依賴的意義和一些慣用法,其實最為常用又無任何副作用的是使用前置聲明來取代包括頭文件。
Item 26 的Guideline - "Never #include a header when a forward declaration will suffice"
在這里,我自己總結了可以使用前置聲明來取代包括頭文件的各種情況和給出一些示例代碼。
首先,我們為什么要包括頭文件?問題的回答很簡單,通常是我們需要獲得某個類型的定義(definition)。那么接下來的問題就是,在什么情況下我們才需要類型的定義,在什么情況下我們只需要聲明就足夠了?問題的回答是當我們需要知道這個類型的大小或者需要知道它的函數簽名的時候,我們就需要獲得它的定義。
假設我們有類型A和類型C,在哪些情況下在A需要C的定義:
- A繼承至C
- A有一個類型為C的成員變量
- A有一個類型為C的指針的成員變量
- A有一個類型為C的引用的成員變量
- A有一個類型為std::list<C>的成員變量
- A有一個函數,它的簽名中參數和返回值都是類型C
- A有一個函數,它的簽名中參數和返回值都是類型C,它調用了C的某個函數,代碼在頭文件中
- A有一個函數,它的簽名中參數和返回值都是類型C(包括類型C本身,C的引用類型和C的指針類型),並且它會調用另外一個使用C的函數,代碼直接寫在A的頭文件中
- C和A在同一個名字空間里面
- C和A在不同的名字空間里面
1,沒有任何辦法,必須要獲得C的定義,因為我們必須要知道C的成員變量,成員函數。
2,需要C的定義,因為我們要知道C的大小來確定A的大小,但是可以使用Pimpl慣用法來改善這一點,詳情請
看Hurb的Exceptional C++。
3,4,不需要,前置聲明就可以了,其實3和4是一樣的,引用在物理上也是一個指針,它的大小根據平台不同,可能是32位也可能是64位,反正我們不需要知道C的定義就可以確定這個成員變量的大小。
5,不需要,有可能老式的編譯器需要。標准庫里面的容器像list, vector,map,
在包括一個list<C>,vector<C>,map<C, C>類型的成員變量的時候,都不需要C的定義。因為它們內部其實也是使用C的指針作為成員變量,它們的大小一開始就是固定的了,不會根據模版參數的不同而改變。
6,不需要,只要我們沒有使用到C。
7,需要,我們需要知道調用函數的簽名。
8,8的情況比較復雜,直接看代碼會比較清楚一些。


從上面的代碼來看,A的一個成員函數doToC2調用了另外一個成員函數doToC,但是無論是doToC2,還是doToC,它們的的參數和返回類型其實都是C的引用(換成指針,情況也一樣),引用的賦值跟指針的賦值都是一樣,無非就是整形的賦值,所以這里即不需要知道C的大小也沒有調用C的任何函數,實際上這里並不需要C的定義。
但是,我們隨便把其中一個C&換成C,比如像下面的幾種示例:

C& doToC(C&);

2.
C& doToC(C);
C& doToC2(C& c) {return doToC(c);};
3.
C doToC(C&);
C& doToC2(C& c) {return doToC(c);};
4.
C& doToC(C&);
C doToC2(C& c) {return doToC(c);};
無論哪一種,其實都隱式包含了一個拷貝構造函數的調用,比如1中參數c由拷貝構造函數生成,3中doToC的返回值是一個由拷貝構造函數生成的匿名對象。因為我們調用了C的拷貝構造函數,所以以上無論那種情形都需要知道C的定義。
9和10都一樣,我們都不需要知道C的定義,只是10的情況下,前置聲明的語法會稍微復雜一些。
最后給出一個完整的例子,我們可以看到在兩個不同名字空間的類型A和C,A是如何使用前置聲明來取代直接包括C的頭文件的:
A.h





//不同名字空間的前置聲明方式







//用using避免使用完全限定名





C useC(C);












C.h















