现代C/C++工程的模块化实践

C++在近年引入了import机制,允许开发者通过导入导出的库来替代传统的#include头文件展开方式。这种改进显著提升了代码可读性并减少了编译时间。相比之下,早期语言如C#和Java早已通过程序集(Assembly)等机制实现了模块化管理,而C/C++长期依赖原始的#include机制。

模块化工程的结构设计

基于模块化思想构建的C/C++工程应遵循分层原则。核心逻辑与接口分离,避免头文件的循环依赖。每个功能模块应具备清晰的边界,通过显式声明导出符号来控制可见性。

编译效率优化策略

采用预编译头文件(PCH)处理稳定的基础库依赖。将频繁变动的模块拆分为独立编译单元,利用并行编译加速构建过程。依赖管理工具如CMake可自动处理模块间的编译顺序。

代码组织规范

源文件与头文件按功能而非类型进行分组。测试代码与实现代码保持相同目录结构。版本控制忽略中间构建产物,仅保留必要的构建脚本和源文件。文档生成工具可自动从模块接口提取API说明。

跨平台兼容方案

抽象平台相关代码为独立适配层。构建系统通过条件编译处理不同环境的特性差异。包管理器统一管理第三方依赖的获取和链接过程。静态分析工具集成到持续集成流程中确保代码质量。

标准化工程目录结构

一个组织良好的工程通常包含以下核心部分,确保代码的可维护性和可移植性:

构建系统配置

  • CMakeLists.txtMakefile:用于定义编译规则和依赖管理,支持跨平台构建。

项目文档

  • README.md:提供项目概述、使用说明、构建步骤及协议信息(如 LICENSE 文件)。

源代码目录

  • src/:存放核心实现代码(.c/.cpp 文件),通常按模块划分子目录。

库文件目录

  • lib/:包含预编译的静态库(.a/.lib)或动态库(.so/.dll`),供链接阶段使用。

生成的可执行文件

  • bin/:存放编译后的二进制文件(如可执行程序或动态库),便于直接运行或分发。

头文件目录

  • include/:提供公开的接口声明(.h/.hpp),确保调用方能正确引用函数或类。

附加建议

  • 测试目录:建议添加 tests/ 存放单元测试或集成测试代码。
  • 第三方依赖:使用 third_party/deps/ 管理外部依赖项。
  • 脚本工具:可创建 scripts/ 放置自动化脚本(如部署、格式检查)。

此结构适用于大多数 C/C++ 项目,具体可根据实际需求调整。

三、工程的代码结构

1. 区分公开部分和非公开部分

我个人认为,一个良好的工程应该区分公开部分和非公开部分。公开和非公开是针对文件,而不是C++层面的public和private限定符。简单来说就是,我应该把别人要用的头文件放到include文件夹,并且如果依赖了其它库,且这个库位于自己的代码仓库,那么自己的源代码路径的头文件尽量不要用#include <>形式引用其它库(个人建议)。

举个例子,假设我有个工程A,它依赖了工程B,并且把工程B的include文件夹设置为了自己的include directory。

A --
   |--src
       |--A.h
       |--A.cpp

B --
   |--include
       |--B.h

并且A.h里面直接引用了B.h:

#pragma once
#include <B.h>

上面的代码在结构上有一些瑕疵。假如使用者C想要使用工程A,那么他首先需要include A.h。但是A工程里面并没有include文件夹,C迫不得已只能将src设置为自己的include文件夹。如果src文件夹里面有很多文件和C的头文件重名,那么代码会造成混乱,所以首先应该在A工程加个include文件夹,并且新建个A.h:

A --
   |--src
       |--A.h
       |--A.cpp
   |--include
       |--A.h

B --
   |--include
       |--B.h

include中A.h的代码非常简单,转而引用真正的A.h:

#include "../src/A.h"

这样,使用者C只需要把A的include文件夹设置为include directory,便可以使用A库所提供的功能了,避免了头文件的混乱。

下面来说一下另外一个问题。由于之前的假设是A和B在一个仓库,而A是通过设置B的include directory来引用B的头文件:

#pragma once
#include <B.h>

这意味着使用者C也必须将B的include也设置成自己工程的include directory,否则当编译器遇到#include <B.h>时,它只会尝试查找A/include/B.h,然后发现并不存在这个文件,抛出一个抱怨。

这种情况下,A中的src的A.h,应该以相对路径的方式来引用B.h,将它改为:

#pragma once
#include "../../B/include/B.h"

当然,这么改可能对于库作者来说是难看了点,但是减少了使用者出错的概率。

当然,还有其它很多种方法可以来解决这样的开发者工程属性使用者工程属性不一致而导致的问题,比如开发者可以写一个CMake宏,来自动帮使用者生成一个解决方案,并且设置正确的include directory。

2. 头文件的依赖一定要写清楚

什么意思呢?还是假设有一个工程A:

A --
   |--src
       |--A.h
       |--A.cpp
       |--B.h
   |--include
       |--A.h
       |--B.h

其中include中的A.h和B.h分别引入了src里面的A.h和B.h。其中B.h依赖了A.h,但是它又没有#include <A.h>:

// B.h
#progma once
inline void B_doSomething()
{
  A a;
  a.doSomething();
}

然后库作者写出了这样的代码:

#pragma once
#include <A.h>
#include <B.h>
inline void foo()
{
  B_doSomething();
}

这段代码对于库的开发者来说不会报错,因为虽然B.h没有include A.h,但是在include B.h之前,A.h已经被引入。但是对于使用者来说,就可能会很有些问题。他可能并不知道B.h是要依赖A.h的,假如他写了下面这样的代码:

#pragma once
#include <B.h>
inline void foo()
{
  B_doSomething();
}

直接报错!因为B_doSomething里面用到了A,但是B.h并没有include A.h。并且编译器的错误会非常含糊,会说找不到A这个类型,然后使用者一脸蒙蔽,他并不知道还需要哪个头文件!修正的方法就是修改B.h的头文件,让它include A.h:

// B.h
#progma once
#include <A.h>
inline void B_doSomething()
{
  A a;
  a.doSomething();
}

PS: 有些头文件,比如freetype或者windows的一些头文件,会检查某个头文件是否被include,否则会通过#error抛出个错误提醒用户引入。

3. 区分导出和非导出函数

当我们写一个动态库时,我们要设置函数的可见性,把需要公开的函数表明为可见函数,也就是导出函数。我们编写静态库时,则不能设置它可见性,因为它是二进制输出文件的一部分。

由于我们用到的是同一份头文件,因此最好的方法就是用宏来区分这个头文件是给谁用的。一般来说有2个群体,每个群体2种输出类型:

  • 这个头文件给库开发者用,静态库
  • 这个头文件给库开发者用,动态库
  • 这个头文件给库使用者用,静态库
  • 这个头文件给库使用者用,动态库

我们分别用2个宏,第一个宏用来区分是开发者还是使用者,第二个宏来区分是静态库还是动态库。

以GameMachine为例:

gamemachine和gamemachine_static分别是dll和lib库(开发者),gamemachinedemo和gamemachinedemo_dll分别是用了gamemachine和gamemachine_static的exe程序(使用者)。

gamemachine工程定义了GM_DLL宏,表示它是一个dll工程。gamemachinedemo定义了GM_USE_DLL宏,表示它用到的是gamemachine的动态库工程。

#ifndef GM_DECL_EXPORT
#	ifdef GM_WINDOWS
#		define GM_DECL_EXPORT __declspec(dllexport)
#	elif GM_GCC
#		define GM_DECL_EXPORT __attribute__((visibility("default")))
#	endif
#	ifndef GM_DECL_EXPORT
#		define GM_DECL_EXPORT
#	endif
#endif
#ifndef GM_DECL_IMPORT
#	if GM_WINDOWS
#		define GM_DECL_IMPORT __declspec(dllimport)
#	else
#		define GM_DECL_IMPORT
#	endif
#endif

#ifdef GM_DLL
#	ifndef GM_EXPORT
#		define GM_EXPORT GM_DECL_EXPORT
#	endif
#else
#	if GM_USE_DLL
#		ifndef GM_EXPORT
#			define GM_EXPORT GM_DECL_IMPORT
#		endif
#	else
#		ifndef GM_EXPORT
#			define GM_EXPORT
#		endif
#	endif
#endif

以上是GameMachine用来区分导入、导出函数的宏。以VS为例,GM_DECL_EXPORT表示__declspec(dllexport),GM_DECL_IMPORT表示__declspec(dllimport)。

那么:

1. gamemachine的GM_EXPORT将展开为__declspec(dllexport)

2. gamemachine的GM_EXPORT将展开为空

3. gamemachinedemo的GM_EXPORT将展开为空

4. gamemachinedemo_dll的GM_EXPORT将展开为__declspec(dllimport)

就这样,通过GM_EXPORT宏,巧妙将类和函数导出了。

class GM_EXPORT GameMachine
{
};

这是一种非常常见的手法,遍布各种库,它可以作为一个模板,应用到各个C/C++工程中去。

以上便是我对于良好的工程结构的一些理解,欢迎大家补充和讨论。

里面说的很多手法,主要还是要结合实际,实践才是检验真理的唯一标准。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐