首先,我们为什么要包括头文件?
问题的回答很简单,通常是我们需要获得某个类型的定义(definition)。那么接下来的问题就是,在什么情况下我们才需要类型的定义,
在什么情况下我们只需要声明就足够了?
问题的回答是当我们需要知道这个类型的大小或者需要知道它的函数签名的时候,我们就需要获得它的定义。
问题剖析(一):假设我们有类型A和类型C,在哪些情况下在A需要C的定义:
①A继承至C
②A有一个类型为C的成员变量
③A有一个类型为C的指针的成员变量
④A有一个类型为C的引用的成员变量
⑤A有一个类型为std::list
⑥A有一个函数,它的签名中参数和返回值都是类型C
⑦A有一个函数,它的签名中参数和返回值都是类型C,它调用了C的某个函数,代码在头文件中
⑧A有一个函数,它的签名中参数和返回值都是类型C(包括类型C本身,C的引用类型和C的指针类型),并且它会调用另外一个使用C的函数,代码直接写在A的头文件中
⑨C和A在同一个名字空间里面
⑩C和A在不同的名字空间里面
解析:
1:没有任何办法,必须要获得C的定义,因为我们必须要知道C的成员变量,成员函数。
(继承,必须有基类头文件,不能前置声明)
2:需要C的定义,因为我们要知道C的大小来确定A的大小,但是可以使用Pimpl惯用法来改善这一点。
(有一个类型为C成员变量,必须有基类头文件,不能前置声明)
3,4:不需要,前置声明就可以了,其实3和4是一样的,引用在物理上也是一个指针,它的大小根据平台不同,可能是32位也可能是64位,反正我们不需要知道C的定义就可以确定这个成员变量的大小。
(有一个类型为C的指针或引用的成员变量,前置声明即可)
5:不需要,有可能老式的编译器需要。标准库里面的容器像list, vector,map,在包括一个list
类型的成员变量的时候,都不需要C的定义。因为它们内部其实也是使用C的指针作为成员变量,它们的大小一开始就是固定的了,不会根据模版参数的不同而改变。
6:不需要,只要我们没有使用到C。(函数签名中参数和返回值用到类型C,前置声明即可)
7:需要,我们需要知道调用函数的签名。
8:代码解释如下
8.1 参数和返回类型都是C的引用(或指针)
C& doToC(C&);
C& doToC2(C& c) {return doToC(c);};
从上面的代码来看,A的一个成员函数doToC2调用了另外一个成员函数doToC,但是无论是doToC2,还是doToC,
它们的参数和返回类型其实都是C的引用(换成指针,情况也一样),引用的赋值跟指针的赋值都是一样,无非就是整形的赋值,所以这里即不需要知道C的大小也没有调用C的任何函数,实际上这里并不需要C的定义。
8.2 随便把其中一个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
#include
#include
C.h
#ifndef C_H
#define C_H
#include
namespace test1
{
class C
{
public:
void print() {std::cout<<"Class C"<
问题剖析(二):前置声明解决互包含问题
定义一个类 class A,这个类里面使用了类B的对象b,然后定义了一个类class 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的容器里包含的是类的对象而不是指针的时候,这个就特别有用了。
当然,在前置声明时,我们只能使用类的指针和引用(因为引用也是基于指针的实现的),** 声明成指针或引用是没有执行需要了解类A的大小或者成员的操作。
③问题: 为什么我们前置声明时,只能使用类型的指针和引用呢?
我们修改class A的定义如下:
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也需要重新编译:
//b.h
#include "a.h"
class B
{
...
private:
A a;
};
如果我们的b.h修改成这样的,那么我们的b.h则不需要重新编译:
//b.h
class A;
class B
{
...
private:
A *a;
};
像我们这样前置声明类A:
class A;
是一种不完整的声明,只要类B中没有执行需要了解类A的大小或者成员的操作,则这样的不完整声明允许声明指向A的指针和引用。
而在前一个代码中的语句
A a;
是需要了解A的大小的,不然是不可能知道如果给类B分配内存大小的,因此不完整的前置声明就不行,必须要包含a.h来获得类A的大小,同时也要重新编译类B。