之前在考虑编写一些个人的开源项目,不奢求成为什么明星项目,只希望把已有的技能和知识进行总结。一方面将来遇到类似需求时可以直接拿去用,另一方面也给遇到相同问题的人一些帮助。
最开始的想法是不管三七二十一先写起来,之后再逐步测试、修改和完善。没想到功能还没堆多少,那些觉得不会有问题懒得测的模块,总是写出低级 bug ;等想要添加单元测试时,面对的是写成一团的构建脚本;之后适配跨平台,每次提交代码都得在虚拟机之间来回切换、拉取代码和编译测试。
在总结失败教训并参考开源项目后,我决定编写一个适合自己需求的脚手架项目KRCppLibraryTemplate :
就我个人经历而言,当初入门学习 C++时确实遇到了很多困难,但好在有很多优秀的书籍和资料(根本看不完),大部分问题也可以通过搜索找到答案。
没想到跨过这座大山后,迎面而来的是另一座大山——构建:
当你觉得学有所成,打算写个稍微像样点的项目时,就一定会被这些问题深深困扰。
这些问题都来自于 C++编译模型,以及平台和编译器实现细节,没办法三言两语概括,当初学习时主要参考了如下资料:
如果想要新人消化这些内容,恐怕不太适合当今快节奏的职场环境,所以稍微划划重点,其他的就让 AI 来辅助吧:
#include <...>
和#include "..."
导入头文件时搜索路径的差异,以及如何指定自己所需的搜索路径;extern "C"
;使用构建系统的目的,主要是避免重复的手动编写编译脚本,同时自动分析依赖变更,从而控制重新编译的范围,加速构建过程。实际上构建系统还用于处理安装和打包等工作,有的支持不同平台,从而简化跨平台开发的工作。
不过构建系统自身也会带来一些复杂性,比如 make ,它基于手写依赖规则,并根据文件时间戳判断是否发生变动,如果漏写了依赖(头文件尤其常见),或是修改的是编译器宏定义等不修改文件的选项,很可能会得到错误的结果。
我曾经尝试系统的学习 make ,但最终结论是,遇到老项目,要么重写,要么别动它,遇事不决全量重新编译。
类似的,CMake 也有问题:
但话又说回来,涉及跨平台 C++开发,CMake 自身确实有问题,但选择 CMake 没啥问题。
比较系统和完善的参考资料如下:
现在的共识应该是 Modern CMake ,但我猜本就不多的 C++程序员里,懂得写 CMake 的人就更少了,更别提推动构建系统脚本的规范化。上面的知识从吸收到能够编写规范的代码需要很久的积累,所以这些就交给脚手架来完成吧。
经常关注 CMake 的人应该都了解过这几个项目:
最终我的选择是造轮子( C++程序员必经之路),从需求角度来说,是因为我希望脚手架应该尽可能封装平台差异,能够支持动态库和静态库,而不是在一开始就对用户强加限制。另一方面则是为了对 CMake 的工程实践有一个完整的了解。
从用户角度来说,根据需要选择动态库或静态库是很正常的需求。就算不考虑使用动态库实现平滑升级、功能插件等需求,对于被多个库或可执行程序依赖的情况,使用动态库也可以减少链接时间和空间占用(尤其是 FFmpeg )。真正应该避免的是在存在菱形依赖的情况下进行动态库和静态库的混编,windows 中经典的跨 DLL 内存问题便来源于此,详情可见Professional CMake: A Practical Guide的 Mixing Static And Shared Libraries 章节。
很多个人开发者不太乐于专门适配动态库(比如Catch2: issue 2895),主要原因应该是会增加额外的工作量。
同时支持两种库的话又产生了新的问题,文章Building a Dual Shared and Static Library with CMake中进行了一些讨论:
原文章中的解决方案不支持将动态库与静态库安装至同一个目录下,这里主要介绍我的解决方案。
用户可以通过 static 和 shared 两个别名目标显式链接至对应版本的库:
target_link_libraries(<app1> PRIVATE KRLibrary::static)
target_link_libraries(<app2> PRIVATE KRLibrary::shared)
也可以不显式链接,而是通过选项进行指定,当库依赖层次较深时,便于从外部进行控制:
# 不指定动态库和静态库,通过 BUILD_SHARED_LIBS 或 KRLibrary_USE_SHARED_LIBS 控制
# 后者优先级更高
set(KRLibrary_USE_SHARED_LIBS ON) # 比起硬编码,一般是通过命令行或 CMakeCache.txt 进行修改
find_package(KRLibrary REQUIRED)
target_link_libraries(<app> PRIVATE KRLibrary::KRLibrary)
动态库和静态库总是独立编译,不使用-fPIC
编译静态库,因为会对静态库用户造成不必要的性能损耗。
当库作为顶层项目进行构建时,认为用户是库本身的开发者或打包人员,此时默认对两种类型的库都进行编译。
当库作为子项目被引入时,认为用户可能只需要其中一种类型的库,此时为两种类型的库目标设置EXCLUDE_FROM_ALL属性,只有被使用时才会进行编译,避免用户产生不必要的构建开销。
windows 下还需要处理动态库和静态库的.lib
同名的问题,如果使用的是 MSBuild ,同名库文件很可能就被静默覆盖掉了,使用 Ninja 才会提示错误。我的解决方案是为 windows 下的静态库基本名称添加_static
后缀,如果有不同的需求可以按需自行调整。
MSVC 默认不导出动态库符号,需要手动通过dllexport 和 dllimport进行控制。GCC 和 Clang 默认导出全局符号,但也提供了对应的控制选项。对于上述属性,CMake 提供了<LANG>_VISIBILITY_PRESET和VISIBILITY_INLINES_HIDDEN进行控制。
从“让错误尽早被发现”的角度出发,我选择默认不导出符号:
CXX_VISIBILITY_PRESET "hidden"
VISIBILITY_INLINES_HIDDEN ON
而导出宏则交给GenerateExportHeader自动生成,开发过程中只需要引入生成的头文件,并为要导出的函数添加宏标记即可:
#pragma once
#include <krlibrary/export.hpp>
namespace krlibrary
{
KRLIBRARY_EXPORT void exported_hello();
} // namespace krlibrary
动态库和静态库共用一份头文件,因此使用静态库时,自动设置宏选项禁用符号导出属性,不需要用户手动处理:
target_compile_definitions(
"${static_target}"
PUBLIC "${project_name_uppercase}_STATIC_DEFINE"
)
安装一般涉及三类文件:
*Config.cmake
和*Targets.cmake
文件。从打包人员的角度出发,则可以分为两种场景:
*.cmake
文件。这两个选项是通过 CMake 的install功能中的 Componet 提供的,安装时默认都安装,也可以指定安装:
$ cmake --install build/ --prefix install/ --component KRLibrary_Runtime
-- Install configuration: "Release"
-- Up-to-date: install/lib/libkrlibrary.so.1.0.0
-- Up-to-date: install/lib/libkrlibrary.so.1
这里额外说明一下,install 中的 component 与 find_package 中的没有任何关系,完全是正交的概念。这里的可以理解为简单的打了个标签方便安装时进行选择,名字也可以按照自己的意图编写,例如干脆细分成*_Headers
、*_Static
、*_Runtime
等,然后添加到对应组件的属性中即可。
另一种角度是从需求角度出发,例如只需要动态库或者静态库之一,那么可以在构建阶段进行设置:
cmake -S . -B build/ -DKRLibrary_ENABLE_INSTALL_STATIC=OFF
同理可以通过选项KRLibrary_ENABLE_INSTALL_SHARED
控制动态库的安装。这两个选项和前述 componets 也是正交的。
提供了 Catch2 的集成,手动处理了它在构建中可能遇到的一些小问题。CI 方面则编写了比较通用的 Github Action 脚本,代码覆盖分析在 Linux 和 windows 平台分别使用gcovr和OpenCppCoverage,编译器警告选项适配了 GCC 、Clang 和 MSVC ,应该足够应付大部分开发场景了。
实际上还处理了一些比较细节的问题,但一方面篇幅所限,另一方面光是要介绍其应用场景可能就要想半天,需要的人就直接阅读源代码吧(懒)。