忙完毕业的事情了。更新继续!
前置声明
链接整理
1、出错代码
//错误代码。报错:error C2079: 'b' uses undefined class 'B'
class B;
class A{
public:
B b;
};
class B{
public:
A a;
};
错误原因:C++编译器自上而下编译源文件的时候,对每一个数据的定义,总是需要知道定义的数据类型的大小。将A中的b更改为B指针类型之后,由于在特定的平台上,指针所占的空间是一定的(在Win32平台上是4字节),这样可以通过编译
2、不同头文件之间的相互引用会产生重复编译
//出错代码。报错:error C2501: 'A' : missing storage-class or type specifiers
//文件A.h中的代码
#pragma once
#include "B.h"
class A{
public:
B* b;
};
//文件B.h中的代码
#pragma once
#include "A.h"
class B{
public:
A* a;
};
对上述代码添加前置声明之后,去掉A.h和B.h中的#include行,发现没有出现新的错误
//文件A.h中的代码
#pragma once
class B;
class A{
public:
B* b;
};
//文件B.h中的代码
#pragma once
class A;
class B{
public:
A* a;
};
3、两点原则
第一个原则: 如果可以不包含头文件,那就不要包含,这时候前置声明可以解决问题,如果使用的仅仅是一个类的指针,没有使用这个类的具体对象(非指针),也没有访问到类的具体成员,那么前置声明就可以了,因为指针这一数据类型的大小是特定的,编译器可以获知.
第二个原则: 尽量在CPP文件中包含头文件,而不要在头文件中包含。假设类A的一个成员是一个指向类B的指针,在类A的头文件中使用了类B的前置声明,那么在A的实现中我们需要访问B的具体成员,因此需要包含头文件,那么我们应该在类A的实现部分(CPP文件)包含类B的头文件而非声明部分(H文件)。
4、前置声明的好处
- 当我们在类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的容器里包含的是类的对象而不是指针的时候,这个就特别有用了。
在前置声明时,我们只能使用的就是类的指针和引用(因为引用也是基于指针的实现的)。
5、为什么我们前置声明时,只能使用类型的指针和引用呢?
如果你回答到:那是因为指针是固定大小,并且可以表示任意的类型,那么可以给你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 a;
};
那么我们的b.h也需要重新编译。
如果是这样的:
//b.h
class A;
class B {
...
private:
A *a;
};
那么我们的b.h就不需要重新编译。
像我们这样前置声明类A:
class A;
是一种不完整的声明,只要类B中没有执行需要了解类A的大小或者成员的操作,则这样的不完整声明允许声明指向A的指针和引用。
而在前一个代码中的语句
A a;
是需要了解A的大小的,不然是不可能知道如果给类B分配内存大小的,因此不完整的前置声明就不行,必须要包含a.h来获得类A的大小,同时也要重新编译类B。
再回到前面的问题,使用前置声明只允许的声明是指针或引用的一个原因是只要这个声明没有执行需要了解类A的大小或者成员的操作就可以了,所以声明成指针或引用是没有执行需要了解类A的大小或者成员的操作的。
6、可以使用前置声明来取代包括头文件的各种情况
首先,我们为什么要包括头文件?问题的回答很简单,通常是我们需要获得某个类型的定义(definition)。那么接下来的问题就是,在什么情况下我们才需要类型的定义,在什么情况下我们只需要声明就足够了?问题的回答是当我们需要知道这个类型的大小或者需要知道它的函数签名的时候,我们就需要获得它的定义。
假设我们有类型A和类型C,在哪些情况下在A需要C的定义:
- A继承至C //没有任何办法,必须要获得C的定义,因为我们必须要知道C的成员变量,成员函数。
- A有一个类型为C的成员变量 //需要C的定义,因为我们要知道C的大小来确定A的大小
- A有一个类型为C的指针的成员变量 //不需要,前置声明就可以
- A有一个类型为C的引用的成员变量 //不需要,前置声明就可以
- A有一个类型为std::list<C>的成员变量 //不需要,有可能老式的编译器需要。标准库里面的容器像list, vector,map,在包括一个list<C>,vector<C>,map<C, C>类型的成员变量的时候,都不需要C的定义。因为它们内部其实也是使用C的指针作为成员变量,它们的大小一开始就是固定的了,不会根据模版参数的不同而改变。
- A有一个函数,它的签名中参数和返回值都是类型C //不需要,只要我们没有使用到C。
- A有一个函数,它的签名中参数和返回值都是类型C,它调用了C的某个函数,代码在头文件中 //需要,我们需要知道调用函数的签名。
- A有一个函数,它的签名中参数和返回值都是类型C(包括类型C本身,C的引用类型和C的指针类型),并且它会调用另外一个使用C的函数,代码直接写在A的头文件中 //需要知道C的定义。
- C和A在同一个名字空间里面 //9和10都一样,我们都不需要知道C的定义
- C和A在不同的名字空间里面
例子——通过前置声明优化下列代码
- A.h和B.h是不能省略的,因为类Good继承他们
- C.h不能省略,因为vector容器中需要通过类C的大小决定m_cGroup的大小。
- D.h也不能省略。因为类Good里面有D类型的数据成员,因此编译器需要知道D的大小
- E.h可以省略。