C++类的前置声明

时间:2021-05-31 20:03:06

一、类嵌套的疑问

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++类的前置声明            C& doToC(C&);
C++类的前置声明            C& doToC2(C& c)  {return doToC(c);};

从上面的代码来看,A的一个成员函数doToC2调用了另外一个成员函数doToC,但是无论是doToC2,还是doToC,它们的的参数和返回类型其实都是C的引用(换成指针,情况也一样),引用的赋值跟指针的赋值都是一样,无非就是整形的赋值,所以这里即不需要知道C的大小也没有调用C的任何函数,实际上这里并不需要C的定义。
但是,我们随便把其中一个C&换成C,比如像下面的几种示例:

C++类的前置声明              1.
                C& doToC(C&);
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

C++类的前置声明#pragma once
C++类的前置声明#include <list>
C++类的前置声明#include <vector>
C++类的前置声明#include <map>
C++类的前置声明#include <utility>
    //不同名字空间的前置声明方式
C++类的前置声明namespace test1
C++类的前置声明{
C++类的前置声明          class C;
C++类的前置声明}
C++类的前置声明
C++类的前置声明namespace test2
C++类的前置声明{   
       //用using避免使用完全限定名
C++类的前置声明    using test1::C;
C++类的前置声明    
C++类的前置声明    class A 
C++类的前置声明    {
C++类的前置声明    public:
              C   useC(C);
C++类的前置声明            C& doToC(C&);
C++类的前置声明            C& doToC2(C& c) {return doToC(c);};
C++类的前置声明                         
C++类的前置声明    private:
C++类的前置声明            std::list<C>    _list;
C++类的前置声明            std::vector<C>  _vector;
C++类的前置声明            std::map<C, C>  _map;
C++类的前置声明            C*              _pc;
C++类的前置声明            C&              _rc;
C++类的前置声明    
C++类的前置声明    };
C++类的前置声明}


C.h

C++类的前置声明#ifndef C_H
C++类的前置声明#define C_H
C++类的前置声明#include <iostream>
C++类的前置声明
C++类的前置声明namespace test1
C++类的前置声明{
C++类的前置声明          
C++类的前置声明    class C
C++类的前置声明    {
C++类的前置声明    public:
C++类的前置声明           void print() {std::cout<<"Class C"<<std::endl;}
C++类的前置声明    };
C++类的前置声明
C++类的前置声明}
C++类的前置声明
C++类的前置声明#endif // C_H