cmake之旅(8)
cmake之旅8cmake之旅8Modern CMake 与 target 思维1 传统 CMake 的问题2 Modern CMake 的核心思想3 深入理解 PUBLIC、PRIVATE、INTERFACE3.1 PRIVATE —— 自己用不传播3.2 PUBLIC —— 自己用也传播3.3 INTERFACE —— 自己不用只传播3.4 传播机制总结4 一个完整的对比示例4.1 传统方式4.2 Modern 方式5 INTERFACE 库 —— Header-only 的利器6 target_compile_features —— 语言标准的正确打开方式7 Modern CMake 规范总结8 本篇命令速查表9 总结与下一篇预告同系列文章cmake之旅(1):构建的过程cmake之旅(2):CMakeLists.txt 核心语法cmake之旅(3):多目录项目管理cmake之旅(4):静态库与动态库cmake之旅5):函数、宏与 .cmake 模块cmake之旅6查找和使用第三方库cmake之旅7编译选项与条件编译cmake之旅8Modern CMake 与 target 思维cmake之旅8Modern CMake 与 target 思维从第三篇开始我们就一直在和target_xxx系列命令打交道。PRIVATE、PUBLIC、INTERFACE 这三个关键字也反复出现。但到目前为止我们对它们的理解可能还停留在按照示例抄就行的层面。这一篇我们要把这些散落的知识点串成一个完整的体系理解Modern CMake到底在说什么以及为什么它值得你全面采用。1 传统 CMake 的问题先看一段传统风格的 CMakeLists.txtcmake_minimum_required(VERSION 2.8) project(OldStyle) # 全局设置头文件路径 include_directories(${CMAKE_SOURCE_DIR}/include) include_directories(/usr/local/include/somelib) # 全局设置链接目录 link_directories(/usr/local/lib) # 全局添加编译选项 add_definitions(-DUSE_FEATURE_X) add_definitions(-DVERSION1.0) # 全局添加编译标志 set(CMAKE_CXX_FLAGS ${CMAKE_CXX_FLAGS} -Wall -Wextra) add_executable(app_a src_a.cpp) add_executable(app_b src_b.cpp) add_library(mylib src_lib.cpp)这段代码有什么问题第一个问题是全局污染。include_directories、link_directories、add_definitions这些命令都是全局的。设置了之后所有目标都受影响。app_b可能根本不需要/usr/local/include/somelib但它也被加上了。mylib可能不应该有-DUSE_FEATURE_X但它也被定义了。第二个问题是依赖关系不透明。看这段代码你无法判断app_a到底依赖了什么。它的头文件路径、链接库、编译选项全部散落在全局设置中混在一起。当项目变大有几十个目标时理清每个目标的依赖关系几乎是不可能的。第三个问题是不能传播。假设mylib依赖了某个头文件路径链接mylib的app_a也需要这个路径。在传统 CMake 中你必须在app_a的构建配置中再写一遍——这就是重复而且容易遗漏。2 Modern CMake 的核心思想Modern CMake大约从 CMake 3.0 开始推行的核心思想可以用一句话概括一切以 target目标为中心通过目标之间的依赖关系自动传播构建需求。具体来说就是三个原则原则一用 target_xxx 替代全局命令传统命令全局Modern 命令目标级include_directories()target_include_directories()link_directories()不再需要link_libraries()target_link_libraries()add_definitions()target_compile_definitions()set(CMAKE_CXX_FLAGS ...)target_compile_options()原则二每个目标自行声明自己的需求每个库target应该清楚地声明我需要哪些头文件路径、我需要哪些编译选项、我依赖哪些其他库。而不是让使用者去猜。原则三通过 PUBLIC/PRIVATE/INTERFACE 控制传播目标的需求不仅仅是给自己用的有些需求需要传播给依赖自己的人。三个关键字精确控制了这种传播。3 深入理解 PUBLIC、PRIVATE、INTERFACE前面的篇章中我们已经多次接触了这三个关键字。现在我们用一个更完整的例子来彻底搞清楚它们的传播机制。假设项目中有三个目标形成链式依赖app → middle_lib → base_libapp依赖middle_libmiddle_lib依赖base_lib。3.1 PRIVATE —— 自己用不传播# base_lib 的头文件路径设为 PRIVATE target_include_directories(base_lib PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/internal)效果只有base_lib自身编译时能找到internal/下的头文件。middle_lib和app都无法找到。使用场景这些头文件是库的内部实现细节使用者不需要也不应该访问。3.2 PUBLIC —— 自己用也传播# base_lib 的头文件路径设为 PUBLIC target_include_directories(base_lib PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include)效果base_lib自身编译时能找到include/下的头文件middle_lib直接依赖 base_lib也能找到但app间接依赖也能找到。PUBLIC 的传播是递归的只要依赖链上有target_link_librariesPUBLIC 的属性就会一直传播下去。使用场景这些头文件是库的公开接口使用者在包含你的头文件时需要能找到它们。3.3 INTERFACE —— 自己不用只传播# header-only 库只有头文件没有 .cpp add_library(header_lib INTERFACE) target_include_directories(header_lib INTERFACE ${CMAKE_CURRENT_SOURCE_DIR}/include)效果header_lib自身不会被编译它是 INTERFACE 库没有源文件但任何链接了它的目标都能获得这个头文件路径。使用场景Header-only 库如 nlohmann/json本身不需要编译但使用者需要知道头文件在哪里。另外如果一个库的头文件中引用了某个依赖的头文件但实现文件中没有用到那个依赖也可以用 INTERFACE。3.4 传播机制总结用一张表来描述传播过程假设base_lib设置了某个属性base_lib 的可见性base_lib 自身middle_lib直接依赖app间接依赖PRIVATE可用不可用不可用PUBLIC可用可用可用INTERFACE不可用可用可用关键理解PUBLIC PRIVATE INTERFACE。它既给自己用也传播出去。4 一个完整的对比示例我们用同一个项目分别用传统方式和 Modern 方式来写感受差异。项目结构├── CMakeLists.txt ├── base │ ├── CMakeLists.txt │ ├── include │ │ └── base.h │ └── src │ └── base.cpp ├── middle │ ├── CMakeLists.txt │ ├── include │ │ └── middle.h │ └── src │ └── middle.cpp └── app ├── CMakeLists.txt └── main.cppapp依赖middlemiddle依赖base。4.1 传统方式顶层 CMakeLists.txtcmake_minimum_required(VERSION 3.10) project(TraditionalDemo LANGUAGES CXX) # 全局设置所有头文件路径 include_directories( ${CMAKE_SOURCE_DIR}/base/include ${CMAKE_SOURCE_DIR}/middle/include ) add_subdirectory(base) add_subdirectory(middle) add_subdirectory(app)base/CMakeLists.txtadd_library(base_lib src/base.cpp)middle/CMakeLists.txtadd_library(middle_lib src/middle.cpp) target_link_libraries(middle_lib base_lib)app/CMakeLists.txtadd_executable(app main.cpp) target_link_libraries(app middle_lib base_lib) # 必须手动写上 base_lib问题app只直接使用了middle_lib但因为middle.h中包含了base.happ也必须手动添加对base_lib的链接。如果base_lib又依赖了其他库你也得手动加上去。依赖链越长手动管理就越痛苦。4.2 Modern 方式顶层 CMakeLists.txtcmake_minimum_required(VERSION 3.10) project(ModernDemo LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED True) add_subdirectory(base) add_subdirectory(middle) add_subdirectory(app)干净得多——没有任何全局的include_directories。base/CMakeLists.txtadd_library(base_lib src/base.cpp) # PUBLIC自己编译需要使用者也需要因为 base.h 是公开接口 target_include_directories(base_lib PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include)middle/CMakeLists.txtadd_library(middle_lib src/middle.cpp) # PUBLIC自己编译需要使用者也需要 target_include_directories(middle_lib PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include) # PUBLICmiddle_lib 依赖 base_lib且在公开头文件中暴露了 base_lib 的接口 target_link_libraries(middle_lib PUBLIC base_lib)app/CMakeLists.txtadd_executable(app main.cpp) # 只需要写直接依赖base_lib 的路径和库通过 PUBLIC 自动传播过来了 target_link_libraries(app PRIVATE middle_lib)对比一下Modern 方式中app只声明了它直接依赖的middle_lib而base_lib的头文件路径和链接信息通过 PUBLIC 自动传播过来。新增或移除base_lib的依赖时只需要修改middle_lib的 CMakeLists.txtapp完全不受影响。5 INTERFACE 库 —— Header-only 的利器C 社区有大量的 Header-only 库只有.h没有.cpp比如 nlohmann/json、Catch2、spdlog 等。对于这类库CMake 提供了INTERFACE库类型# 定义一个 INTERFACE 库没有源文件 add_library(my_header_lib INTERFACE) # 所有属性都必须是 INTERFACE因为自身不编译 target_include_directories(my_header_lib INTERFACE ${CMAKE_CURRENT_SOURCE_DIR}/include) target_compile_definitions(my_header_lib INTERFACE SOME_MACRO1) target_compile_features(my_header_lib INTERFACE cxx_std_17)使用add_executable(app main.cpp) target_link_libraries(app PRIVATE my_header_lib)虽然my_header_lib没有任何编译产物不生成 .a 或 .so但通过target_link_libraries它的头文件路径、编译定义、语言标准等属性都会传播给app。INTERFACE 库是 Modern CMake 的一个优雅设计即使没有编译产物也能以 target 的形式参与依赖管理。6 target_compile_features —— 语言标准的正确打开方式之前我们用set(CMAKE_CXX_STANDARD 17)来设置 C 标准。这是全局设置所有目标都会使用 C17。Modern CMake 推荐用target_compile_features为每个目标单独指定所需的语言特性add_library(my_lib src.cpp) # 这个库需要 C17 标准 target_compile_features(my_lib PUBLIC cxx_std_17)好处是什么第一精细控制。不同的库可以使用不同的 C 标准——你的基础库只需要 C11高级库需要 C17完全可以分开设置。第二自动传播。如果设为 PUBLIC使用者会自动获得这个标准要求。编译器会选择满足所有依赖的最高标准。# base_lib 需要 C11 target_compile_features(base_lib PUBLIC cxx_std_11) # middle_lib 需要 C17 target_compile_features(middle_lib PUBLIC cxx_std_17) # app 链接了两者编译器会自动使用 C17取最高 target_link_libraries(app PRIVATE middle_lib base_lib)建议对于要发布给他人使用的库推荐用target_compile_features。对于自己的应用程序set(CMAKE_CXX_STANDARD 17)就够了更简单。7 Modern CMake 规范总结把 Modern CMake 的最佳实践浓缩为以下几条规范不要做不要使用include_directories()用target_include_directories()不要使用link_directories()用target_link_libraries()直接链接目标或完整路径不要使用link_libraries()用target_link_libraries()不要使用add_definitions()用target_compile_definitions()不要修改CMAKE_CXX_FLAGS用target_compile_options()或target_compile_features()不要使用cmake_minimum_required(VERSION 2.x)至少用 3.10 以上要做每个库自行声明自己的 PUBLIC 和 PRIVATE 需求使用导入目标XXX::XXX链接第三方库使用target_compile_features声明语言标准需求让依赖信息通过 PUBLIC/INTERFACE 自动传播而不是手动重复最低版本要求使用 3.10 或更高8 本篇命令速查表传统命令Modern 替代作用include_directories()target_include_directories()头文件路径add_definitions()target_compile_definitions()编译宏定义set(CMAKE_CXX_FLAGS ...)target_compile_options()编译选项link_libraries()target_link_libraries()链接库—target_compile_features()语言标准需求可见性关键字关键字自身编译用传播给依赖者典型场景PRIVATE是否内部实现细节PUBLIC是是公开头文件中用到的依赖INTERFACE否是Header-only 库、纯接口9 总结与下一篇预告这一篇我们系统地梳理了 Modern CMake 的核心思想以 target 为中心、通过 PUBLIC/PRIVATE/INTERFACE 控制依赖传播、告别全局命令。这是整个 CMake 学习中最重要的思维转变——从设置全局环境变为声明目标需求。掌握了 Modern CMake 的思维方式之后下一个自然的问题是我写好了一个优秀的库怎么把它安装到系统中让别人使用怎么让别人通过find_package就能找到我的库下一篇——cmake之旅9安装与导出我们来完成从构建库到发布库的闭环。