如何组织好 C 的头文件

一直很疑惑如何组织好头文件里的内容,有时候会写了一些很奇怪的声明,导入了很多不必要的头文件,在网上找到了这样一个文档,感觉作者写的相当清楚,试着翻一下就当练习六级了……

C 语言头文件配置指引

出处:

David Kieras, EECS Dept., University of Michigan

December 19, 2012

本文档内容和对应 C++ 的内容相似,但是引用的代码均为 C 的

**在一个复杂的工程中,头文件应当是什么样子的呢?**C 和 C++ 程序基本上都是一些分离编译的模块的集合。得益于分离编译的观念,在编译一个大型项目的可执行文件时,单独重新编译被修改了的模块能提高效率。

在 C 语言中,一个模块是由一些结构体,全局变量和函数们组成的。函数们通常会被定义在源文件中(.c 文件)。除了main模块,其他模块的源文件(.c文件)都会有一个对应的头文件(.h文件),其中提供了其他模块要使用本模块时所必须的一些声明。这么一来其他模块在访问模块 X 时,只要简单地写上#include "X.h"就行了,链接器会继续完成剩下的工作。除非X.c文件中的代码被修改了,否则这个文件中的代码只需要一次编译就足够了;剩下的时间里,链接器会将 X 模块的代码链接到最终的可执行文件里而不用再次编译 X 模块,这使得 Unix 下的make工具和一些 IDE 们能够更有效地工作。

一个组织良好的 C 程序应当良好地选择(抽象出?)模块,并且适当地构建其头文件,让他能够让人轻松地使用他的功能。这也确保了所有的程序的组件都使用了相同的定义和声明。这对编译器和链接器强制遵守一次定义规则(One Definition Rule)来讲也是非常有帮助的。

此外,组织良好的头文件能够尽可能避免重编译一些被修改的模块。减少重编译的技巧就是尽可能减少与其他模块之间的耦合程度,方法就是尽可能少的导入其他模块的头文件。在一个巨大的工程中,最小化耦合可以极大地减少构建的时间。

为了尽可能地表述清晰和达到编译上的遍历,你可以按照之下的几条规则来做。

规则一、每个由.c.h文件组成的模块都应符合清晰的功能

从概念上讲,一个模块就是一些可以被一起开发和维护的声明和函数,并假定会在不同的工程中被重用。不要强行合并一些需要被分开来维护的内容,也不要强行分离一些总是要被一起维护的内容。标准库中的math.hstring.h就个很明显的例子。

规则二、总是在头文件中使用『包含文件防护』

这是与#ifdef最紧密的例子。选择一个基于头文件名的防护符,这个简单的防护符会确保在一个工程中头文件名总是相互独立的。按照惯例这个防护符是全部大写的。比如Geometry_base.h文件中就应当以如下内容开头:

#ifndef GEOMETRY_BASE_H
#define GEOMETRY_BASE_H

并且以如下内容结束

 #endif

作者注:防护符不要以下划线为开头。下划线开头的名字通常为 C 语言内部的一些模块所使用,打破这一规则会造成一些不必要的错误。一下划线开头的规则是相当复杂的,不过只要你记住这一点,你就几乎不会遇到错误。

规则三、把使用本模块需要的声明都放在头文件中,这个文件也常被用来访问此模块。

因此#include进来的头文件提供了编译此模块所有必要的信息,并且让链接器能正确地链接它们。此外,如果模块 A 需要使用模块 X 的功能,那么它总是要#include "X.h"文件,永远不要出现 X 模块中的声明的硬编码。为什么呢?如果 X 模块被修改了,但是你却忘记修改那些 A 模块中那些被硬编码了的声明,那么模块 A 就会出现一些不容易被编译器和链接器发现的run-time错误。这个种行为违反了One Definition Rule但是编译器和连接器却又难以发现。总是通过一个模块的头文件来引用模块可以确保只有一处的声明需要被维护,这也对遵守One Definition Rule有帮助。

规则四、头文件只包含声明,并且他要在其.c文件中被导入

把模块中的结构体、函数原型和使用extern修饰的全局变量的声明放在.h文件中;把函数的定义和全局变量的定义以及初始化的过程放到.c文件中。.c文件必须导入对应的.h文件;编译器会检查两个文件之间的差异,并确保两个之间的一致性。

规则五、将外部全局变量的声明使用extern修饰后放在头文件里,并且把声明定义放在.c文件中

针对有些需要在整个程序中访问的全局变量,把它用extern修饰后放到头文件中,就像下面这样:

extern int g_number_of_entires;

其他模块在使用此模块时只需要导入对应的.h文件。在模块自身的.c文件中也要导入相应的.h文件,并且在文件开头的地方应当出现对应全局变量的声明定义,就像下面这样:

int g_number_of_entities = 0;

当然,某些默认为 0 的变量可以被直接当作初始值来使用,静态变量或者全局变量会被自动地初始化成 0。但是显式地初始化成 0 并不是必要的,因为这等于标记这个声明是定义声明,意思就是这个变量的值是唯一的可确定的。注意不同的 C 编译器和链接器可能会允许其他设置全局变量的方式,但是这是被 C++ 的标准所接受的,并且对 C 语言里也是有效的,这也确保了全局变量的One Definiton Rule

规则六、把模块内部的声明移出头文件

有时候模块需要严格使用内部的不被外部所支持的零件来。如果某些结构体声明、全局变量或者函数仅仅在模块内部要被使用到,那就把他们只放在.c文件的顶部,并且不要在.h文件中提及它们。此外,使用static来修饰这些全局变量和函数来关联它们。

这么做的话,其他模块就不会知道(也无法知道)这些内部的声明、全局变量或者函数。使用static修饰过的声明会让链接器来强制实施它们内部的关联性。

规则七、每个头文件的都应仅仅导入此头文件所必须的一些头文件,来让这个头文件能够被正确地编译

这个头文件到底需要什么呢?如果一个结构体 X 备用来作为这个头文件的中一个结构体中的成员,那么你就必须导入X.h进来,那么编译器就知道这个 X 结构体到底是谁。不要导入一些仅仅在.c文件里需要的头文件。

举个例子的话,<math.h>就常常被使用在函数的定义中,将他在.c文件中导入,而不是.h文件。

规则八、如果仅仅要使用一个未完全声明的结构体,那么就不用导入他对应的头文件了

如果一个结构体 X 在结构体声明或函数定义中仅仅出现为一个指针类型,并且代码中不需要访问这个结构体内的成员变量,那么你就不应该导入X.h,而是在使用它前用一个未完全声明的结构体 X 来代替他(也称做传递声明)。就像下面这样,通过一个指针来引用 X:

struct X; /* incomplete ("forward") declaration */
struct Thing { 
    int i; 
    struct *X x_ptr; 
}

编译器很乐意接受代码中一个指针指向一个未完全声明的结构体类型,因为指针总是有相同的大小并且只取决于它们指向了什么。典型的就是只有源文件中需要访问 X 结构体的变量或者大小,所以只有.c文件中需要#include "X.h"。这是对模块的概括及送耦合化是非常有用的。

规则九、一个头文件应当能被他自身所成功编译

一个头文件需要显示地导入或者传递一些任何他需要的东西。如果不遵守这个规则的话,在修改所导入的头文件或者被其他头文件所导入时会出现一些令人疑惑的问题。你可以建立一个test.c并在这个源文件里仅仅导入A.h来检查这个头文件是否可以被他自身所编译。这里不应该出现任何编译错误。如果发生错误的话说明有些内容需要被导入或者传递声明。测试所有的头文件从导入顺序的底部移到最前面,这会帮助你找到一些与其他头文件的意外的依赖问题。

规则十、A.c文件应该最先导入A.h文件,之后再导入其他所需的头文件

总是吧#include "A.h"放在第一位来避免丢失任何其他的所依赖的头文件。之后如果模块A的代码中需要使用模块X的话,显式地#include "X.h",那么A.c文件并不是意外地导入X.h在其他位置。

并没有一致规定A.c文件需要再次导入A.h文件中已经导入的文件,但是有两个建议:

  1. 如果X.h文件在逻辑上不可避免地需要出现在A.h中,那么在.c文件中再次被导入是多余的了。所以在A.c文件中不导入X.h文件是可以的。
  2. 如果在A.h文件中#include "X.h"可以让读者更明确我们在使用 X 模块,并且 A 模块中需要对 X 模块中的某些修改所以来的话就需要在A.c文件中导入X.h。举个例子:可能我们已经有一个结构体Thing了,之后需要摆脱它,但是依然需要在实现的代码中需要他,所以导入这个头文件可以帮助我们摆脱编译错误,那么就可以再次导入X.hA.c文件中。当然,如果X.h文件不再是必须的了,那么对这个文件的导入语句就应该被删去。

规则十一、永远不要因为任何原因导入一个.c文件

这是鲜有发生的,而且总是会把一切都搞乱。为什么会发生这种事呢?因为有时候我们为了维护上的方便而需要把一块代码放到同一个文件中而被其他.c文件锁共享,所以你把它单独分为一个文件。因为这些代码并不是由一些常规的声明和定义,你知道把他们放在一个头文件里会误导别人,所以你把它放在一个.c文件里,并且之后用#include "stuff.c"

但是这么做会造成其他开发者或编译器的困惑,因为.c文件应当被分别编译,所以你必须另外告诉其他开发者不要编译这个.c文件。此外,如果它们丢失了这份难以纪录的点,它们会得到由编译器返回的大量奇怪的问题,让人们困惑它们应该怎么使用你的代码。

结尾:如果他并不像其他一个正常的头文件或者源文件的话,不要把他们命名成差不多的。

如果你认为你必须这么做的话,首先确认这部分代码并不能被单独分成一个模块,之后将这个文件使用不一样的后缀名.inc或者.inl以来使用他。