本文讨论如何更好地组织C/C++工程下的所有文件。说的都是平时工作中看到的问题。
三种组织模型
模型1:在一个文件中,写上所有的代码,并保证它可以编译和运行。这很容易,初学者写代码都是这个样子的。所以他们写不了多少行就觉得不可能再增加规模了。
模型2:尝试将代码拆分写到多个文件中。非初学者都有这样的觉悟。但是各文件互相成网状依赖,和写在一个文件中没有本质上的区别。文件之间依赖关系成指数级增长,根据个人的头脑清晰程度不同,增加到一定数量的文件后,再想增加规模就力不从心了。
模型3:代码分布于各个独立文件中,文件之间呈现层次结构。每一层都可保持一定规模。增加新文件对增加系统影响有限。这样构建一个大型软件就成为有可能的事情了。这就是我们追求的目标。
封闭开放原则
我曾经尝试阅读过四人帮的《设计模式》。说实话,我是看不太明白的。翻着书的时候,觉得它说的每一句话都有道理;把书一合上,却什么都想不起来。level还不够,尚且不能理解大师的想法。
但是,它其中提到过的一个基本原则,我却觉得非常有意义——"封闭开放原则"。所有的设计方法,都是为了要更好地达到这一目标——"封闭修改,开放扩展"。
比如:在我们的设计下,即使今后有各种新需求,也不会要去修改原来已经写出的代码;而是通过新增文件来满足。
毕竟呢,你有新的需求,总会需要在某个地方写上这些逻辑,对吧?不可能平白无故地就从无到有了吧?写在什么地方呢?在新文件里就是最好的!如果你又不需要这个功能了,把文件删除掉,新功能就去掉了。
这样的好处,也很明显:
1. 那些已经经过广泛验证的旧代码,不会因为你的后来的修改,而又再一次变成未验证过的"新"代码了
2. 接到新的需求以后,很容易扩展,并融入到现有的工程体系中
3. 尊重其他人的劳动成果,实际上就是节省了开发成本
这就是最美好的目标了,我们在完成代码设计的时候要朝这个目标努力。我们下面的讨论,也都以这个原则为基础。
依赖的原因
文件之间为什么会相互依赖,我觉得都是从头文件开始的。因为头文件之间互相胡乱 include 。
比如,有文件 a.h, b.h, c.h。它们之间互相include对方的头文件。你能说清楚它们之间是个什么关系呢?不就是"一陀"吗?网......
你在某个 x.cpp 文件中include了其中任何一个,编译之,过了。(因为三个文件都include在前面了)。现在,如果这些头文件之间的包含关系被修改了,那个特别无辜的x.cpp是不是就编译不过去了?又需要去修改一下了。
所以现在我们的首要目标就是要解开头文件之间的相互依赖。要怎么样才能做到这一点呢?
什么时候需要include其他头文件
为什么编译一些东西的时候,编译不过,我们include一些头文件以后就能编译过去了呢?
1. 在C中,如果我们引用一些全局的变量,或函数。那么在引用之前需要该变量、函数的声明。注意是声明(declaration),而不是定义 (definition)。毕竟,如果你不声明一下某个东西(比如xxxx函数)的存在,编译器怎么知道你是在引用真实的xxxx对象,还是你拼写错误了呢?
2. 如果我们使用C++语言提供的类功能,当我们定义一个XYZ对象的时候,编译器如果不知道这个class长什么样子,它怎么能知道需要替这个对象预留多少内存空间呢?如果你调用XYZ对象的yyy_func()函数,编译器怎么知道这个对象是不是真的能执行这个操作呢?还是因为你的拼写错误呢?
所以,如果引用其他源文件中提供的函数或全局变量,则需要include其对应的头文件。(比如math.h)如果定义某个类对象,或调用某个对象的方法,则需要include该对象class声明的那个头文件。
什么时候不需要include其他头文件
如果出现的只是一个对象的指针,那么不需要include类对象的class声明头文件。只需要前项定义就可以了。不论你是什么对象,指针总是4个字节的。所以,编译器已经知道分配多少空间,无需完整的类定义。
// #include "YYY.h" class YYY; // 前项定义 class XXX { YYY *p; // 指针,总是4个字节的。编译器只需要知道YYY确实是一个类就可以了。具体它长什么样子不关心。 };
这样,这个xxx.h头文件前就不需要再 include "YYY.h" 了。
我们只要在整个系统中提供一个独立的jjjForward.h头文件,放在预编译头文件中,让整个系统都用上,那么头文件依赖的问题会少掉一批。这个头文件类似于:
// jjjForward.h // 在OGRE系统中,这个文件叫做:OgrePrerequisites.h。 // 下载一个正儿八经的开源项目,打开看看,都能找到类似的文件。 class XXX; class YYY; class ZZZ; ....
通用性的头文件应该放在什么地方
比如,
1. 一些工具类头文件
2. 一些整个系统中处处用到,但是又相对来说比较稳定,一旦写好不会经常再变动的头文件(比如,OGRE的矩阵、向量之类的类定义)
3. STL相关的头文件 <vector>, <list>, <map>, <cassert>, <algorithm> 之类的
既然这些东西基本上到处都在用,我们如果总是在不同的地方去include它,不觉得麻烦么?基本上所有的头文件、源文件中都要有那么几句 #include <vector>, #include <map>。
让你通过一些技巧,编写一些宏来hook一些操作时,因为头文件到处都是,那就是不可能完成的任务了。但是,如果这些头文件在一个地方,刚才的需求就比较容易办到。(比如你试试在gloox中重载new/delete你就知道stl头文件散放的恶劣了)
有些文件可能不直接放在源编译头文件里,而是放在 StableHeaders.h 之类的头文件中,因为 StableHeaders.h 被放在预编译头文件中,所以被间接include。
做了这一步,依赖又减少了一部分。因为我们把基础公共的部分整个提出来了。
头文件书写的原则
头文件前面尽量不要再出现头文件!比如:在下面这个 xxx.h 中:
#include "jjj.h" // 当你某个头文件前面出现这样的东西时,你就要注意了!!!! #include "kkk.h" // 你想,当你把这个xxx.h嵌入到某个源文件时,那个源文件实际上包含了多少东西啊? #include "www.h" // 文件稍微一多,网状包含相当复杂。有时候相当于整个系统所有的头文件都直接,间接地被包含进去了。 #include "kdjd.h" .... class XXX { };
我们通过前面说的两个方法来尽量想办法干掉这些额外的头文件。
当然,有些情况下,我们确实没有办法绕过。比如,我们使用了继承或包含具体的对象。
#include "father.h" // 这里没办法,编译器必须知道father长什么样子,否则怎么继承?随它吧。 class Child : public Father { };
还有:
#include "yyy.h" // 如果这个已经在预编译头文件中了,那么不论如何这个都不需要了。 // 如果没有在预编译头文件中,那么继续看下面的讨论。 class XXX { YYY my; // 如果非要包含这个对象,那么确实没办法。编译器要知道YYY占多少空间。随它吧。 // 但是,要自己问自己一下,这个地方换成 YYY *pmy 行不行?在构造和析构的时候将YYY实例化和干掉。 // 这样虽然语意本质上没有区别,但是却将这两个头文件的依赖解开了。 // 换成 YYY *pmy 以后,就不需要 include "yyy.h" 了。 };
源文件前面是否已经出现头文件
当然需要!除了已经在预编译头文件中出现过的那些。其他你差什么,你就include什么。千万别客气。无所谓。因为它确实需要知道这些信息,不可少的。但是include在某个源文件中,它不会影响到任何其他文件。对吧?
比如某个.cpp文件:
#include "xxx.h" // 需要什么就包含什么。不要怕,这是必不可少的。严格做到上面的,这里已经是最优的了。不会影响其他文件。 #include "yyy.h" ... XXX d; YYY h; d->func(h); ....
预编译头文件
一点儿都不神秘。预编译头文件不是要求所有的.cpp都最先包含同一个.h吗?比如:
#include "stdafx.h" // 第一句必须是预编译头文件。 // 你想,每一个.cpp前都是这个,那不是相当于这个.h是在所有的.cpp和所有的其他.h之前吗?? // 所以,你在这个头文件包含、定义的东西,在系统中的任何.cpp和任何.h中都随时可用。 #include "djkfdf.h" ...
现在知道为什么你在预编译头文件中定义的东西在其他所有地方都是可访问的了。因为是你自己把它放置到所有所有的文件(.cpp和.h)之前了啊。
但这个不是它被成为"预编译头文件"的原因。你想,在linux和mac下,我们使用gcc来编译,也没有设置什么东西啊。不是一样编译过去了吗?把它放到哪里和"预编译"没有直接关系。
预编译是系统提供的(在vc中通过界面来设置),将这个指定的头文件(一般包含很多东西)先编译一次。以后再编译其他文件时,遇到#include "指定的预编译头文件" 就直接用之前编译过的那份数据代替,来提高编译速度的。
将东西放置到里面,在任何地方都能访问,并不是由"预编译"提供的;而是你自己把它放到所有其他文件前面而已(当然是它引导你这样做的,但是它不改变C/C++的include语意)。
.h和.cpp要成对
东西最好匹配。一个.h就对应一个.cpp。除非非常非常简单的东西,尽量将实现写在.cpp里。比如类成员,直接写在.h中唯一的作用也就是隐含inline关系。但是,你如果对它有个什么改动。所有直接间接include它的
源文件都要重新编译一次。比如,OGRE中的一些实现,(比如smart pointer)想增加点调试信息,稍微一碰,就要整个工程重新rebuild。(30分钟左右)
其实,我们如果不是在类中增加删除成员,而只是改个函数的具体实现,其他include了它的文件根本就不需要重新编译。没有信息需要更新。如果我们把这个实现放到.cpp中,只需修改.cpp,然后编译这一个.cpp,link一下就OK了!
对于一个大型的库来说,能做到这一点是多么的了不起。你自己多build几下ogre就知道了。
所有的headers
比如,你在写一个库。你就应该提供一个AllHeaders.h之类的头文件。比如,OGRE中的Ogre.h:
// Ogre.h #include "OgreAny.h" #include "OgreArchive.h" #include "OgreArchiveManager.h" #include "OgreAxisAlignedBox.h" #include "OgreBillboardChain.h" #include "OgreBillboardSet.h" #include "OgreBone.h" ....
因为我们之前已经解过头文件之间的依赖关系了,所以将它们按字母顺序排在一起没有关系。但是,如果解得不太干净,适当地调整顺序也是OK的。
比如将基本的东西放在最前面,后面的头文件依赖前面的头文件。呈现层次结构。如果出现包含这个头文件编译不过的情况,说明还需要继续解依赖。
这样做有什么好处?——外部使用这个模块的地方,只需要在它的预编译头文件中包含这一个文件就OK了。它怎么使用都行。但是,如果你不提供这个东西,而让使用者自己去include需要的头文件,就恶心了。他怎么知道该include哪个?是不是要看代码?知道了他不该知道的细节。其二,如果今后你调整了头文件中东西的位置。
那些客户程序是不是要全部修改?它们要自己找出它们使用的那个东西现在被调整到哪个头文件中了,然后将之前的删除掉,换上新的头文件。这不是在恶心人家吗?
你想,我们下载一个新版本的OGRE回来,就发现我们的问鼎编译不过去了,会是什么感受?而且本来就是有方法可以绕过这点的。
不要滥用名字空间
在头文件中使用 using namespace xxxx; 之类的!!严重错误啊!!对其他模块是一个重大的困扰!不推荐使用,实在要用就在.cpp中使用。
后记
以后我们要按照上面的原则来组织C/C++代码啊。什么文件为什么要包含某个文件,必须前因后果都清楚才能加添加啊。避免仅仅为了能编译过去,就随便搞个头文件到处包含哪。在编写和维护软件的时候,必须知道自己每步、每句在干什么,会对其他地方造成什么样的影响。这样我们才能构建更大的系统,维护更多的代码。否则弄个渔网几下就把自己给罩死了。到时候加班加点来解决问题,还不是自己累......