前置声明是C/C++开发中比较常用的技巧,主要用在三种情形:
- 变量/常量,例如
extern int var1
;; - 函数,例如
void foo();
,注意类的成员函数无法单独做前置声明; - 类,例如
class Foo;
,也可以前置声明模板类:template class<typename T1, int SIZE>Foo;
。如果类包含在名字空间中,需在名字空间内做前置声明:namespace tlanyan {class Foo;};
,而不能这样:class tlanyan::Foo;
。
1.前置声明作用(优点)
根据其用途,前置声明的主要作用为:
- 避免重复定义变量;
- 避免引入函数定义/声明文件,从而函数文件发生更改时不会重新编译依赖文件;
- 解决循环依赖问题。
前两种用途好理解,第三种稍微复杂点,但却是前置声明最重要的用途。其解决类A包含类B,同时类B包含类A的依赖问题。循环依赖一般是设计层面的问题,可通过接口、引入辅助类等手段化解。前置声明也能解决,只是架构上稍微别扭。
不管A和B是否定义在同一个文件中,c++永远无法解决如下形式的循环依赖(后文解释原因):
// file: A.hpp
#include "B.hpp"
class A {
int id;
B b;
};
// file: B.hpp
#include "A.hpp"
class B {
...
A a;
};
前置声明解决该问题需要与指针配合,转换成另一种形式。要点如下:
- 至少将某类的变量类型转换成指针,例如A中将B转成B*;
- 类A中对B使用前置声明;
- 类A的定义文件中移除对类B文件的包含(做了包含保护则可忽略)。
使用前置声明后,以下是一种可行的解决形式(两个类均使用了前置声明):
// file: A.hpp
//3. 移除对B的包含(使用了#pragma once或者#ifndef B_HPP等保护措施则无必要)
// 2. 前置声明类B
class B;
class A {
int id;
// 1. 成员变量转换成指针
B* b;
};
// file: B.hpp
// 3. 移除对A的包含(有包含保护则非必要)
// 2. 前置声明类A
class B {
...
// 1. 成员变量转换成指针
A* a;
};
2.深入前置声明(优点)
如果你有其他编程语言的经验,会发现c++有点怪异:Java/C#/Python/PHP等语言可以轻松做到循环引用,无需使用类似的前置声明技巧。这不禁让人思考:C++为何必须要用前置声明才能化解?
原因在于C++定义对象有两种方式:一种是A a形式,a即对象,调用成员变量或函数用.,对象在栈中分配;另一种是A* a,a是指针,调用成员变量或函数用->,其指向地址存储实际对象,对象在堆中分配。
分配对象需要知道具体的内存大小,但以下形式我们不能确定类A和类B对象的大小:
class A {
B b;
};
class B {
A a;
};
对于这个简单例子,你可以直观认为A和B占用同样的内存,例如1字节,但也可以是2字节,3字节等;根据内存对齐要求,一般是4字节,8字节等。无论哪种情况,编译器无法确定其对象占用内存,便会报错停止编译。所以你应该知道为什么C++永远不应该(不能)这样做了吧?
那为何前置声明加指针的组合能解决循环引用问题的呢?因为正常情况下,数据类型指针在同一机器的编译器里占同样的内存。指针一般是4或者8个字节,对应32和64位指针。用了指针,即使有循环引用,类的大小也能轻易的确定下来。这也是Java/C#/Python/PHP等可以轻松循环引用的原因:这些语言中,对象变量其实都是指针,也意味着对象变量都是引用传递。
如果不移除文件的相互包含,能否省去前置声明呢?答案是不能,原因如下:
- C++按照一个个编译单元(translation unit)进行编译,如果两个文件互相包含且没有#pragma once等包含保护措施,则会出现递归包含,编译器报错;
- 如果两个头文件都有文件包含保护,编译A时会把B包含进来,但因为B包含了A,A中的包含保护生效,导致B文件内的内容实际未引入A,于是报B为未知符号的错误。
总的来说,不管是否移除对方的头文件,前置声明都是必须的。实践中为了避免文件变动时重新编译的耗费,移除不必要的头文件是一个好习惯。
3.前置声明有新的争议(缺点)
Google内部有从『倾向于使用前置声明』到『倾向于使用#include』的这个过程的。事实上在很多年前Google内部就开始了对这两者的比较和探讨。在2014年,内部有一篇总结性文章指出了前置声明将造成的十种危险。最终C++ Code Style经历了一个过渡期之后全面倒向了#include。
简单来说,前置声明最大的好处就是『节省编译时间』。毕竟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则能保证消除这种风险。
链接:https://www.zhihu.com/question/63201378/answer/208096403
4.我在使用前置声明的过程中遇到的坑(缺点)
// b.h文件
class A;
class B {
public:
B();
~B() = default
private:
std::unique_ptr<A> a_;
}
这里使用A的前置声明是有问题的,因为我们把B的析构函数定义为default,则它默认是在.h文件里实现的,而实现需要知道A的定义。在新文件c去include这个A.h是,编译c文件会报错。
两种修改方式:
1.去掉A的前置声明,改为include。
2.不使用default,在.cpp里去定义B::~B() {}
,.h里只声明
~B();