CATEGORY / Development

#include

Permanent Link: http://wutiam.net/notes/148

译自 zeuxcg 的《#include 》(墙外),有删改。请勿转载。
若不听劝告非要转载,请注明出处,谢谢。

我们没法摆脱 C++,至少短期内不太可能。我真希望 C++ 里没有那么多恶心的特性,但目前也没有真正能取代 C++ 的玩意。现代编程语言往往采用大宗编译(bulk compilation)和/或智能链接器,但 C++ 仍然受困于头文件分离的设计思想(当然从另一方面说,C++ 的这种设计也使得其构建过程可以是增量的、高度并行的),使得鱼与熊掌不可兼得。 这种分离头文件并保持代码清晰的策略很显而易见,但我却很纳闷为啥还有那么多人没搞明白到底该怎么使用头文件,希望这篇文章能够进一步理清这团乱麻。本文也适用于 C,但有幸使用其他语言的同学们请绕行。

包含头文件的问题在于预编译器很笨:你的代码告诉它要包含某个文件,那么它就会“递归地”包含整个文件内容;如果你不想包含那个文件却又想用该文件中定义的一个符号呢,那你只有面对编译错误的份了;但你如果把所有头文件都包含进来,哼哼,编译的时候你就去数星星吧。

一般情况下,一个头文件被包含的次数越多(包括递推的包含,比如 A 包含了 B,B 又包含了 C,那么 A 也就包含了 C),那么改动这个头文件就会导致越多的代码文件需要被重编。迭代周期的时间很紧(这是关于时间的另一个话题了),所以我们要尽可能地减少头文件被包含的次数。这就是第一条重要规范:每个文件仅包含其真正需要显式包含的头文件,尽可能地减少包含的数量。遵守这条规范能确保你代码的构建速度不会像拖拉机一样。

现在,假设我们的某个头文件里声明了一个类 A。按照 C++ 的原则,这个类声明在没有声明其他某些符号前不会被编译。例如,A 继承自类 B 并且包含了一个类型是 C 的成员,那么你就需要在声明 A 之前同时将 B 和 C 的声明提供给编译器。有两种方法:在 A 的头文件里包含 B 和 C 的头文件,或者强制 类A 的使用者们每次在使用前都手动地引入 B 和 C 的头文件。问题是有时使用者没法知道这些内在的依赖关系,有时依赖关系又变了,这会让使用者们抓狂的,更崩溃的是,为了包含你写的这么一个头文件,还得莫名其妙地包含一大堆看起来完全无关的头文件。正因如此,所有的头文件都该自给自足——任何人都可以在任一 cpp 中包含任何一个头文件而不会产生编译错误。这就是第二条重要规范:每个文件都必须包含所有依赖的头文件。遵守这条规范能确保程序员们不会被混乱的依赖关系逼疯。

以上这两大规范共同框定了如何正确编写头文件的方法:

  • 为每个需要的声明包含相应的头文件到你的头文件里;
  • 但千万不要包含别的头文件了。

也就是说,如果能用前置声明,千万别把整个头文件都包含进来——尽量使用前置声明。花一些时间和精力去解除头文件依赖关系有时是值得的,例如使用 PIMPL 模式(这个得视情况而定,并非套上了模式就上流了;不过得避免包含重量级的平台文件,比如 windows.h 或 d3d9.h)(关于怎么给 D3D9 的编译瘦身,作者去年的这篇文章最后一段有讲,供参考)。

还有一件事,由于疏忽或者意外,我们可能把同一个头文件包含了两次(例如 A 依赖于 B 和 C,而 B 又依赖于 C,所以 C 的头文件就被 A 包含了两次),所以我们需要一种机制来确保不会出现这种问题。我们得让每个文件都能够检测到多重包含,这里有两个办法:使用 #pragma once 或者使用。#pragma once 是一种非标准的技巧,它明确地告知预处理器“不要重复包含当前这个头文件”。而 header guard (也叫做 #include guard 或 macro guard)则通过预编译器的定义来模拟这样一种行为:

#ifndef FILE_NAME_H
#define FILE_NAME_H
...
#endif

可能很多人不知道这种方法,但 #pragma once 已经被现代编译器广泛支持了。它有两点比 header guard 更好:

  • 更快(MSVC 不会第二次读取标记有 #pragma once 的文件,但却会读若干遍用 header guard 的文件);
  • 更简单(不需要再为每个文件的 header guard 取名了,也就不会再把名字搞混了)。

所以,尽量使用 #pragma once,就算用不了,至少也得使用 header guard。如果你所使用的编译器不支持 #pragma once(而且你也没法说服开发商添加这个功能),那得确保你使用了一套能够生成永不重名 header guard 的方法。例如,把文件名连同路径一起转成以下划线(代表子文件夹)分隔的大写字符串,如 THEGAME_RENDER_LIGHTING_POINTLIGHT_H。不要只使用文件名,它可能不是唯一的!

如果你使用的是 header guard,理论上你可以在任何地方判断当前是否已经包含了某个头文件。但是,别通过判断是否已包含了某个头文件来改变后续代码的走势!这么干就等于让程序依赖于包含头文件的顺序,不但违反了一般性认知,而且由于没有预编译器的输出而无从 debug。如果你觉得你需要干诸如“如果当前包含了渲染器接口,那么我就该加入光照渲染器类,否则就不该让这堆用不到的代码被加进来”,那你应该把你的头文件分成两个类,第二个文件应该明确地包含渲染器接口,因为就是和它有关嘛。

至少在开发游戏的过程中,。最常见的可能就是断言的宏定义了(由于标准的 assert 实在太烂了,你得定义一套符合你们开发需求的),当然其实还有更多:日志接口、min/max函数、跨平台的定义(“当前系统是高位在前的字节顺序么?”)、内存管理相关的宏定义等等。一般惯例是把这些都放到一个单独的通用头文件里。所以你得控制这个文件的 size(当然,你知道我这里的 size 指的是累计包含进该头文件里的所有头文件的大小),而且你得确保所有文件都是在第一行先包含了这个头文件——否则你就有麻烦了(可能你得花上几个小时来查原因)。

呼,我能想到的关于头文件的就是这些了。额,还得再罗嗦几句,关于包含路径的问题。包含头文件时,你得指定是相对于当前文件的路径呢,还是相对于某个 include directories 全局设置项:

  • 如果你是在写库代码,当然我说的是相对较小的库啦(像虚幻引擎这种巨人级的平台直接忽略),应该尽量降低配置门槛,也就是理论上不要强迫使用者把你的库加到 include directories 配置列表里去。对于这类工程,建议使用相对当前文件的的包含路径
  • 其他情况下,包含路径应该便于查找,也就是所有被包含的文件应该在同一个路径下。这时建议使用相对 include directory 配置项的包含路径,同时还要确保包含路径没有二义性
  • 不管用哪种包含路径的方式,请尽力保持项目间的一致性。理论上讲,每个项目的 include directories 配置都应该是一样的,比如引擎工程的设置应该是游戏工程的严格子集。

噢,我是不是没提到良好的头文件依赖关系还能加快工程的链接速度?

No Comments / Trackbacks / Pingbacks

Leave a Reply

:) :wink: 8-O :lol: :-D 8) :-| :mrgreen: :oops: :-o :-? :( :twisted: :cry: more »