C++的前置聲明與Exceptional C++中減少編譯時依賴的意義和一些慣用法

1.

剛開始學習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的大小或者成員的操作的。

 

 

2.

這篇文章很大程度是受到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.
                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
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章