C++中的前置声明
最近在阅读orb-slam2的源码,一眼就看到了代码中的前置声明,这里做个笔记复习一下。
最新的Google C++ Style Guide对前置声明的建议是:
Avoid using forward declarations where possible. Instead, include the headers you need.
Note 1
简单来说,前置声明最大的好处就是『节省编译时间』。毕竟C++的编译时间长已经是一个臭名昭著人人喊打的问题。但对于Google来说,这方面的效率节省就不见得那么可观了——毕竟Google内部有超大规模的分布式编译集群『Forge』。哪怕是十万以上的target,全部build一遍也就是几分钟的事情。
与此同时,前置声明带来的问题则显得更加关键:
例如,如果一个类的实现者需要把这个类改个名字/换个命名空间,出于兼容性他原本可以在原命名空间里/用原名通过using来起一个别名指向新类。然而别名不能被前向声明。内网有一份代码改动一下子试图修改总计265个头文件,就是实现者为了要改这个类的名字而不得不去改所有的调用处。想一想,如果这265个文件分属于50个不同的团队,你得拿到50个人的同意才能提交这份改动,想不想打人?
再举一个code style中提到的,更为严重的例子——它可能导致运行时出现错误的结果:
// b.h:
struct B {};
struct D : B {};
// good_user.cc:
#include "b.h"
void f(B*);
void f(void*);
void test(D* x) { f(x); } // calls f(B*)
若把#include换成前置声明,由于声明时不知道D是B的子类,test()中f(x)就会导致f(void*)被调用,而不是f(B*)。
再比如,C++标准5.3.5/5中规定,delete一个不完整类型的指针时,如果这个类型有non-trivial的析构函数,那么这种行为是未定义的。把前置声明换成#include则能保证消除这种风险。
诚然,从理论上说,一个牛逼的程序员当然是可以通过分析一个头文件的源码来决定会不会碰到以上诸多坑,并由此决定用哪个好。但一来不是所有人都是牛逼程序员,二来,把精力花费在这件事情上真的值得吗?
Note 2
这里触发了C++的一个知识点,叫 C++前置声明 这是发生这种情况的根本原因,我在这里大概给你讲一下。
A.h:
#ifndef A_H
#define A_H
#include "B.h"
class A{
typedef vector<string>::sizetype size_type;
B b;
}
#endif
B.h:
#ifndef B_H
#define B_H
#include "A.h"
class B{
A::size_type num;
}
#endif
在这种循环依赖场景中,由于C++是按单个编译单元编译的,编译报错。
解决方法是: 引入前置声明。
- 把A中的B转成
B*
; - class A中先声明B,也就是前置声明;
- A.h 移除对类B.h文件的包含(若用了#ifdef则不必须)
A.h:
#ifndef A_H
#define A_H
class B
class A{
typedef vector<string>::sizetype size_type;
B* b;
}
#endif
B.h:
#ifndef B_H
#define B_H
#include "A.h"
class B{
A::size_type num;
}
#endif
为什么要用指针B* 呢?
因为用了前置声明,编译器无法确定对象的实际大小,而指针的大小在特定机器类型上是固定的(x86机器是4byte,x64机器是8byte)
Note 3
自己也碰到了这个问题,我发现前置声明可能会有隐患,而且代码结构还是不够清晰。个人感觉一旦出现相互引用,大概率是两个模块设计的不合理,应该是可以拆解的。看到其他答主有说抽出一个共有的基类,但是在某些情况下不适用。个人的解决办法是把其中一个类拆分。比如A和B相互引用,那么我把A拆成C和D两个类。那么现在有B,C,D三个类了,有可能就解决了这个问题。
简而言之,要么纵向拆解,要么横向拆解。
Note 4
两个头文件相互包含会导致超前引用的问题,所谓超前引用是指一个类型在定义之前就被用来定义变量和声明函数。发生这种情况是无法编译通过的,不过可以采取一些手段解决该问题。
超前引用导致的错误有以下几种处理办法:
1. 使用类声明
在超前引用一个类之前,首先用一个特殊的语句说明该标识符是一个类名,即将被超前引用。其使用方法是:
a) 用class ClassB;声明即将超前引用的类名
b) 定义class ClassA
c) 定义class ClassB;
d) 编制两个类的实现代码。
上述方法适用于所有代码在同一个文件中,一般情况下,ClassA和ClassB分别有自己的头文件和cpp文件,这种
方法需要演变成:
a) 分别定义ClassA和ClassB,并在cpp文件中实现之
b) 在两个头文件的开头分别用class ClassB;和class ClassA;声明对方
c) 在两个cpp文件中分别包含另外一个类的头文件
NOTE:这种方法切记不可使用类名来定义变量和函数的变量参数,只可用来定义引用或者指针。
2. 使用全局变量
由于全局变量可以避免超前引用,不用赘述。我的习惯是,把类对象的extern语句加在该类头文件的最后,大家喜欢怎样写那都没有什么大问题,关键是保证不要在头文件中胡乱包含。
3. 使用基类指针
这种方法是在引用超前引用类的地方一律用基类指针。而一般情况下,两个互相引用的类并不涉及其基类,因此不会造成超前引用。