C++的前置声明注意点

转载自:https://www.cnblogs.com/King-Gentleman/p/5081159.html  原作者:King先生

一、类嵌套的疑问

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.h  
 #include "a.h"  
 class B  
{  
....  
 private:  
 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 使用类 B 的前置声明时,我们修改类 B 时,只需要重新编译类 B,而不需要重新编译 a.h 的(当然,在真正使用类 B 时,必须包含 b.h)。

另外一个好处是减小类 A 的大小,上面的代码没有体现,那么我们来看下:

//a.h  

class B;  

class A  

{  

....  

private:  

*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;  

};  

那么我们的 b.h 也需要重新编译。

如果是这样的: 

//b.h  

class A;  

class B  

{  

...  

private:  

*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 的定义:

  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 的情况比较复杂,直接看代码会比较清楚一些。

CdoToC(C&);  
CdoToC2(Cc) {return doToC(c);};

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

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

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

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

无论哪一种,其实都隐式包含了一个拷贝构造函数的调用,比如 1 中参数 c 由拷贝构造函数生成,3 中 doToC 的返回值是一个由拷贝构造函数生成的匿名对象。因为我们调用了 C 的拷贝构造函数,所以以上无论那种情形都需要知道 C 的定义。

9 和 10 都一样,我们都不需要知道 C 的定义,只是 10 的情况下,前置声明的语法会稍微复杂一些。 最后给出一个完整的例子,我们可以看到在两个不同名字空间的类型 A 和 C,A 是如何使用前置声明来取代直接包括 C 的头文件的: A.h

// 不同名字空间的前置声明方式  
namespace test1  
{  
class C;  
}  

namespace test2  
{     
   // 用 using 避免使用完全限定名  
    using test1::C;  
     
    class A   
    {  
    public:  
              C   useC(C);  
            CdoToC(C&);  
            CdoToC2(Cc) {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

 

原文地址:https://www.cnblogs.com/Rainingday/p/13869853.html