1. 程式人生 > >C++---前置類宣告

C++---前置類宣告

一、類巢狀的疑問

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,就成了這樣: 

  1. //a.h  
  2. #include "b.h"  
  3. class A  
  4. {  
  5. ....  
  6. private:  
  7.     B b;  
  8. };  
  9. //b.h  
  10. #include "a.h"  
  11. class B  
  12. {  
  13. ....  
  14. private:  
  15.     A a;  
  16. };  

一編譯,就出現了一個互包含的問題了,這時就有人跳出來說,這個問題的解決辦法可以這樣,在a.h檔案中宣告類B,然後使用B的指標。

  1. //a.h   
  2. //#include "b.h"  
  3. class B;   
  4. class A   
  5. {  
  6.  ....   
  7. private:  
  8.  B *b;   
  9. };   
  10. //b.h   
  11. #include "a.h"   
  12. class B  
  13. {  
  14.  ....   
  15. private:  
  16.  A a;   
  17. };  

然後,問題就解決了。

但是,有人知道問題是為什麼就被解決的嗎,也就是說,加了個前置宣告為什麼就解決了這樣的問題。下面,讓我來探討一下這個前置宣告。

類的前置宣告是有許多的好處的。

我們使用前置宣告的一個好處是,從上面看到,當我們在類A使用類B的前置宣告時,我們修改類B時,只需要重新編譯類B,而不需要重新編譯a.h的(當然,在真正使用類B時,必須包含b.h)。

另外一個好處是減小類A的大小,上面的程式碼沒有體現,那麼我們來看下:

  1. //a.h  
  2. class B;  
  3. class A  
  4. {  
  5.     ....  
  6. private:  
  7.     B *b;  
  8. ....  
  9. };  
  10. //b.h  
  11. class B  
  12. {  
  13. ....  
  14. private:  
  15.     int a;  
  16.     int b;  
  17.     int c;  
  18. };  

我們看上面的程式碼,類B的大小是12(在32位機子上)。

如果我們在類A中包含的是B的物件,那麼類A的大小就是12(假設沒有其它成員變數和虛擬函式)。如果包含的是類B的指標*b變數,那麼類A的大小就是4,所以這樣是可以減少類A的大小的,特別是對於在STL的容器裡包含的是類的物件而不是指標的時候,這個就特別有用了。

在前置宣告時,我們只能使用的就是類的指標和引用(因為引用也是居於指標的實現的)。

那麼,我問你一個問題,為什麼我們前置宣告時,只能使用型別的指標和引用呢?

如果你回答到:那是因為指標是固定大小,並且可以表示任意的型別,那麼可以給你80分了。為什麼只有80分,因為還沒有完全回答到。

想要更詳細的答案,我們看下下面這個類:

  1. class A  
  2. {  
  3. public:  
  4.     A(int a):_a(a),_b(_a){} // _b is new add  
  5.       
  6.     int get_a() const {return _a;}  
  7.     int get_b() const {return _b;} // new add  
  8. private:  
  9.     int _b; // new add  
  10.     int _a;  
  11. };   

我們看下上面定義的這個類A,其中_b變數和get_b()函式是新增加進這個類的。

那麼我問你,在增加進_b變數和get_b()成員函式後這個類發生了什麼改變,思考一下再回答。

好了,我們來列舉這些改變:

第一個改變當然是增加了_b變數和get_b()成員函式;

第二個改變是這個類的大小改變了,原來是4,現在是8。

第三個改變是成員_a的偏移地址改變了,原來相對於類的偏移是0,現在是4了。

上面的改變都是我們顯式的、看得到的改變。還有一個隱藏的改變,想想是什麼。。。

這個隱藏的改變是類A的預設建構函式和預設拷貝建構函式發生了改變。

由上面的改變可以看到,任何呼叫類A的成員變數或成員函式的行為都需要改變,因此,我們的a.h需要重新編譯。

如果我們的b.h是這樣的:

  1. //b.h  
  2. #include "a.h"  
  3. class B  
  4. {  
  5. ...  
  6. private:  
  7.     A a;  
  8. };  

那麼我們的b.h也需要重新編譯。

如果是這樣的: 

  1. //b.h  
  2. class A;  
  3. class B  
  4. {  
  5. ...  
  6. private:  
  7.     A *a;  
  8. };   

那麼我們的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的定義:

  1. A繼承至C
  2. A有一個型別為C的成員變數
  3. A有一個型別為C的指標的成員變數
  4. A有一個型別為C的引用的成員變數
  5. A有一個型別為std::list<C>的成員變數
  6. A有一個函式,它的簽名中引數和返回值都是型別C
  7. A有一個函式,它的簽名中引數和返回值都是型別C,它呼叫了C的某個函式,程式碼在標頭檔案中
  8. A有一個函式,它的簽名中引數和返回值都是型別C(包括型別C本身,C的引用型別和C的指標型別),並且它會呼叫另外一個使用C的函式,程式碼直接寫在A的標頭檔案中
  9. C和A在同一個名字空間裡面
  10. 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的情況比較複雜,直接看程式碼會比較清楚一些。

            C& doToC(C&);
            C& doToC2(C& c) {return doToC(c);};

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

              1.
                C& doToC(C&);
              C& doToC2(C c) {return 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

#pragma once
#include <list>
#include <vector>
#include <map>
#include <utility>
    //不同名字空間的前置宣告方式
namespace test1
{
          class C;
}

namespace test2
{   
       //用using避免使用完全限定名
    using test1::C;
    
    class A 
    {
    public:
              C   useC(C);
            C& doToC(C&);
            C& doToC2(C& c) {return doToC(c);};
                         
    private:
            std::list<C>    _list;
            std::vector<C>  _vector;
            std::map<C, C>  _map;
            C*              _pc;
            C&              _rc;
    
    };
}


C.h

#ifndef C_H
#define C_H
#include <iostream>

namespace test1
{
          
    class C
    {
    public:
           void print() {std::cout<<"Class C"<<std::endl;}
    };

}

#endif // C_H