C/C++符号隐藏与依赖管理(三):头文件管理

前文谈了代码和库的符号隐藏手段。在C/C++中,无论我们如何对符号进行隐藏,最后该暴露给客户的API还是要声明到头文件中发布给别人使用。如何设计和管理好头文件,决定了我们更大范围内的依赖治理水平。

首先谈谈头文件设计。这里一个重要前提是要理解:头文件首先是提供给别人使用的

很多C/C++程序员习惯了一个实现文件对应一个头文件,因此总下意识的觉得头文件先是给自己用的,所以无论什么声明(宏、常量、类型、函数)都一股脑先声明到自己的头文件中。

这是个很糟糕的做法!因为客户使用你API的标准做法就是包含你的头文件,上述做法的头文件会将大量实现细节暴露给所有客户,增加了彼此的耦合,造成无谓的依赖和构建负担。

所以,首先要明白头文件是提供给别人使用的,否则把所有符号都声明在自己的实现文件里岂不是更简单。因此,头文件设计要站在客户的角度去思考:1)怎么让别人用着方便?即遵循自满足原则;2)怎么减少别人不必要的依赖?即遵循最小公开原则。

下面我们看看一个具体的C的头文件executor_api.h:

// executor_api.h

#ifndef H867A653E_0C66_4A68_80C4_B0F253647F7F
#define H867A653E_0C66_4A68_80C4_B0F253647F7F

#include "executor/keywords.h"
#include "executor/command_type.h"

#ifdef __cplusplus
extern "C" {
#endif

struct Executor;

MOD_PUBLIC struct Executor* executor_clone(const struct Executor* e);
MOD_PUBLIC void executor_exec(struct Executor* e, CommandType cmd);

#ifdef __cplusplus
}
#endif

#endif

上面是一个标准的C的头文件。首先为了保证每个头文件在同一个编译单元中只展开一次,头文件的内容必须处于Include Guard中,也即熟悉的#ifndef ... #define ... #endif中。

Include Guard中的宏需要全局唯一,一般使用路径名和文件名的大写加下划线。但这种做法有个问题是,当文件重命名后经常忘记改对应的宏,久而久之就会不小心出现冲突。

在有的地方你会看到使用#pragma once来作为Include Guard,不过这不是标准,存在兼容性问题。

在本例中我们仍旧是采用Include Guard的标准做法,只是宏采用IDE自动生成的UUID,这样既能保证全局唯一,也不会和文件名产生重复。没必要纠结这个宏的可读性,因为它只是给编译器看的,不是给程序员看的。

接下来为了自满足性,executor_api.h头文件中include了它依赖的其它头文件。本例中是"keywords.h"和 "command_type.h",它们分别定义了后面会用到的宏MOD_PUBLIC和枚举CommandType

再往下是如下语句块:

#ifdef __cplusplus
extern "C" {
#endif

//...

#ifdef __cplusplus
}
#endif

这个语句块表达了:如果该头文件被C++的程序所使用的话,就将中间的所有符号声明和定义包含在extern "C" { }语句块中间(因为C++的编译器中有__cplusplus的定义,而C编译器下没有)。

extern "C" { }指示大括号中的所有函数符号不要经过C++名称粉碎(name mangling)过程,全部按照C语言的标准进行符号链接。这样就可以保证C++程序能正确链接到C语言的函数实现。

注意这里对extern "C" { }用途的解释,它和extern的含义是完全不同的。extern "C" { }完全是为了让C语言的API也能被C++程序所使用,扩大C语言库可被复用的范围。

另外注意仔细看上例,extern "C" { }是放在所有的#include语句下面的,也就是说: extern "C" { }中间不要包含#include语句。我们希望每个头文件自己声明自己需要放置在extern "C" { }中的符号,不要为别的头文件代劳,否则可能出现某些匪夷所思的编译或链接错误(原因解释起来稍微有些复杂,记住这个原则就好了)。

如果可以保证C程序永远不会被C++程序调动,C的头文件中也可以不用加这个语句块。遗憾的是这个保证经常被打破,比如当前主流的C程序的单元测试框架大多是C++写的,因此当你要对所写的C程序做单元测试的时候,就必须把头文件交给C++程序使用。所以,如果没有特殊的原因,建议对所有的C语言头文件加上上述语句块,以保证其能在更大范围内使用。

我们继续看上例中的头文件,接下来的是一句前置声明struct Executor

前置声明是解除头文件依赖的好方法,一般函数的参数、返回值、以及结构体中的指针和引用类型等都只用前置声明即可,无需包含头文件。而枚举、宏以及需要知道内存布局或大小的类型定义,则需要显示包含头文件。

在上例中,CommandType由于是枚举所以必须包含头文件"command_type.h",而struct Executor在后面的函数声明中仅当做参数和返回值,而且都是使用其指针类型,因此只用前置声明而无需包含定义其结构体的头文件。

示例的头文件的最后是对外API executor_cloneexecutor_exec的函数的声明,这里还进一步使用了我们之前介绍过的MOD_PUBLIC进行API的显示导出。

上述这些基本是一个标准的C语言头文件的全貌。

前面我们说了,头文件首先是给别人用的,但是为了避免重复声明,自己也可以包含自己对外发布的头文件。

如本例,为了避免Executor的实现文件重复声明MOD_PUBLIC struct Executor* executor_clone(const struct Executor* e)MOD_PUBLIC void executor_exec(struct Executor* e, CommandType cmd),所以executor.c也包含了executor_api.h。

// executor.c

#include "executor/executor_api.h"

struct Executor {
    // ...
};

struct Executor* executor_clone(const struct Executor* e) {
    // ...
}

void executor_exec(struct Executor* e, CommandType cmd) {
    // ...
}

如果需要把某些符号通过头文件共享给内部其它实现文件,但是又不需要把这类头文件公布出去。这时建议把头文件分开,明确分成对外头文件和私有头文件。自己可以同时包含对外的和私有的头文件,但对外只发布公开头文件。

假设本例中,Executor的结构体定义需要向内部公开,但是外部并不需要看到。这时可以新创一个内部头文件executor.h包含struct Executor的定义,但对外仍然只发布executor_api.h。这时executor.c可以同时包含executor_api.h和executor.h,而外部客户只能包含executor_api.h,无法访问到executor.h。

除了按内外部用途将头文件分开,有的时候当满足 1)库的使用方明确且有限;2)库的使用方对库头文件中符号依赖存在明显差异;这时为了避免库的不同用户因为依赖相同的头文件而互相影响(例如库按照一个使用方的要求修改了头文件中的某个函数声明,却导致并不依赖该函数的其它使用方都要重新编译),这时可以按照“接口隔离原则”,把对外头文件按照不同用户进一步分开。一般集中式的大项目中划分的内部模块会容易满足上述条件,而开源代码由于并不能假设自己的用户所以一般不这么做。

OK,接下来我们遇到的问题是,当按照内外部用途拆分开的头文件越来越多,在目录结构上要如何进行有效的规划和管理呢?

继续用上面的例子示例,当前社区对于单个库目录的主流布局如下:

executor
│
│   README.md
│   CMakeLists.txt    
│   ...
│
└───include
│   │
│   └───executor
│       │   keywords.h
│       │   command_type.h
│       │   executor_api.h
│       │   ...
│   
└───src
│   │   executor.h
│   │   executor.c
│   │   ...    
│   │   CMakeLists.txt
│   
└───tests
│   │   executor_stub.h
│   │   executor_stub.cpp
│   │   executor_test.cpp
│   │   ...    
│   │   CMakeLists.txt
│   
└───benchmarks
│   │   performance_test.cpp
│   │   ...    
│   │   CMakeLists.txt
│   
└───examples
│   │   example.cpp
│   │   ...    
│   │   CMakeLists.txt
│   
└───docs
│   │   quickstart.md
│   │   apis.md
│   │   ...    

在这个目录布局中,首先会将所有对外发布的头文件都放在"include/<module_name>"目录下,这样方便发布的时候直接把include下的所有头文件一次导出。

这里在include目录和实际的头文件中间增加一层以模块名命名的目录(如include/executor),是为了无论自己还是发布后给别人用,都希望对外头文件的包含路径能明确的从模块名开始(make中-I统一指定到每个模块的include目录),这样方便一眼看出头文件是哪个模块的API。

例如上例中无论是内部还是外部使用executor_api.h,都希望写作#include "executor/executor_api.h",这样一眼看去便知当前依赖的是executor模块的API。

在上面的目录布局中,所有的实现文件都放在src目录下,内部头文件也放在src目录下,和自己的实现文件放在一起。

其它常见的顶级目录还有:

  • tests目录下是库的功能测试用例以及供测试代码使用的桩文件,还有测试单独使用的头文件;

  • benchmarks目录下是性能测试用例,或者其它非功能性测试用例;

  • examples目录下是库的示例代码,用于帮助客户理解库的功能以及API的常见用法;另外这里的代码示例也用于文档中的代码引用;

  • docs目录下是库的使用手册或者API接口文档等;

无论是include/executor目录,还是src、tests、benchmarks、examples目录,需要的时候都可以在内部继续划分子目录。

再稍微看看构建。库顶层的CMake文件用于对构建做整体控制,指定构建src目录,以及选择是否构建tests、benchmarks和examples。

src、tests、benchmarks和examples下有自己更具体的CMake文件用于控制内部的构建细节。由于对外头文件和内部头文件的分离,所以构建脚本的编写也变得容易。关于构建的话题,我们后面会详细的讲述,这里先略过。

上述目录结构是C/C++社区主流的一种布局规范。社区中还有其它的一些布局格式,但是经过对比并不比这个布局清晰及使用范围大。另外这个布局与其它和C/C++语言相似的现代化语言的标准布局是趋于一致的(如RUST)。

我们推荐在实践中尽量遵循上述目录布局规范。即使在一个集中式的大项目中,也请保持其中每个模块的目录布局符合上述规范,即内外部头文件分离,同时每个模块自己维护和管理自己的头文件。

切忌不要把所有模块的对外头文件都集中放到一个大目录下,这样会让每个模块的头文件和实现离得过远,还容易导致把所有模块的公开头文件一下子全部暴露给每个模块从而引起各种依赖混乱问题。这个话题我们在后面谈依赖管理时还会再聊。

至此,我们总结下对头文件设计和管理的一些建议:

1)明白头文件首先是提供给别人使用的,头文件设计要遵循自满足原则和最小公开原则;
2)遵循头文件的设计规范,本文提到了Include Guard,extern "C"和前置声明等使用时的一些最佳实践;
3)将对外头文件和对内头文件分开;在满足一定条件(库的使用方明确、有限,且对库接口的依赖存在明显差异)时,可以进一步按照接口隔离原则将对外头文件对不同用户分开;
4)对头文件的目录管理尽量遵循主流的社区规范;避免将所有模块或者库的对外头文件集中放置到一起然后暴露给所有用户;

C/C++符号隐藏与依赖管理(四):依赖管理

推荐阅读更多精彩内容