头文件设计与使用规范

众所周知,相较于面向对象的C++,面向过程的C语言是没有封装、继承、多态之说,也就无从谈起接口之类的操作。那么在C程序中,使用分离编译的原则,头文件(.h或.hpp)提供声明、接口的作用,源文件(.c)提供实现的作用,进而实现工程的模块化,保证模块的高内聚、低耦合,无疑是非常重要的。

序一、免责声明

本文内容多来自于《C|头文件包含的处理原则、规则、建议》,但该文排版欠佳,故优化排版摘录于此。

序二、关于声明和定义

在C程序中,使用分离编译的原则,头文件(.h或.hpp)提供声明、接口的作用,源文件(.c)提供实现的作用,C编译器通常以源文件作为编译单元。编译时,根据声明来验证标识符,到了链接阶段,才去查找具体实现,并链接为一个整体。

另外,为了避免二义性,对于声明定义的原则是“多次声明(外部变量),一次定义”。在C工程中,为避免歧义,只能有一次定义,可以多次声明以满足同文件内定义之前声明的条件,或提供给其它作为编译单元的文件中使用。

  • 定义通常涉及到内存分配,而声明不会;

  • 定义的同时也是声明,如:

    1
    
    int v_g; // 既是定义,也是声明;
    
  • 声明通常使用关键字extern

    1
    
    extern int v_g; // 使用同文件中后面的外部变量或其它文件中定义的外部变量;
    
  • 如果用extern声明的同时进行了初始化,则是定义:

    1
    
    extern const int vc_g = 28; // const默认为内部链接,显式声明为extern后为外部链接;
    

源文件中实现变量、函数的定义,并指定链接范围。头文件中书写外部需要使用的全局变量、函数声明及数据类型和宏的定义。

序三、通用扩展

Unix编译器和链接器常使用允许多重定义的“通用模式”,只要保证最多对一处定义进行初始化即可。

该方式被ANSI C标准称为一种“通用扩展”。某些古老的系统可能要求显式初始化以区别定义和外部声明。

通用扩展在《深入理解计算机系统》中解释为:多重定义的符号只允许最多一个强符号。函数和定义时已初始化的全局变量是强符号;未初始化的全局变量是弱符号。Unix链接器使用以下规则来处理多重定义的符号:

  • 规则一:不允许有多个强符号。在被多个源文件包含的头文件内定义的全局变量会被定义多次(预处理阶段会将头文件内容展开在源文件中),若在定义时显式地赋值(初始化),则会违反此规则。
  • 规则二:若存在一个强符号和多个弱符号,则选择强符号。
  • 规则三:若存在多个弱符号,则从这些弱符号中任选一个。

当不同文件内定义同名(即便类型和含义不同)的全局变量时,该变量共享同一块内存(地址相同)。若变量定义时均初始化,则会产生重定义(multiple definition)的链接错误;若某处变量定义时未初始化,则无链接错误,仅在因类型不同而大小不同时可能产生符号大小变化(size of symbol `XXX’ changed)的编译警告。

在最坏情况下,编译链接正常,但不同文件对同名全局变量读写时相互影响,引发非常诡异的问题。这种风险在使用无法接触源码的第三方库时尤为突出。

序四、本文由来

C语言工程通常用文件来实现模块化,模块化的要求是“高内聚、低耦合”,由此可以衍生出一系列头文件包含的处理原则、规则、建议。

曾以为一个“.c文件”(若无特殊说明,后文将.c和.cpp文件等统称为源文件)对应一个“.h文件”(若无特殊说明,后文将.h和.hpp文件等统称为头文件),源文件只包含它自身的头文件就好,若源文件中用到其他文件中的内容,在头文件把用到的头文件包含进来就可以了。貌似可以一直秉承这个理念进行代码编写,在工程文件数量小时,这种理念似乎看不出问题,但随着工程文件数量越来越多,就会发现自己这种思路有了弊端:头文件互相包含,导致编译时自以为有些宏变量声明了,它就能起作用,但实际测试发现这种方式编译后,有些声明的宏没能起到作用。(头文件采用了避免重复包含的extern "C"的写法)

其实这是一个不正确的编程习惯,应该秉承源文件对应的头文件只包含其它必须的头文件,任何非必须的头文件都不要包含;而在源文件里面要包含用到的所有头文件。这样写即使存在源文件内重复包含头文件也无伤大雅。

注:《Google 开源项目风格指南》对C++的头文件的要求是Self-contained,也即“自包含”。用通俗的话说就是头文件应该能够自给自足,可以作为第一个头文件被引入而无需依赖其他头文件。

对于C语言来说,头文件的规划很大程度上体现了系统规划的合理性,不合理的头文件设计是编译时间过长的根本原因之一。

注:举个例子,x.h依赖于y.h,而y.h又依赖于z.h,此时就发生了依赖传递,所有包含x.h的源文件都通过y.h依赖了z.h,无论这三个头文件的哪一个发生了变化,所有包含x.h的源文件都需要重新编译。此外依赖链过长,会导致头文件解析的时间也变长,哪怕其中大部分内容根本不会被使用。一个简单的链式依赖都会导致如此问题,更别说菱形依赖的幺蛾子了。

好在工程中早已得出一些通用的的设计方法,用来合理规划头文件。

序五、目录

  1. 原则

    • 头文件中适合放置接口的声明,不适合放置实现。
    • 头文件应当职责单一。
    • 头文件划分原则
    • 头文件的语义层次化原则及使用通用头文件
    • 头文件的语义相关性原则
    • 源文件内的头文件包含顺序应从最特殊到一般。
    • 精减原则:使用前置声明和extern函数声明,能不包含就不包含头文件
  2. 规则

    • 每一个.c文件应有一个同名.h文件,用于声明需要对外公开的接口。
    • 禁止头文件循环依赖,减少嵌套和交叉引用,尽量避免顺序依赖
    • 尽量在源文件中包含头文件,而非在头文件中。.c/.h文件禁止包含用不到的头文件
    • 头文件应当自包含和自完备的。
    • 头文件应包含哪些头文件仅取决于自身,而非包含该头文件的源文件
    • 总是编写内部#include保护符( #define 保护)。
    • 禁止在头文件中定义变量。
    • 只能通过包含头文件的方式使用其他.c提供的接口, 禁止在.c中通过extern的方式使用外部函数接口、变量。
    • 禁止在extern “C”中包含头文件。
  3. 建议

    • 一个模块通常包含多个.c文件,建议放在同一个目录下,目录名即为模块名。 为方便外部使用者,建议每一个模块提供一个.h,文件名为目录名。
    • 如果一个模块包含多个子模块,则建议每一个子模块提供一个对外的.h,文件名为子模块名。
    • 头文件不要使用非习惯用法的扩展名,如.inc。
    • 同一产品统一包含头文件排列方式。

1. 原则

  • 头文件中适合放置接口的声明,不适合放置实现。 头文件是模块(Module)或单元(Unit)的对外接口。头文件中应放置对外部的声明,如对外提供的函数声明、宏定义、类型定义等。

    内部使用的函数(相当于类的私有方法)声明不应放在头文件中。

    内部使用的宏、枚举、结构定义不应放入头文件中。

    变量定义不应放在头文件中,而应放在源文件中。

    变量的声明尽量不要放在头文件中,亦即尽量不要使用全局变量作为接口 。变量是模块或单元的内部实现细节,不应通过在头文件中声明的方式直接暴露给外部,应通过函数接口的方式进行对外暴露。 即使必须使用全局变量,也只应当在源文件中定义全局变量,在头文件中仅声明变量为全局的。

    头文件内不允许定义变量和函数,只能有宏、类型(typedef/struct/union/enum等)及变量和函数的声明。特殊情况下可extern基本类型的全局变量,源文件通过包含该头文件访问全局变量。但头文件内不应extern自定义类型(如结构体)的全局变量,否则将迫使本不需要访问该变量的源文件包含自定义类型所在头文件。

「全局变量的使用原则」

1)若全局变量仅在单个源文件中访问,则可将该变量改为该文件内的静态全局变量;

2)若全局变量仅由单个函数访问,则可将该变量改为该函数内的静态局部变量;

3)尽量不要使用extern声明全局变量,最好提供函数访问这些变量。直接暴露全局变量是不安全的,外部用户未必完全理解这些变量的含义。

4)设计和调用访问动态全局变量、静态全局变量、静态局部变量的函数时,需要考虑重入问题。