第28章:项目结构

有效项目结构的因素多种多样。对一个项目有效的东西可能对另一个项目无效,但是有一些事情是比较统一。项目生命周期的早期,选择灵活但可预测的目录结构,使其能够以最小的摩擦和重组进行发展。

最重要的决策是,项目应该构建为超级构建还是常规项目。两者是不同的,各有优缺点。决定很大程度上取决于项目想要如何对待依赖关系,以及是否意愿直接吸收它们,还是将它们隔离在子构建中。对于那些没有任何依赖关系的项目(重要的是,没有任何任何依赖关系),常规项目是显而易见的选择。但是,当存在依赖关系时,可能需要在正确的项目结构和顺利的构建工作间做权衡。

邮件列表、问题跟踪器和Q&A站点中最常见的主题是,试图使用一种项目结构,却期望它具有另一种结构的功能,从而产生问题。许多情况下,出现这种情况是因为项目以特定的结构开始,但随着依赖项的添加,该结构不再支持开发人员希望项目能够实现的功能。参与其中的人已经习惯了在现有的结构下工作,所以改变它可能造成非常混乱的局面,而且经常会遇到相当大的阻力。一个项目越老,这样的改变可能就越难。因此,需要在项目生命周期的早期处理依赖关系,并适当考虑未来的期望。

28.1. 超级构建结构

当依赖项不使用CMake作为构建系统时,超级构建往往是首选结构。将每个依赖项视为单独构建,主项目将指导整个序列,以及从一个依赖项的构建传递到另一个依赖项的方式。每个单独的构建都使用ExternalProject添加到主构建中,这样允许CMake查看每个构建产生的内容,并自动检测可以传递给其他依赖项的信息,从而避免在主构建中手动硬编码这些信息。即使所有的依赖项都使用CMake,超级构建仍然是首选,比如为了避免目标名称冲突或假设总是顶层项目的问题。

超级构建允许对独立依赖构建的顺序进行精确控制。例如,其他依赖项运行自己的配置阶段前,可能需要一个或多个依赖项完全完成构建,包括安装步骤。对于这样的示例,后面的配置步骤可以查看已安装的组件,并自动计算出适当的文件名、位置等。这在常规构建中是不可能的。

超构建可以使用顶层CMakeLists.txt文件实现,该文件遵循可预测的模式。一种为所有依赖项使用公共安装区域,而另一种是将每个依赖项安装到自己的安装区域。两者是相似的,使用通用的安装区域的定义稍微简单一些:

cmake_minimum_required(VERSION 3.0)
project(SuperbuildExample)
include(ExternalProject)

set(installDir ${CMAKE_CURRENT_BINARY_DIR}/install)

ExternalProject_Add(someDep1 ①
 ...
 INSTALL_DIR ${installDir}
 CMAKE_ARGS -DCMAKE_INSTALL_PREFIX:PATH=<INSTALL_DIR>
)
ExternalProject_Add(someDep2
 ...
 INSTALL_DIR ${installDir}
 CMAKE_ARGS -DCMAKE_INSTALL_PREFIX:PATH=<INSTALL_DIR>
 -DCMAKE_PREFIX_PATH:PATH=<INSTALL_DIR> ②
)
ExternalProject_Add_StepDependencies(someDep2 configure someDep1) ③

① 至少有一个依赖项不需要其他依赖项。

② 使用find_package()定位其依赖关系,通常只需将CMAKE_PREFIX_PATH设置为通用安装目录即可。

③ 添加步骤依赖项,以确保配置步骤只在安装了其他必需的依赖项之后运行。

如果每个依赖项都安装到自己的安装区域,与上面的唯一区别是,为后面的依赖项提供的CMAKE_PREFIX_PATH可能需要是之前所有依赖项安装的目录列表,而不仅仅是公共的安装目录。

如果依赖项不使用CMake作为构建系统,那么整体结构不会改变,只会改变依赖项构建细节的方式。例如,使用像autotools这样的构建系统的依赖项可能会这样指定:

ExternalProject_Add(someDep3
 INSTALL_DIR ${installDir}
 CONFIGURE_COMMAND <SOURCE_DIR>/configure --prefix <INSTALL_DIR>
 ...
)

可能还需要将其他选项传递给配置脚本,以更具体的方式告诉它在哪里找到依赖项。这显然会根据依赖项的配置功能而有所不同。

超级构建中打包就比较困难了。每个依赖项都可以控制自己的打包,因此顶层项目最终不打包任何东西。相反,如果确实需要支持打包,可能会给ExternalProject_Add()调用一个或多个自定义打包步骤。上一章演示了如何使用ExternalProject_Add_Step()函数来实现这个(类似的方法也可以用于非cmake的子项目):

ExternalProject_Add_Step(myProj package
 COMMAND ${CMAKE_COMMAND} --build <BINARY_DIR> --target package
 DEPENDEES build
 ALWAYS YES
 EXCLUDE_FROM_MAIN YES
)
ExternalProject_Add_StepTargets(myProj package)

超级构建所做的一切只是将其他外部项目组合在一起时,它们可以很好地工作。它们依赖于所有具有良好安装规则的外部项目,如果知道外部项目的位置,那么每个项目都应该能够找到它们自己的依赖项。如果这些都做不到,那顶层项目将不可避免地硬编码关于平台特定信息,这时超级构建就没那么好用了。

28.2. 非超级构建结构

如果项目没有依赖项,或者依赖项使用FetchContent或类似于git子模块的机制引入到主构建中,那么一些前瞻性计划将有助于避免以后的困难。真正帮助项目保持易于理解和使用的一种做法是,其顶级CMakeLists.txt更像一个目录。该结构可分为以下几个部分:

序言

包括最基本的设置,比如对cmake_minimum_required()和project()的调用。还包括使用FetchContent模块引入工具链文件和CMake助手库。这个部分通常非常短。

项目设置

这个高级的部分会做一些事情,比如设置一些全局属性和默认变量,会在CMake缓存中定义构建选项,可能会包含一些逻辑,以解决整个构建所需的一些事情。设置默认语言标准、构建类型和各种搜索路径。

依赖关系

引入外部依赖关系,以便对项目的其余部分可用。与其在顶层CMakeLists.txt文件中定义它们,不如将它们放在专门的目录中,这样更干净,更具有健壮性。

主要构建目标

理想情况下,这个部分应该是由一个或多个add_subdirectory()组成。

测试

虽然单元测试可以作为主源嵌入在相同的目录结构中,但集成测试可以位于主源之外的单独区域中。它们将添加到主构建目标之后。

打包

这通常应该是项目定义的最后一件事,最好还是在它自己的子目录中,以保持顶层的整洁性。

上面重复出现的模式是,除了序言和项目设置外,大多数内容最好在通过add_subdirectory()添加的子目录中定义。这不仅使顶层CMakeLists.txt文件更易于阅读和理解,而且允许每个子目录关注特定的区域。这有助于简化查找,还意味着可以使用目录作用域,最小化不相关区域的变量暴露给不需要了解它们的人。下面是一个简单的顶层CMakeLists.txt的例子,它遵循了上面的指导意见,看起来像这样:

# Preamble
cmake_minimum_required(VERSION 3.1)
project(MyProj)
enable_testing()

# Project wide setup
list(APPEND CMAKE_MODULE_PATH ${CMAKE_CURRENT_LIST_DIR}/cmake)
set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_STANDARD_REQUIRED YES)
set(CMAKE_CXX_EXTENSIONS NO)

# Externally provided content
add_subdirectory(dependencies)

# Main targets built by this project
add_subdirectory(src)

# Things typically only needed if we are the top level project
if(CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR)
 add_subdirectory(tests)
 add_subdirectory(packaging)
endif()

实践中,项目设置可能会包含比上面显示的更多的内容,并且可能会有其他目录用于项目构建(例如,用于文档,添加其他可安装内容,如脚本、图像等)。

如果按照上面关于在子目录中定义大多数内容的建议,项目源树的顶部目录通常只包含大部分管理文件。其中可能包括自述文件、许可信息、贡献说明等等。持续集成系统还经常在顶层目录中查找特定的文件名。将源文件保存在顶层目录之外,可以确保脚本专注于项目的高级描述。

将依赖项的处理委托给自己的子目录,可以实现两件事情。首先,确保了任何依赖项都不能看到相同的CMAKE_SOURCE_DIRCMAKE_CURRENT_SOURCE_DIR,因此可以通过比较这两个变量来检测是合并到更大的项目结构中,还是独立构建。上面的简单示例显示了不是顶层项目时,如何经常使用此方法来避免定义测试和打包信息。将所有依赖项处理放在自己的子目录中还可以确保,用于设置依赖项的非缓存变量不会意外地泄漏到构建的其他部分。这样做的结果是,它倾向于鼓励使用CMake目标,而不是变量,作为项目的其他部分利用依赖关系的手段。

使用FetchContent模块将项目依赖项合并到构建中的示例如下:

dependencies/CMakeLists.txt

include(FetchContent)

# Declare all the dependency details first in case any dependency wants
# to pull in some of the same ones (this keeps us in control)
FetchContent_Declare(jerry ...)
FetchContent_Declare(foo ...)
FetchContent_Declare(bar ...)

# Add each dependency if not already part of the build
FetchContent_GetProperties(foo)
if(NOT foo_POPULATED)
 FetchContent_Populate(foo)
 add_subdirectory(${foo_SOURCE_DIR} ${foo_BINARY_DIR})
endif()

FetchContent_GetProperties(bar)
if(NOT bar_POPULATED)
 FetchContent_Populate(bar)
 add_subdirectory(${bar_SOURCE_DIR} ${bar_BINARY_DIR})
endif()

依赖关系可能需要在add_subdirectory()之前设置某些变量。这应该在它自己的范围内完成,这样就不会影响在同一范围内添加的其他依赖项。因此,把每个依赖填充放到自己的子目录中也是很有用的,这将使上面的例子看起来像这样:

dependencies/CMakeLists.txt

include(FetchContent)

FetchContent_Declare(jerry ...)
FetchContent_Declare(foo ...)
FetchContent_Declare(bar ...)

add_subdirectory(foo)
add_subdirectory(bar)

子目录会看起来像这样:

dependencies/foo/CMakeLists.txt

FetchContent_GetProperties(foo)
if(NOT foo_POPULATED)
 FetchContent_Populate(foo)

 # Add any customizations needed before actually pulling in the dependency.
 # For example, build static libs by default and only build those targets
 # that another target depends on.
 set(BUILD_SHARED_LIBS NO)
 set_directory_properties(PROPERTIES EXCLUDE_FROM_ALL YES)

 # Now add the dependency
 add_subdirectory(${foo_SOURCE_DIR} ${foo_BINARY_DIR})
endif()

bar子目录在结构上是类似的。以上甚至可以扩展处理预构建的二进制包或源代码包:

FetchContent_GetProperties(foo)
if(NOT foo_POPULATED)
 FetchContent_Populate(foo)

 if(EXISTS ${foo_SOURCE_DIR}/CMakeLists.txt)
 # Probably source, but could still be a binary package that
 # provides itself through a top level CMakeLists.txt file
 add_subdirectory(${foo_SOURCE_DIR} ${foo_BINARY_DIR})
 else()
 # Must be a binary package, assume it provides a config file in a
 # standard location within its directory layout
 find_package(foo REQUIRED
 NO_DEFAULT_PATH
 PATHS ${foo_SOURCE_DIR}
 )
 # For this to be useful, imported targets must be promoted to global
 # so that other parts of the project can access them
 set_target_properties(foo::foo PROPERTIES IMPORTED_GLOBAL TRUE)
 endif()
endif()

FetchContent模块和IMPORTED_GLOBAL目标属性只在CMake 3.11之后可用。没有这些特性的情况下添加依赖关系会非常困难,需要在一些推荐原则上做出让步,或者放弃添加预构建的二进制包。由于不能将局部目标提升为全局目标,替代方法通常依赖于变量将信息传递回主构建,或者将全局目标定义为本地导入目标。另一种不太理想的方法直接从顶层CMakeLists.txt文件中添加依赖项,这会使项目难以合并到更大的项目层次结构中。如果不需要支持预构建的二进制包,则不需要IMPORTED_GLOBAL目标属性。为了支持3.11之前的CMake,像git子模块或file(DOWNLOAD)这样的技术可以作为FetchContent模块的替代品。

其他主项目的顶层子目录中,添加测试和打包并不需要任何特殊的东西,只需要遵循前面章节中已经介绍过的推荐实践即可。test子目录的内容和结构将特定于项目,而打包通常只需要一个CMakeLists.txt文件,可能还需要配置到构建目录中供cpack使用的其他一些文件。还可能包含包生成器使用的资源。src目录的结构是一个更大的主题,在28.5节中会进一步介绍。

28.3. 常见的顶层子目录

上一节提到了一些目录名,它们通常是在源树顶层下面的子目录。经常使用的目录如下:

  • cmake

  • dependencies

  • doc

  • src

  • tests

  • packaging

没有任何其他现有约定的情况下,鼓励项目使用这些目录名。CMake子目录中收集CMake助手脚本可以很容易地找到它们,允许开发人员浏览该目录的内容,并发现他们可能不知道的有用工具。顶层CMakeLists.txt的项目范围设置中的单个list(APPEND CMAKE_MODULE_PATH…)也使它们对整个项目可用。doc子目录可以方便地收集文档,如果使用像Markdown或Asciidoc这样的格式,并且文件包含彼此之间的相对链接,那么doc子目录将非常有用。

有一些项目应该避免的子目录名称。默认情况下,仅使用一个参数调用add_subdirectory()将在构建目录中产生一个同名的对应目录。项目应该避免使用可能导致与在构建区域中创建的预定义目录冲突的源目录名称。应避免包括以下内容:

  • Testing

  • CMakeFiles

  • CMakeScripts

  • 任何默认构建类型(即CMAKE_CONFIGURATION_TYPES的任何值)。

  • 任何以下划线开头的目录名。

由于某些文件系统可能不区分大小写,所以上面所有的名称都不应该用于任何大小写组合。其他用作安装目的地的常见目录名也可能出现在构建目录中,具体取决于构建二进制位置所使用的策略(在后面的28.5.2节中进一步讨论)。因此,避免源目录名(如bin、lib、share、man等)也是明智的选择。

一些项目选择顶层的include目录并在那里收集公共标头,而不是将它们保存在实现文件旁边。请注意,如果像这样分开头文件,一些IDE工具可能无法自动找到头文件,因此这样的方式可能对一些开发人员不太方便。它还倾向于对某个特定特性进行更改或不太本地化的bug修复。另一方面,专用的include目录清楚地告知哪些头文件是公开的,它们可以拥有与安装时相同的目录结构。这两种方法都有各自的优点,但是对于新开发人员来说,将头文件与相关的实现文件放在一起可能会更简单一些。

28.4. IDE项目

当使用Xcode或Visual Studio等项目生成器时,会在构建目录的顶部创建一个项目或解决方案文件。这个文件可以在IDE中打开,就像这个应用程序的其他项目文件一样,但它仍然在CMake的控制之下。重要的是,这些项目文件是作为构建的一部分生成的,所以不应该签入版本控制系统。IDE中对项目所做的更改将在下一次CMake运行时丢失。

因为Xcode或Visual Studio项目文件是由CMake生成的,这意味着项目的目标和文件在项目层次结构或文件树中显示的方式也在CMake项目的控制之下。CMake提供了许多属性,这些属性可以影响在某些IDE环境中如何对目标和文件进行分组和标记。第一级分组是针对目标的,可以通过将USE_FOLDERS全局属性设置为true来启用目标。然后可以使用FOLDER目标属性指定每个目标的位置,该属性区分大小写的名称,可以将目标放置在该名称下。要创建类似树的层次结构,可以使用前斜杠来分隔嵌套级别。如果文件夹属性为空或未设置,目标将保持在项目的顶层未分组。Xcode和Visual Studio生成器都遵循文件夹目标属性。

set_property(GLOBAL PROPERTY USE_FOLDERS YES)

add_executable(foo ...)
add_executable(bar ...)
add_executable(test_foo ...)
add_executable(test_bar ...)

set_target_properties(foo bar PROPERTIES FOLDER "Main apps")
set_target_properties(test_foo test_bar PROPERTIES FOLDER "Main apps/Tests")

CMake 3.11之前,默认情况下文件夹目标属性是空,而在CMake 3.12之前,由CMAKE_FOLDER变量初始化。

IDE中为目标显示的名称默认与CMake使用的目标名称相同。Visual Studio生成器允许通过设置PROJECT_LABEL目标属性来覆盖这个显示名称,但是Xcode生成器不支持这个设置。

set_target_properties(foo PROPERTIES PROJECT_LABEL "Foo Tastic")

有些目标是由CMake本身创建的,比如用于安装、打包、运行测试等等。

对于Xcode,大多数这些文件都没有显示在文件/目标树中,但是对于Visual Studio,默认情况下它们分组在一个名为CMakePredefinedTargets的文件夹下。可以用PREDEFINED_TARGETS_FOLDER全局属性覆盖它,但通常不会这样做。

每个目标对单个文件的分组也可以由CMake项目控制。这是使用source_group()命令完成的,并且独立于目标文件夹分组(也就是说,即使USE_FOLDERS全局属性为false或unset)。该命令有两种形式,第一种用于定义单个组:

source_group(group
 [FILES src...]
 [REGULAR_EXPRESSION regex]
)

组可以是一个简单的名称,用于对相关文件进行分组,也可以指定类似于目标的层次结构。由于历史原因,嵌套级别是由反斜杠而不是正斜杠定义的。为了正确的解析CMake,反斜杠必须转义,所以一个组foo下面的嵌套将以如下方式指定:

source_group(foo\\bar ...)

可以使用FILES参数指定单个文件,并假设路径相对于CMAKE_CURRENT_SOURCE_DIR。因为该命令不是特定于某个目标的,所以选项可以确保只有特定的文件受到分组的影响。如果项目希望定义一个更灵活的分组结构,使用REGULAR_EXPRESSION选项更合适。它可有效地应用于项目中所有目标的分组规则。特定文件可以匹配多个分组的情况下,文件条目优先于REGULAR_EXPRESSION,后定义的正则REGULAR_EXPRESSION组优先于前面定义的正则表达式的组。

下面的示例为所有目标设置了通用规则,将具有常用源和头文件扩展名的文件分组到Sources之下。测试源和头文件将覆盖该分组,并放在Tests组下,而特殊情况是special.cxx将放在自己的专用子组下面。

source_group(Sources REGULAR_EXPRESSION "\\.(c(xx|pp)?|hh?)$")
source_group(Tests REGULAR_EXPRESSION "test.*") # Overrides the above
source_group(Sources\\Special FILES special.cxx) # Overrides both of the above

CMake为源文件提供默认的源文件组,为头文件提供默认的头文件组,这些都可以覆盖,如上例所示。还可以定义其他默认组,如Resources和Object Files。

source_group()命令的第二种形式允许组层次结构遵循特定文件的目录结构。可以在CMake 3.8或更高版本中使用。

source_group(TREE root
 [PREFIX prefix]
 [FILES src...]
)

TREE选项指示命令根据根目录下的目录结构,对指定的文件进行分组。PREFIX选项可用于将该分组结构置于前缀父组或组层次结构之下。这可以与SOURCES目录属性一起使用,以重新生成组成目标所有源的目录结构,但前提是所有这些源都低于一个公共点(例如,没有从build目录生成的源)。许多目标都满足这些条件,因此通常可以使用下面的示例模式,快速而轻松地为在IDE中表示目标的方式提供某种结构。

# Only suitable if SOURCES does not contain generated files in this example
get_target_property(sources someTarget SOURCES)

source_group(TREE ${CMAKE_CURRENT_SOURCE_DIR}
 PREFIX "Magic\\Sources"
 FILES ${sources}
)

IDE通常只显示作为目标源显式添加的文件。如果目标定义为仅添加实现文件作为源,那么头文件通常不会出现在IDE文件列表中。因此,常见的做法是显式地列出头文件,即使它们不会进行编译。CMake会有忽略它们,而不是将它们添加到IDE源文件列表中。这不仅可以扩展到头文件,还可以用于添加其他未编译的文件,如图像、脚本和其他资源。一些特性,比如与MACOSX_PACKAGE_LOCATION源属性相关的特性,需要将一个文件作为源文件列出,才能产生任何效果。

某些情况下,可能希望源文件出现在IDE文件列表中,但不进行编译。例如,特定于平台的文件应该只在其他目标平台上编译和链接。为了防止CMake试图编译特定的文件,源文件的HEADER_FILE_ONLY源属性可以设置为true(不要被属性名称所迷惑,它的可以范围不仅仅是头文件)。

add_executable(myApp main.cpp net.cpp net_win.cpp)

if(NOT WIN32)
 # Don't need to compile this file for non-Windows platforms
 set_source_files_properties(net_win.cpp PROPERTIES
 HEADER_FILE_ONLY YES
 )
endif()

28.5. 定义目标

前面已经介绍了一系列CMake特性,允许详细定义目标。包括构建目标的源和其他文件、应该如何构建目标,以及目标间如何进行交互。本节的重点是演示如何使用这些技术,使项目易于理解,生成健壮的构建,并提高可维护性。

对于简单的项目,源文件和目标的数量可能很少,所有相关细节都在一个CMakeLists.txt文件中给出。如果遵循前面推荐的目录结构,这将意味着src目录将没有子目录,其CMakeLists.txt文件将定义所有内容。最初,看起来像这样:

src/CMakeLists.txt

add_executable(planter main.cpp soy.cpp coffee.cpp)

target_compile_definitions(planter PUBLIC COFFEE_FAMILY=Robusta)

add_test(NAME NoArgs COMMAND planter)
add_test(NAME WithArgs COMMAND planter beanType=soy)

这就对项目将如何使用做出了许多假设,最大的假设是项目不会安装或打包,并且不会吸收到更大的项目层次结构中。这些限制是可以避免的。上述简单案例的具体缺点有:

  • 目标名称不特定于项目,因此如果合并到更大的父项目中,目标名称可能与其他目标冲突。在目标名称上使用特定于项目的前缀是解决这个缺点的简单方法。

  • 由于没有安装规则,因此无法轻松地安装目标或将其包含在包中。

  • 没有定义带命名空间的别名目标,即使后来添加了install()命令并实现了打包,其他项目对于预构建的二进制文件和源码包含必须使用不同的目标名称。

  • 测试名称不是特定于项目的,如果这个项目吸收到更大的项目层次结构中,那么可能会与其他项目的测试名称发生冲突。同样,将项目名称或其他字符串合并到测试名称中,可以解决这个问题。

  • 总是添加测试,即使这不是顶级项目。对于具有许多测试的大型项目,这可能会增加不必要地构建时间。

  • 头文件没有作为源文件列出,所以它们不会出现在某些IDE中。

针对以上几点,并遵循前几章推荐的实践,该示例需要扩展到以下内容:

src/CMakeLists.txt

#=============================
# Define targets
#=============================
add_executable(BagOfBeans_planter main.cpp soy.cpp soy.h coffee.cpp coffee.h)
add_executable(BagOfBeans::BagOfBeans_planter ALIAS BagOfBeans_planter)
set_target_properties(BagOfBeans_planter PROPERTIES OUTPUT_NAME planter)
target_compile_definitions(BagOfBeans_planter PUBLIC COFFEE_FAMILY=Robusta)

#=============================
# Testing
#=============================
add_test(NAME BagOfBeans.planter.NoArgs COMMAND BagOfBeans_planter)
add_test(NAME BagOfBeans.planter.WithArgs COMMAND BagOfBeans_planter beanType=soy)

#=============================
# Packaging
#=============================
include(GNUInstallDirs)
install(TARGETS BagOfBeans_planter
 EXPORT BagOfBeans_apps
 DESTINATION ${CMAKE_INSTALL_BINDIR}
 COMPONENT BagOfBeans_apps
)

对于相当简单的可执行文件来说,这个CMakeLists.txt的信息量太大了。因为其强调了在实际项目中,除了单独构建二进制文件之外,还需要考虑更多事情。增加的复杂性主要是使用了较长的名称,减少了冲突的可能性。添加打包逻辑会增加大量的信息,这些细节对于没有经验的开发人员来说是很少接触到的。如上所示,向文件中添加分割符,可以帮助新开发人员更容易理解文件,并且随着项目的发展,还可以使文件保持组织。

28.5.1. 目标源

源文件的数量增加时,全部放在一个目录中会使处理变得更加困难。通常将它们放在按功能分组的子目录下,这还有一些其他的好处。不仅有助于避免事情变得过于混乱,还可以使基于CMake缓存选项,或其他配置时间的逻辑更容易打开或关闭。例如:

add_executable(BagOfBeans_planter main.cpp)

option(BAGOFBEANS_SOY "Support planting soy beans" ON)
option(BAGOFBEANS_COFFEE "Support planting coffee beans" ON)
if(BAGOFBEANS_SOY)
 add_subdirectory(soy)
endif()
if(BAGOFBEANS_COFFEE)
 add_subdirectory(coffee)
endif()

前面的所有章节中,可执行文件和库总是在一个目录中定义的,所以完整的文件列表可以直接提供给addexecutable()或add_library()调用。在上述安排中,子目录在使用target_sources()命令(CMake 3.1或更高版本可以使用)定义目标之后,将源添加到目标中。这个命令就像其他的target…()命令一样工作,并且有非常相似的形式:

target_sources(targetName
 <PRIVATE|PUBLIC|INTERFACE> src...
 # Repeat with more sections as needed
 ...
)

提供了一个或多个PRIVATE、PUBLIC或INTERFACE部分,每个部分都列出了要保存的源文件

添加到相关目标。将PRIVATE源添加到targetName的SOURCES属性,而将INTERFACE源添加到INTERFACE_SOURCES属性。PUBLIC源会同时添加到这两个属性中。考虑这一点的更实用的方法是将PRIVATE源编译到targetName中,将INTERFACE源添加到链接到targetName中,而将PUBLIC源添加到两者中。

除了PRIVATE以外都是不常见,因为向所有通过targetName链接的目标添加源文件的作用有限。可以使用它来添加需要作为转换单元的部分资源,或者嵌入不应该通过接口公开的内容,这些情况都不常见。

target_sources()的一个特性是,如果用相对路径指定了源,那么该路径是相对于添加目标的源目录,这就产生了许多问题。第一个问题是,如果作为INTERFACE源添加的,那么该路径将视为相对于其他目标的,而不是targetName。显然,这可能会创建不正确的路径,因此任何非PRIVATE源都必须使用绝对路径指定。第二个问题是,当从定义targetName的目录以外的目录调用target_sources()时,相对路径并不直观。考虑如何指定前面示例中一个目录的CMakeLists.txt文件:

src/coffee/CMakeLists.txt

target_sources(BagOfBeans_planter
 PRIVATE
 # WARNING: These will be wrong
 coffee.cpp
 coffee.h
)
...

上面的例子目的是添加来自相同目录的源文件,它们解释为相对于src而不是相对于src/coffee。解决这个问题最健壮的方法是在它们前面加上CMAKE_CURRENT_SOURCE_DIR或CMAKE_CURRENT_LIST_DIR,以确保始终使用正确的路径。

src/coffee/CMakeLists.txt

target_sources(BagOfBeans_planter
 PRIVATE
   ${CMAKE_CURRENT_LIST_DIR}/coffee.cpp
   ${CMAKE_CURRENT_LIST_DIR}/coffee.h
)

target_compile_definitions(BagOfBeans_planter
 PUBLIC COFFEE_FAMILY=Robusta
)

target_include_directories(BagOfBeans_planter
 PUBLIC $<BUILD_INTERFACE:${CMAKE_CURRENT_LIST_DIR}>
)

这种情况下,在每个源文件前面加上${CMAKE_CURRENT_LIST_DIR}$<...:${CMAKE_CURRENT_LIST_DIR}>不是很方便,也不直观。这方面的改进可能会在即将发布的版本中出现(必要的更改已经完成)。

上面还演示了如何将其他target_…()命令移动到子目录中,而不仅仅是target_sources(),这有助于将内容保存在本地。例如,只有在启用某个特性时,才能添加特定于该特性的编译定义、编译器标志和头文件搜索路径。如果需要重新组织目录结构,并将该目录移到其他位置,则该文件中的任何内容都不需要更改,目标中有#include "coffee.h”的源文件将继续工作,不进行修改。

这种信息本地化的例外是target_link_libraries(),它只能在同一目录中定义的目标上使用。如果子目录需要使目标链接到某个内容,就不能在该子目录中这样做。对target_link_libraries()的调用必须在调用add_executable()add_library()的目录中进行。例如,如果BagOfBeans_planter目标需要链接到名为weather的库,则必须在src/CMakeLists.txt中而不是在src/coffee/CMakeLists.txt中添加target_link_libraries()。这将会有如下结果:

option(BAGOFBEANS_COFFEE "Support planting coffee beans" ON)

if(BAGOFBEANS_COFFEE)
 add_subdirectory(coffee)
 target_link_libraries(BagOfBeans_planter PRIVATE weather)
endif()

CMake开发人员正在积极讨论这一限制,并在未来的CMake版本中删除或放宽。对于3.1到3.12的CMake版本,除了添加目标应该链接到的库之外,子目录可以完全自包含。CMake 3.1之前,需要完全不同的方法,它依赖于在变量中建立源列表,只有在所有子目录都添加之后才创建目标。可能是这样:

# Pre-CMake 3.1 method, avoid using this approach
unset(planterSources)
unset(planterDefines)
unset(planterOptions)
unset(planterLinkLibs)

# Subdirs are expected to add to the above variables using PARENT_SCOPE
option(BAGOFBEANS_SOY "Support planting soy beans" ON)
option(BAGOFBEANS_COFFEE "Support planting coffee beans" ON)
if(BAGOFBEANS_SOY)
 add_subdirectory(soy)
endif()
if(BAGOFBEANS_COFFEE)
 add_subdirectory(coffee)
endif()

# Lastly define the target and its other details. All variables
# are assumed to name PRIVATE items.
add_executable(BagOfBeans_planter ${planterSources})
target_compile_definitions(BagOfBeans_planter PRIVATE ${planterDefines})
target_compile_options(BagOfBeans_planter PRIVATE ${planterOptions})
target_link_libraries(BagOfBeans_planter PRIVATE ${planterLinkLibs})

如果有些项不需要是PRIVATE,上述问题就会变得更加复杂。这样的变量使用非常脆弱,因为不依赖于在子目录使用的变量,所以目标在变量中写错时CMake并不会发现这个错误。这些项还加强了父目录和子目录之间的耦合性,因为每个子目录都必须使用set(…PARENT_SCOPE)将相关的变量返回给父目录。对于嵌套很深的目录,这样做很快就会出错。

28.5.2. 目标的输出

构建库或可执行文件时,其默认位置是CMAKE_CURRENT_BINARY_DIR,或者是下面特定配置的子目录,具体取决于所使用的项目生成器类型。对于有许多子目录或深度嵌套层次结构的项目,这会给开发人员使用带来不便。对于这种情况,CMake提供了目标属性,让项目在一定程度上控制每个目标构建的二进制文件的输出位置:

RUNTIME_OUTPUT_DIRECTORY

用于所有平台上的可执行文件和Windows的DLL。

LIBRARY_OUTPUT_DIRECTORY

用于非Windows 平台上的动态库。

ARCHIVE_OUTPUT_DIRECTORY

用于所有平台上的静态库和Windows上与DLL库关联的导入库。

对于以上三种方式,Visual Studio和Xcode这样的多配置生成器将自动为每个值添加特定于配置的子目录(除非包含生成器表达式)。由于历史原因,也支持附加_<CONFIG>的配置属性,但是应该避免使用那些需要配置行为的生成器表达式。

这些目标属性常见的用途是将库和可执行文件,一起收集到与安装时类似的目录结构中。如果应用程序希望各种资源位于相对于可执行文件的特定位置,这也很有用。Windows上,可以简化调试,因为可执行文件和DLL可以收集到同一个目录中,允许可执行文件自动找到它们的DLL依赖项(其他平台上不需要这样做,RPATH可以将必要的位置嵌入到二进制文件中)。

按照通常的模式,这些目标属性都由同名的CMake变量初始化,并使用CMAKE_前缀。当所有目标都使用相同的输出位置时,可以在项目的顶层设置这些变量,这样就不必为每个目标单独设置属性。为了将项目合并到更大的项目层次结构中,只有在还没有设置这些变量时,才设置它们,以便父项目可以覆盖输出位置。还应该使用相对于CMAKE_CURRENT_BINARY_DIR的位置,而不是CMAKE_BINARY_DIR。下面的示例展示了如何安全地收集当前构建目录下的stage子目录下的二进制文件(除非父项目进行了覆盖)。

include(GNUInstallDirs)
if(NOT CMAKE_RUNTIME_OUTPUT_DIRECTORY)
 set(CMAKE_RUNTIME_OUTPUT_DIRECTORY
 ${CMAKE_CURRENT_BINARY_DIR}/stage/${CMAKE_INSTALL_BINDIR})
endif()
if(NOT CMAKE_LIBRARY_OUTPUT_DIRECTORY)
 set(CMAKE_LIBRARY_OUTPUT_DIRECTORY
 ${CMAKE_CURRENT_BINARY_DIR}/stage/${CMAKE_INSTALL_LIBDIR})
endif()
if(NOT CMAKE_ARCHIVE_OUTPUT_DIRECTORY)
 set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY
 ${CMAKE_CURRENT_BINARY_DIR}/stage/${CMAKE_INSTALL_LIBDIR})
endif()

避免创建CMAKE…OUTPUT_DIRECTORY作为缓存变量,因为它们不应该在开发人员的控制之下,应该由项目控制,项目的各个部分可能会对二进制文件的相对布局做出设定。更重要的是,将它们作为普通变量,意味着可以在定义测试可执行文件的子目录中取消设置,从而避免将与其他二进制文件一起收集,使该区域变得混乱。

二进制文件的名称也可以由项目控制。默认情况下,二进制文件的名称将与目标名称相同。当目标名称遵循合并项目名称的约定时(当作为较大项目层次结构的一部分时,可以保持它们的惟一性),目标名称可能不适合作为二进制文件的名称,因此可能需要重写此默认值。OUTPUT_NAME目标属性可以设置为二进制文件使用的名称,或者对于特殊情况,可以设置更具体的RUNTIME_OUTPUT_NAMELIBRARY_OUTPUT_NAMEARCHIVE_OUTPUT_NAME属性。大多数情况下,OUTPUT_NAME就足够了,也是首选的。

add_executable(BagOfBeans_planter ...)
set_target_properties(BagOfBeans_planter PROPERTIES OUTPUT_NAME planter)

由于历史原因,也支持如OUTPUT_NAME_<CONFIG>这样的特定配置变量,但是项目应该更倾向于使用生成器表达式。

较老的项目有时会尝试读取LOCATION目标属性,以确定二进制文件的输出位置和名称,并在自定义目标命令或其他类似逻辑中使用它。这对于多配置生成器是有问题的,因为位置取决于配置,但是LOCATION目标属性没有考虑到这一点。CMake 3.0及以后版本会在项目试图设置此目标属性时发出警告。项目应该使用像$<TARGET_FILE:…>这样的生成器表达式。

28.5.3. Windows平台的问题

Windows不支持RPATH会开发人员带来了许多问题。开发期间运行可执行文件时,可执行文件需要的DLL必须位于同一目录中,或者位于PATH环境变量中。为项目的主要二进制文件设置各种…_OUTPUT_PATH属性将可执行文件和库放在相同的位置,但是这种技术对测试文件不太方便,因为可能有许多可执行文件,并且将它们全部放在一个输出目录中使用起来也比较困难。

对于通过ctest执行的测试,可以使用ENVIRONMENT测试属性将所需的DLL目录添加到路径,如下所示:

add_executable(fooTest ...)
target_link_libraries(fooTest PRIVATE algo)
add_test(NAME fooWithAlgo COMMAND fooTest)
if(WIN32)
 set_tests_properties(fooWithAlgo PROPERTIES ENVIRONMENT
 "PATH=$<SHELL_PATH:$<TARGET_FILE_DIR:algo>>$<SEMICOLON>$ENV{PATH}"
 )
endif()

这对测试可执行文件在调试器下的Visual Studio IDE中运行没有帮助,所以需要更多的操作。CMake 3.8增加了对VS_USER_PROPS目标属性的支持,该属性可以在每个目标基础上覆盖用户属性文件的位置。创建自定义属性文件时,可以将其LocalDebuggerEnvironment将PATH项添加到默认PATH中。如果测试所需的DLL都集中在少数的几个位置,可以为每个测试生成一个用户属性文件并重用它(如果需要,仍然可以为每个目标生成和使用自定义用户属性文件)。可以使用configure_file()命令自动填充输出目录。

file(TO_NATIVE_PATH ${CMAKE_RUNTIME_OUTPUT_DIRECTORY} baseDir)
configure_file(user.props.in user.props @ONLY)

用户属性文件有点复杂,但这是一个相当基础的例子,可以使用是示例可能是这样的:

user.props.in

<?xml version="1.0" encoding="utf-8"?>
<Project DefaultTargets="Build" ToolsVersion="15.0"
 xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
 <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|Win32'">
 <LocalDebuggerEnvironment>PATH=@baseDir@\Debug</LocalDebuggerEnvironment>
 </PropertyGroup>
 <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|Win32'">
 <LocalDebuggerEnvironment>PATH=@baseDir@\Release</LocalDebuggerEnvironment>
 </PropertyGroup>
</Project>

用户属性文件不仅可用于设置调试器环境,上面的内容还可以为希望进一步研究此技术的开发者提供了一个起点。

对于Windows的可执行文件和DLL,通常会生成一个PDB(程序数据库)文件,以便在开发期间提供调试信息。有两种PDB文件,CMake为这两种文件提供了特性。对于动态库和可执行文件,可以使用PDB_NAME和特定于配置的PDB_NAME_<config>目标属性来覆盖PDB文件的名称。默认名称通常就挺好,因为它匹配DLL或可执行文件的名称,后缀为.pdb,而不是.dll或.exe。默认情况下,PDB文件放置在与DLL或可执行文件相同的目录中,也可用PDB_OUTPUT_DIRECTORY和配置特定的PDB_OUTPUT_DIRECTORY_<config>目标属性覆盖。注意,与其他…_OUTPUT_DIRECTORY属性不同,PDB_OUTPUT_DIRECTORY不支持CMake 3.11或更早版本的生成器表达式。

CMake还会创建第二种PDB文件,它保存为目标构建的各个对象文件的信息。这个PDB文件在开发过程中用处不大,除了用于静态库之外。对于C++,这个PDB文件的默认名称是VCxx.pdb。其中xx代表正在使用的Visual C++版本(例如VC14.pdb)。因为默认名称不是特定于目标的,所以在某些情况下很容易出错,并可能将不同目标的PDB混淆。CMake允许使用COMPILE_PDB和特定的COMPILE_PDB_<config>目标属性来控制每个目标PDB文件的名称。这些PDB文件的位置也可以用COMPILE_PDB_OUTPUT_DIRECTORYCOMPILE_PDB_OUTPUT_DIRECTORY_<config>目标属性覆盖。请注意,这些对象PDB文件对于DLL和可执行目标没什么用处,因为主PDB已经包含了所需的所有调试信息。

28.6. 杂项工程特性

项目生成器通常提供一些清理目标,可以用来删除所有生成的文件、构建输出等。IDE工具有时使用它来进行重新构建,作为在构建之后进行清理,或者开发人员使用它来删除构建输出,以便在下一次构建时强制重新构建所有内容。有时候,项目定义了一个规则,创建了CMake不知道的文件,所以不包括在清理步骤中,并且仍然有可能影响下一个构建。项目可以通过将这些文件添加到ADDITIONAL_MAKE_CLEAN_FILES目录属性来告诉CMake有关这些文件的信息,该属性保存该目录范围的文件列表,该目录范围应该是清理目标的一部分。只有Makefile系列生成器支持这一点。Ninja生成器不支持该属性,但它可以通过add_custom_command()add_custom_target()等命令提供的选项提供更健壮的替代方法。通过将这些文件作为副产品列出,Ninja知道在构建清理目标时要删除它们。其他项目生成器这样的功能。

某些高级的方法可能需要CMake重新运行,如果特定的文件进行更改。CMake在自动跟踪依赖关系方面做得很好,比如使用configure_file()命令复制文件,但是自定义命令和其他任务可能依赖于CMake没有意识到的文件。可以将这些文件添加到CMAKE_CONFIGURE_DEPENDS目录属性中,如果列出的文件发生了更改,将在下一次构建之前重新运行CMake。如果用相对路径指定文件,则文件将视为相对于与目录属性相关联的源目录。大多数项目通常不需要使用CMAKE_CONFIGURE_DEPENDS目录属性,当CMake没有机会了解作为配置或生成步骤的输入的文件时,应该使用它。大多数文件依赖关系是构建时依赖关系,而不是配置或生成时依赖关系,因此在使用此属性之前,请检查项目是否真的需要重新运行CMake,而不是作为常规构建的一部分简单地重新编译源文件或目标。

有时会出现这样的情况:需要将来自外部的源项目添加到构建中,但它存在一些问题,无法正常工作。一些常见的例子包括没设置相关的变量或属性,特别是当项目支持非常老的CMake版本,并且没有更新来处理新的CMake特性和检查时。对于其中的一些问题,注入CMake代码解决问题是可能的(不修改外部项目)。project()命令有一个特性,会检查名为CMAKE_PROJECT_<PROJNAME>_INCLUDE的变量,其中是project()命令的项目名。如果定义了该变量,则假定该变量包含文件的名称,CMake应该将该文件作为project()命令返回之前执行的最后一项内容包含进来。实际上,project()命令是这样工作的:

project(SomeProj)
if(CMAKE_PROJECT_SomeProj_INCLUDE)
 include(${CMAKE_PROJECT_SomeProj_INCLUDE})
endif()

因为每个project()调用都支持这种行为,因此每个project()调用都成为CMake代码注入的点。可以用来更改项目中目标属性的默认值,也可以用于添加额外的编译器或链接器标志等。该特性的另一个特别方便的用法是,可以安全地为持续集成构建设置选项,而不必将它们保存在CMake缓存中。这意味着增量构建不会受到旧的CMake缓存选项的影响,旧的CMake缓存选项会在项目进行更改后删除或不再设置。

例如,开发人员正在处理集成分支,其中应该临时启用额外的检查。一种简单的方法是显式地设置像CMAKE_C_FLAGSCMAKE_CXX_FLAGS这样的变量,但是由于CI脚本不应该更改项目本身,唯一的选择就是将它们设置为缓存选项。当分支合并时,缓存选项将继续提供给增量构建,但不再应用。这时惟一的操作是清除缓存,这可能会强制进行完整的重新构建。更好的替代方法是使用CMAKE_PROJECT_<PROJNAME>_INCLUDE,在最顶层的project()调用结束时,处理特定于CI的文件。这个文件会像项目的其他部分一样处于源代码控制下。分支合并前,该文件会恢复正常,并且构建不会保留临时标志。

CMakeLists.txt

cmake_minimum_required(VERSION 3.0)
project(MyProj)
...

CI系统会像这样调用CMake:

cmake -D CMAKE_PROJECT_MyProj_INCLUDE:FILEPATH=path/to/ciOptions.cmake ...

文件ciOptions.cmake可能是空的,或者只是包含一些常见的设置,比如打开可选特性。对于分支,它可能包含如下内容:

ciOptions.cmake

compile_definitions(DO_EXTRA_CI_CHECKS=1)
set(ENABLE_SANITIZERS YES)

像这样将文件注入到project()命令中不应该是正常开发的一部分。它有特定的用途,用于克服较旧项目中的缺陷,以及一些非常的情况,比如在持续集成构建中,但在这些情况之外,开发人员通常更喜欢直接添加或修改项目的CMakeLists.txt文件。

28.7. 总结

项目的结构和使用方式可以不同。一些过去常见的事情现在看起来就非常糟糕,因为新特性和经验使旧方法需要被更健壮、更灵活的方法取代,并可以完成以前完成不了的事情。工具在升级,语言在发展,依赖关系在变化——所有这些都意味着项目也需要随着时间的推移而更新。特别是对于CMake项目来说,以3.0之前的旧CMake版本为目标的项目将面临越来越多的坎坷。现在正朝着以目标为中心的模式迈进,CMake的大部分开发都是朝着这个方向发展的。因此,最好使用支持这些特性的CMake版本。低于CMake 3.1的内容都可能过于严格,由于更新了语言支持和新特性,尽可能至少考虑CMake 3.7。如果使用较新的工具,如CUDA或新的语言标准,强烈建议使用最新的CMake版本。Visual Studio或Xcode的新版本也倾向于需要新的CMake版本,以便为工具链中的变化进行修补。

每个项目都需要做出的一个选择,将自己构建为超级建筑还是常规建筑。如果项目将CMake的最小版本设置为3.11,那么非超级构建的方式将有更强大的特性进行依赖管理,这可能会使超级构建的需求变得不必要。考虑FetchContent模块和将本地导入目标推广到全局范围,是否为开发人员提供了更大的灵活性和更好的体验。当项目的所有依赖项都相对成熟,并且有良好的安装规则时,超级构建可能是一个合适的选择,其优势是可以与旧的CMake版本一起使用。这两种方法都有自己的定位,但是在项目的生命周期中越早决定是否使用超级构建,项目就越有可能避免以后的大规模破坏性重组。

不管项目是不是超级构建,目标都是让项目的顶层集中在高层上。可以将顶层CMakeLists.txt文件看作是项目的目录。顶层目录应该包含管理文件和一组子目录,每个子目录都关注特定的区域。避免子目录名称与在构建目录中自动创建的名称发生冲突。宁愿使用相当标准的名称,除非有必须遵守的协议。

对于常规项目,目标是使顶层CMakeLists.txt文件遵循以下常用模式:

  • 序言

  • 项目设置

  • 依赖关系

  • 构建目标

  • 测试

  • 打包

用注释块清楚地描述每个部分,将有助于项目的开发人员维护该结构。跨项目建立此模式将有助于加强对顶层CMakeLists.txt文件流水的关注,可以看作为流程概述。

定义分散在不同目录中的源构建目标时,最好先创建目标,然后使用target_sources()添加目录源。适当的情况下,按功能或特性对子目录进行分组,以便它们可以很容易地移动或作为一个单元启用/禁用。其他以目标为中心的命令(即target_compile_definitions()target_compile_options()target_include_directory())也可以在相关的子目录中使用。这有助于将信息保持在与其相关的位置,而不是分散到不同的目录中。避免使用变量来建立源列表,通过目录层次结构向上传递,并最终用于创建目标、定义编译器标志等。使用变量而不是直接操作目标更脆弱、更冗长,而且会让CMake捕捉不到拼写错误或其他错误。

按照上面的建议,构建简单的目标,避免使用不必要地变量来保存目标或项目的名称。应特别避免下列模式:

set(projectName ...)
project(${projectName})
add_executable(${projectName} ...)

上面的例子把不应该有的情况放在一起。项目名称很少更改,在project()命令中直接指定项目的名称,如果需要在项目的其他地方引用它,使用CMake提供的标准变量。对于目标,目标名称会广泛的使用,在变量中携带它既麻烦又容易出错。为目标指定名称,并在整个项目中始终使用该名称。即使整个项目中只有一个目标,也不一定必须与项目名称相同,两者应该视为独立的,而非捆绑在一起。

添加测试时,考虑将测试代码放在与被测试代码较近的位置。这有助于将逻辑相关的代码放在一起,并鼓励开发人员使测试保持最新。源目录其他部分的测试很容易被遗忘,对于涉及多个领域的测试(如集成测试),局部性不是很强,因此在公共位置收集这些较高级别的测试可能更合适。顶层测试子目录可以用于此类情况。

对于较大的项目,请考虑是否值得用IDE工具中表示项目的方式组织项目。如果有许多目标,除非使用文件夹目标属性添加了一些结构,否则很难处理该项目。对于那些有许多源的目标,可以使用source_group()命令对结构进行组织,该命令可用于围绕任何有意义的概念或特性定义层次结构。

应该特别考虑那些在Windows上构建的项目,开发人员可能使用Visual Studio IDE的项目。缺少RPATH支持意味着可执行文件依赖于能够在同一目录中,或通过PATH环境变量找到它们的DLL依赖项。这既影响了通过ctest运行的测试程序,也影响了开发人员在Visual Studio IDE中运行可执行文件的能力。强制所有可执行文件和DLL在相同的输出目录中是解决问题的一种方法,可以通过各种…OUTPUT_DIRECTORY目标属性,及关联的CMAKE_…OUTPUT_DIRECTORY变量实现。避免在后期构建规则或自定义任务中复制dll,可以将它们放在多个位置,以便其他可执行程序找到它们。但这种方式是脆弱的,很容易导致错误地使用dll。

测试程序最好不要收集到与主程序和dll相同的位置。一些测试代码可能需要找到相对于它们自己位置的其他文件,因此将它们分开是一种需要。使用ENVIRONMENT测试属性指定适当的PATH,以确保测试在通过ctest运行时能够找到所需的dll。还可以考虑使用CMake 3.8或更高版本,并定义一个用户属性文件,然后使用VS_USER_PROPS目标属性来识别测试目标。这可用于具有调试器的环境,以便直接在Visual Studio IDE中运行测试。

使用Visual Studio生成器时,最好保持PDB的默认设置。这会让PDB文件出现在开发人员期望的位置,并具有与可执行文件或库匹配的名称。当使用生成器表达式时,尝试更改PDB文件的输出目录实现起来非常复杂,某些情况下,很难将PDB文件放入所需目录。

Last updated