向google学习良好的C++代码风格-(2)头文件

来源:互联网 发布:linux svn服务器地址 编辑:程序博客网 时间:2024/04/29 08:16

一、预备知识一:文件的扩展名

google 把C++源文件的扩展名取为 .cc ,头文件则为 .h——这显然仅在我们真的非常荣幸地参加google 的c++开源项目时,我们才需要遵守,其它时候大可不必,之所以开篇就和google唱个反调,是因为这一点确实早就约定成熟了——不仅是口头的约定——通常c++的编译器不仅能编译C++源代码,也能编译C的源文件。此时,如果要明确地让编译器认定这是C++代码文件,通常源文件扩展名为.cpp,而头文件为 hpp或hxx。所以我推荐:cpp + hpp。把一个含有大量class的声明或定义的代码放在 .h 文件里,只会让公司里纯C项目组的人心存幻想,以为C语言也可以直接使用它。

说到扩展名,很容易会想起一件事,为什么C++标准库中的头文件,居然没有扩展?比如 vector ,list。这只是C++标准委员又一次喝高了的决定。放弃扩展名确实不会带来什么大问题,但一些小细节实在让人不爽,譬如你双击一个没有扩展名的文件,操作系统(主要是Windows)一脸雾水,不知该如何打开它,就算你手动用IDE打开了,通常IDE的语法高亮设置也是通过扩展名来自动判断的,它没有扩展名,于是不高亮。Linux系统习惯通过预读来猜测一个文件的内容,但偏偏cpp源文件没有规定必须在文件第一行做点纯ascii码的描述,所以,虽然有办法蒙对,但终归让人不安。

对于充足大量泛型代码的头文件,又有另一种扩展名,叫 inl。后面会提到。

二、预备知识二:为什么要有头文件?

头文件中通常是定义或声明一堆类,一堆变量声明,一堆函数声明,通常,类定义,类声明,函数声明,变量声明,都可以认为是在描述一些东西的“原型”。比如有一个函数:

Code:
  1. double foo(int a, int b, double c)  
  2. {  
  3.   
  4.     return (a - b) * c;  
  5.   
  6. }  

这段代码,定义了函数foo的“真身”,或者更直接一点,它就是foo函数的“肉体”。对于一个函数来说,肉体只能有一个,如果有多个肉体,要么编译直接过不去,要么拖到链接时过不去。这和现实中的逻辑是一样的,你说你要是有两个肉身,并且同时出现在一个教室时,在上课点名时直接过不去,这叫编译出错;如果你坚持大学四年两个肉身不同处一处,但在你毕业后并且结婚,洞房花烛夜某些事情必须有实质进展,却出现两个郎君终于过不去,这叫链接出错。

不过,肉身虽然可以只有一个,但名片却可以有无限张。名片上描述了一个肉身对外所需要展示的最重要的一些内容(对于人来说,可能就是姓名,职务,公司,电话等);函数,类,变量也如此。函数的声明就是函数的“名片”,上述foo函数的声明如下:

Code:
  1. extern double foo(intint , double);  

没错,extern 可多数时候可以不需要。而参数中变量名字,虽然在声明中无用,但出于阅读方便,通常我们会写上。

头文件就像一个“名片夹”,把一堆相关的“人员(类,函数,变量……)”的描述信息,全写在一个头文件中。然后谁要是需要,谁就自己去include它。就像暑假时,你把在名片上写明自己会家教哪些科目、每小时价格、还有最重要的联系方式,写在名片上,然后站在街头,路人甲经过,他觉得自己“将来可能用得到”你,于是他取了你一张名片。如果说你是一段函数或一个类(具备家教能力),你的名片就是一段“头文件”, 而路人甲有他自己的生活,所以他是另一段C++源代码(但他是干什么的,我们不关心),那么当他伸手取了你一张名片然后塞入他的上衣口袋时,这一段“动作”,就是:

Code:
  1. #include "丁小明.hpp"  

路人甲回到家后,可能把你的名片丢了,但在C++的世界,更可能是他非常高兴地为他的儿子写一个暑期生活函数,其中有一段他大胆地用到了你(丁小明),虽然他此时还不敢肯定你的家教能力如何,但他总可以写出这段代码了:

Code:
  1. void SummerTime4Son ( )  
  2. {  
  3.     //...  
  4.   
  5.     PrivateTeacher   dingXiaoMing;  
  6.   
  7.     dingXiaoMing.Teach(mySon, "几何", 500, 30);  
  8.   
  9.    //...  
  10. }  

路人甲通过你的头文件,知道了该如何“使用”你,尽管此时他还没有真正了解过你。不仅仅路人甲可以include你,路人乙,路人丙都可以include你,这说明你的生意很好。头文件如果有感情,应该也如此,一个没有别的源代码包含的头文件,是可悲的——但作为一个程序员,你又不能搞妇人之仁:因为同情心就让头文件被很多cpp文件包含,这时候程序员就像一位市场管理者,要保持源代码之间彼此关系简单,这一点我们后面再谈。

现在的问题是,前面所说一切,只是在说“头文件的功能”。当有人要向我们解释“为什么要有XXX”时,我们要警惕,因为人们很习惯会用“啊,XXX有这么些那些功能”来“回答”这个问题。假设小A和小B两人向我兜售电风扇,并且现在是夏天——

情景一:

小A: 我认为,您是需要买一台风扇的,因为:这台风扇它有四级调速,并且有遥控器,您晚上休息时,随时可以调整。另外这个台风扇你看它有四个扇叶,转起来风特别在透彻;还有它既可以放在桌子上,也可以挂在墙上,真的是太贴心了的……

我:对不起,你说的功能我都不需要,我不买。

小A:TMD,你这个不近人情的家伙,你就等着热死吧!

情景二:

小B: 天气好热啊,请问您现在采用的夜间避暑方案是什么?

我: 我家的客厅及全部卧室都装了空调。

小B: 空调的制冷效果比风扇强多了。不过,如果一个晚上所有房间都开着空调,那您一个月下来,电费至少要翻一番啊。另外,我看您这年纪,家里应该有小孩吧?关着屋门吹着冷气,对小孩身体发育不是好事啊。您看我卖的风扇,和空调一样有静音功能,并且吹出来的风非常柔和,像自然风,小孩的房间放一台,就不用天天关着门窗了。还有噢,连续使用用30个晚上,用的电都不用1度,就是空调的一个晚上的费用而已……

学习任何一门知识,包括一门编程语言,比如C++,最忌讳的,但往往有最多人又会犯错的就是:掉进C++语言功能的坑里。为什么呢?倒推一下。我认为有很多知识点(当然,不是全部),其良好学习结果应是:“知其然(知道它是这个样子)”,并且“知其所以然(知道它为什么会是这样子)”,而要“知其所以然”,最重要的输入就是了解它要解决什么问题。“解决什么问题”,和“提供什么功能” 之间的微妙差别在于,你必须知道问题所在。 然而,通常在编程这个行业里,如果还停留“学习语言”这一阶段的人,他们的实际编程经历都不多,事情做得不多,自然遇上的“问题”也就不多,或者知道的“问题”,但对“问题”的理解不够,所以容易一天学习来,一直在记C++有这个功能,那个功能……

回到前面的卖风扇的例子。小A也知道风扇具备“省电”和“可以开门窗使用”的功能,但由于他没有事先挖掘出我的“问题”所在,所以他堆砌了一堆功能,却没能解决问题。而小B则主动了解了我的状态,从中找到我真正存在的“问题”,然后发现他的风扇具备解决这一问题的功能。这当然完全只是一个例子,现实会更复杂,但无论如何,小B的方法值得肯定。

最后回到 C++ 语言,大到OO中的各个概念,我们需要在学习有一个老师来向发问并引导我们思考:为什么需要抽象?为什么需要封装?为什么需要多态?为什么要有个构造函数,为什么要有个虚构函数?小到当前这个问题:为什么要有头文件?C和C++有头文件,可是同一时代的Pascal语言,及后来的语言:Java、C#,以及大量的脚本语言:PHP,Python……它们都不需要“头文件”啊?这是为什么?

Pascal不仅有严格的语法结构,同样有着严格的文件内容结构,它将声明与实现全都放在一个文件中。由严格的文件结构来告诉编译器文件中哪一段是其它源文件需要读取的内容。好处是编译速度更快了,坏处是“声明”代码,和“实现”代码放在同一个文件中。商业应用,如果您不想给别人看到代码的真实实现,事实上的做法是最终写一个“空的源文件”,里面只有声明,没有实现,这正是C和C++中提供单独头文件,用于彻底剥离声明与实现的本意,但C/C++的方法是有实有名,而Pascal的方法是有实无名。别小看“名”的作用,C++标准库头文件没有扩展名,以及C++模板代码不得不将所有实现写在包含文件里,也是名实不分的例子,都给使用者带来麻烦。

C#,Java对“interface”的支持,比Delphi来得更早,甚至还得说更加纯粹地道。不过,这三者在这一点名实不分带来的细节上的问题,还是很多。以java为例,那些没有开源的库的提供者,非常恶搞地提供一堆空的源代码(所有函数都是 一对"{ }"解决),如果要人工去维护一套类库的这样两份代码:一套真实代码,一套空的代码,我估计程序员要抓狂,所以这样的工作通常是有另一套程序来生成。为什么需要这个工作?现代的IDE,通常有代码提示,而要代码提示,就必要需要知道一个类有什么成员函数,一个函数有几个参数,返回什么,我们可以把前者称为“类长什么样子”,后者称为“函数长什么样子”。“长什么样子”只需要一张名片,并不需要类或函数的真身。但由于java语言放弃了头文件,只提供真身——也就是说,如果我想知道你长什么样子,我就必须真实地把你拉我到面前——而那些不开源的库,又不允许你看到我真实的代码——无奈之下,干脆提供“真人全身1:1倒模”了——事实上想知道长什么样子就要拉到真身,是如此的不便,所以就算是很多开源库,最终也走了这条“倒模”的路子。

C# 和 Java与C++的另一处与此相关的不同,可以引出另一个问题:为什么C++语言需要“链接”这个过程。原来C/C++以单个源文件为编译单位,每个文件被编译中中层文件,最后再把这些中间文件“拼接”成一个最终的目标文件。比如 编译器在编译a.cpp时,只会打开a.cpp和它所include进来的头文件,而其它cpp文件则一概不需要碰。C#和Java则与此不同,编译器需要一次知道所有源代码。假设有一个C#项目,它有a,b,c三个源文件,则编译命令可以是(仅示意, csc 为编译器):

csc  a.cs b.cs c.cs

而如果是C++的项目,同样是a,b,c三个源文件,则编译过程为(同样仅示意,gcc 为编译器):

gcc a.cpp

gcc.b.cpp

gcc. c.cpp

gcc  a.o  b.o c.o

最后一步,gcc实际会调用“链接程序”,将三个中间文件链接成最终目标文件。

这两种编译过程同样各有利弊。限于篇幅,我就只讲C++采用“链接”的“好处”了:这种编译可以占用最小内存。编译boost时,产生的中间文件达到10G。第二,当编译到某个文件失败时,如果仅仅修改这一文件的内部实现,则之前编译过的文件无须重新编译。第三,它使得“模块”组合的颗粒度变小,你只需要链接你所需要的模块。

又讲“编译”又讲“链接”,接下来我们还要继续节外生枝,讲一个重要的“设计思想”,它甚至不仅仅是编程的一种思想,而且可以上升到一种人的生活理念、上升到一种哲学高度(好的程序员都必须善于YY,我这是示意:),那就是“高内聚,低耦合”。讲得粗俗一点,一个对象,内部之间应该紧密结合,对外则应该减少依赖。一个团队,内部团结,对外部不需要太多地方需要求爷爷告奶奶,它就是高内聚低耦合;一个个人,自身所学通融贯通,对外则关系清晰了解,没有什么见不得人东西,那他就是高内聚低耦合的。一个古龙小说里的武学者,身体内部大小周天都已经打通,对外却随便摘一根树枝,就可以“只要心有有剑,则万事万特皆可为剑”,那这家伙也是高内聚低耦合的。

注意! 对外降低“耦合”,并不是让你“万事不求人”,而是指你的内部功能实现与外部的条件之间的关系,要清楚简单。另一方面,“内聚”也包含“内敛”的意义,做人不能张扬,写程序要注意封装。

OK,可以回到头文件来了。结论很简单。源代码(.cpp)对应“内部实现”,而“头文件”则表明了这个实现的对外的接口。怎么在源码与头文件上做文章,让它们表现得“高内聚,低耦合”呢? google给了我们很多有意义的规则。

三、头文件保护 / The #define Guard

头文件很脆弱吗?要保护什么?

生活中,我们应该收集一些有用的名片,也要避免重复收集相同的名片。比如张三是一位下水道疏通公司的人,你有张三的一张名片,你爱人也有一张。这种状态好吗?有人说,好啊,两个人都有名片方便啊。但,如果以家庭作为一个“对象”,这就是不好的,这说明这个家庭不够“内聚”。高内聚的家庭,这样的名片资源应该是共享的。

如果家里的名片重复了一大堆,在使用时一定挺浪费时间的。如果不同的源文件一直在重复地include一堆相同的头文件,编译时就必然浪费大量时间。所谓头文件保护,并不是要保护头文件自身,而是要保证一个头文件只被包含一次。

C++采用宏定义来判断一个头文件是否已经包含过了——这是非常非常基础的知识点了。google只是建议我们这样命名那个宏:

<PROJECT>_<PATH>_<FILE>_H_

PROJECT :项目名称, PATH: 头文件相对路径, FILE:文件名,再以 _H_ 作后缀。比如,如果你在写一个扑克24点的游戏,项目名为:Poker24,现在是一个位于该项目所在目录下的,一个名为xml的子文件夹下的 parser头文件,则宏定义类似:

Code:
  1. #ifndef POKER24_XML_PARSER_H_  
  2. #define POKER24_XML_PARSER_H_  
  3.   
  4.   
  5. //...头文件真实内容  
  6.   
  7. #endif  

 初学者容易对此不以为然:不就是要保证这个宏的名字是独立无二的吗?随便取个名字就行了。其实不然,C/C++这道机制其实是不完美的,它依赖于程序员要严格要求自己——如果说c/c++这门语言有什么不完美的话,那这就是一点:它不小耦合上了“程序员必须拥有良好品质”这个要求。 当我们写大项目时,会用到大量的第三方代码,特别是开源代码,一些相对有点年代的代码,确实在这方面做得就不好,结果很容易就发现同一名字,并且所用的“保护宏”也同名的情况。

 有两个特别注意一点:

其一:现代的IDE通常有向导会自动生成头文件及源文件,如果你的项目组成员习惯这一方便的工具,那就直接使用它所生成的保护宏的名字吧,通常这类工具对该宏的命名,和google所提的建议,是接近的。

其二:提到IDE,我们却不得不遗憾的说一下VC。VC中MFC的向导,通常会生成如下的保护宏:

Code:
  1. #if !defined(AFX_***_H__B09AAFFB_CE87_6BAD_ACC4_2AE75C5D8741__INCLUDED_)  
  2. #define AFX_***_H__B09AAFFB_CE87_6BAD_ACC4_2AE75C5D8741__INCLUDED_  
  3.   
  4. #if _MSC_VER > 1000  
  5. #pragma once  
  6. #endif // _MSC_VER > 1000  
  7.   
  8. //...  
  9.   
  10. #endif // !defined(AFX_***_H__B09AAFFB_CE87_6BAD_ACC4_2AE75C5D8741__INCLUDED_)  

这也是IDE自动生成的,并且要一一手动去订正它,几乎是不可能的,所以我们只是表示遗憾,无力反对些什么,基本上这是一种成功的商业行为,按C++之父的话来说,这是一种“政治”。

 四、降低头文件依赖 / Header File Dependencies

如果在前面写一个声明性的语句,就可以解决问题,那就不要去使用 #include 来随便包含一个头文件。

最常见的例子,一个大项目,通常会有一个类似toolset.hpp的头文件,里面放了一大堆“杂七杂八,实在无法归类”的函数。假设它包含了100个函数。现在有叫a.cpp的源文件需要用到100个函数中的一个。那么,直接在a.cpp里写一句该函数的声明就好了,而不是一句“#incldue toolset.hpp", 就把直接把这100个函数给引进来。

再如, google里讲到的。假设头文件里有一个名为A的类定义,它需要另外三个类的成员,以下两种方式需要我们考虑一下:

Code:
  1. //方式一:  
  2. class A  
  3. {  
  4.   
  5. private:  
  6.     B b;  
  7.     C c;  
  8.     D d;  
  9. };  
  10.   
  11. //方式二:  
  12. class A  
  13. {  
  14. private:  
  15.    B* b;  
  16.    C* c;  
  17.    D* d;  
  18. };  

方式二可以让我们避免在该头文件中包含更多的其它头文件(比如类B,C,D的定义)。因为在方式一中,编译器一定要看到类B,C,D 的真实定义,而在方式二,我们可以在class A定义之前,加一些类声明:

Code:
  1. //方式二:  
  2. class B;  
  3. class C;  
  4. class D;  
  5. class A  
  6. {  
  7. private:  
  8.     B* b;  
  9.     C* c;  
  10.     D* d;  
  11. };  

我们只是在头文件中声明了三个类,从而避免了在头文件中又包含其它头文件,上例中,我们可以在源文件中去include 类B,C,D 定义所在的头文件。

留两个问题:(1),为什么对于指针或引用,编译器只需要知道 B,C,D是一个class,而不需要知道它们真实的类定义长什么样子呢?(2),上面的例子表明了, 尽量在源文件中include其它头文件,而不是在头文件中包含其它头文件,为什么?

五、内联函数/Inline Functions

多于10以上的函数,就不要内联了。如果一个内联函数是在cpp文件中实现的,那其实它就无法被其它源文件使用。如果一个内联函数(一个真实的函数,而不是函数模板)是在头文件中实现,那真的要小心!所有include这个头文件的人,都可能会用上它,而它又是内联的,你的程序的体积可能因为它而膨胀了。

内联函数一定要在遇上关键的速度性能问题时,再去考虑,其它时都要少用。一个内联函数事实上它并不是一段函数,但在语法上它又确实是一段函数,如果有人对你的内联函数取址呢?善良的编译器会帮你解决问题,但解决问题之后可能带来的副作用,可能要费上你一堆脑细胞去思索。

六、使用.inl扩展名 / The -inl.h Files

我对这一点不太以为然,C++中对付泛型代码,没有任何可以真正解决前面所说的“名实不分”的问题,就算它提供用于此目的关键字。好吧,用一个叫 .inl的扩展名,来假装它不是一个头文件,这真有点那么自欺欺人的意思 :)。

话说回来,对于还在上学的同学,我和google一样推荐使用 inl。由于C++的泛型代码必须有“实现”,当它要被多个源代码共用时,这些实现没办法写在源文件中,只能全部写在头文件中。在这种情况下,相当是把 .hpp 文件再拆一次。将所有“函数模板”及“类模板”的定义写在.hpp文件中,而将其中的实现写在.inl文件中。假设有一个demo.hpp文件,它含有一个函数模板:

Code:
  1. //只使用.hpp的例子  
  2. #ifndef DEMO_H_  
  3. #define DEMO_H_  
  4.   
  5. template <typename T>  
  6. int foo( T t)  
  7. {  
  8.     //...  
  9.     return 0;  
  10. }  
  11.   
  12. #endif //DEMO_H_  

如果将它再拆出一个inl文件. demo.hpp 文件内容为:

Code:
  1. //使用.hpp和.inl的例子  
  2. //这个是.hpp文件  
  3. #ifndef DEMO_H_  
  4. #define DEMO_H_  
  5.   
  6. template <typename T>  
  7. int foo( T t); //只是模板声明  
  8. #include demo.inl
  9. #endif //DEMO_H_  

demo.inl 文件内容为:

Code:
  1. //使用.hpp和.inl的例子  
  2. //这个是.inl文件  
  3. #ifndef DEMO_INL_  
  4. #define DEMO_INL_  
  5.   
  6. template <typename T>  
  7. int foo( T t)  
  8. {  
  9.     //...  
  10.     return 0;  
  11. }  
  12.   
  13. #endif //DEMO_INL_  

inl其实也是一个头文件,所以也需要保护宏,其后缀为INL_。

如果已经在工作中,inl文件我不建议使用。原因之是很多IDE还不认识它,如果将它加入项目,则会被当成需要编译的源文件,如果不加入项目,则不好管理。

七、函数参数的声明次序 / Function Parameter Ordering

怎么函数参数与归到“头文件”来?没错,函数通常在头文件中,就决定了它的参数次序。

google和我的一致建议:先入参,后出参。

入参通常使用const 引用(char 、int、 double、 bool 等内置简单类型,就不需要引用了)。

出参则使用指针,当然,它不加const修饰。我个人认为,如果出参是必须的,用非const引用更合适,以免搞出一个空指针。

总会有出现一个参数既是入参,又出出参的时候……那好吧,这时听你自己的,尽量保持一致性就是。

 

八、头文件的包含次序

建议次序如下:

1)、与源文件同名的头文件。

2)、C库头文件

3)、C++库头文件

4)、其它外来库头文件,比如boost

5)、你自己在本项目里写的头文件。

其中 1和5使用 #include "xxx.hpp",其它的通常使用 #include <xxx.hpp>

 

九、补充:

补充一: 学究派的include 写法是其后没有任何空格,直接接头文件名:

Code:
  1. #include<iostream>  

补充二:如果可能,就使用 “/",而不是"/"。既然windows也支持这种写法了。如:

#include <base/abc.hpp>

而不是:

#include <base/abc.hpp>

当然,你的编译器若不支持,那你只好认了,或者你正在使用中库文件不是这种风格,那……也算了。

补充三:必须的,请直接看google的原文件,我漏掉了很多有益的内容。

 

原创粉丝点击