第7章:子目录

对于简单的项目来说,将所有内容保存在一个目录中没什么问题,但大多数项目倾向于将文件放到多个目录中。通常会发现不同的文件类型或分组有自己的目录,或者属于逻辑功能文件在项目的目录层次结构中,有属于它们的部分。虽然目录结构可能由开发人员对项目的想法所形成,但项目结构也会影响构建系统。

任何多目录项目中有两个基本CMake命令是add_subdirectory()include()。这些命令将另一个文件或目录的内容引入到构建中,从而允许构建逻辑分布在整个目录结构中。这样做的好处有:

  • 本地化构建,这意味着构建可以在相关的目录中定义。

  • 构建可以由子组件组成,这些子组件的独立于顶层项目(项目使用git子库或嵌入第三方源)。

  • 由于目录可以自包含,因此只需选择是否在该目录中添加,就可以轻松地打开或关闭部分构建。

add_subdirectory()include()具有不同的特性,所以了解其优缺点非常重要。

7.1. add_subdirectory()

add_subdirectory()允许项目将另一个目录引入到构建中。该目录必须有CMakeLists.txt文件,调用add_subdirectory()处理时,在项目的构建中会为其创建相应的目录。

add_subdirectory(sourceDir [ binaryDir ] [ EXCLUDE_FROM_ALL ])

sourceDir不必是源树中的子目录(不过通常都是),可以添加任何目录,将sourceDir指定为绝对路径或相对路径,相对路径相对于当前源目录。通常只在添加主源之外的目录时才需要绝对路径。

通常,不需要指定binaryDir。当省略这个目录时,CMake会创建一个具有相同名称的目录sourceDir。如果sourceDir包含任何路径组件,这些组件将映射到CMake创建的binaryDir中。另外,可以显式地将binaryDir指定为绝对路径或相对路径,相对路径是相对于当前二进制目录进行计算的(稍后将详细讨论)。如果sourceDir是源之外的路径,则需要指定binaryDir,因为相应的路径不能再进行自动构建。

EXCLUDE_FROM_ALL关键字,用于控制子目录中的目标是否包含在项目的ALL目标中。不过,对于某些CMake版本和项目生成器,并不总是像预期的那样工作,甚至可能构建失败。

7.1.1. 源目录和构建目录

有时候,开发人员需要知道构建目录与当前源目录对应的位置,比如:在运行时需要复制文件或执行自定义构建任务。使用add_subdirectory(),源和构建目录结构都可以是任意的。甚至可以使用同一个源进行多个构建。因此开发人员需要CMake的帮助来确定感兴趣的目录。为此,CMake提供了许多变量,用于跟踪当前正在处理的CMakeLists.txt文件的源目录和构建目录。在CMake处理每个文件时,将自动更新以下只读变量。它们总是以绝对路径显示:

CMAKE_SOURCE_DIR

源的最顶层目录(即最顶层CMakeLists.txt文件所在的位置)。这个变量永远不会变。

CMAKE_BINARY_DIR

构建的最顶层目录。这个变量永远不会改变。

CMAKE_CURRENT_SOURCE_DIR

当前处理的CMakeLists.txt文件的目录。通过add_subdirectory()处理新文件时更新。

CMAKE_CURRENT_BINARY_DIR

当前处理的CMakeLists.txt文件对应的构建目录。每次调用add_subdirectory()时进行更新。

下面的例子有助于对以上变量的理解:

顶层CMakeLists.txt

cmake_minimum_required(VERSION 3.0)
project(MyApp)

message("top: CMAKE_SOURCE_DIR = ${CMAKE_SOURCE_DIR}")
message("top: CMAKE_BINARY_DIR = ${CMAKE_BINARY_DIR}")
message("top: CMAKE_CURRENT_SOURCE_DIR = ${CMAKE_CURRENT_SOURCE_DIR}")
message("top: CMAKE_CURRENT_BINARY_DIR = ${CMAKE_CURRENT_BINARY_DIR}")

add_subdirectory(mysub)

message("top: CMAKE_CURRENT_SOURCE_DIR = ${CMAKE_CURRENT_SOURCE_DIR}")
message("top: CMAKE_CURRENT_BINARY_DIR = ${CMAKE_CURRENT_BINARY_DIR}")

mysub/CMakeLists.txt

message("mysub: CMAKE_SOURCE_DIR = ${CMAKE_SOURCE_DIR}")
message("mysub: CMAKE_BINARY_DIR = ${CMAKE_BINARY_DIR}")
message("mysub: CMAKE_CURRENT_SOURCE_DIR = ${CMAKE_CURRENT_SOURCE_DIR}")
message("mysub: CMAKE_CURRENT_BINARY_DIR = ${CMAKE_CURRENT_BINARY_DIR}")

上面的例子,如果顶层CMakeLists.txt文件在/somewhere/src目录下,而build目录为/somewhere/build,则会有以下输出:

top: CMAKE_SOURCE_DIR = /somewhere/src
top: CMAKE_BINARY_DIR = /somewhere/build
top: CMAKE_CURRENT_SOURCE_DIR = /somewhere/src
top: CMAKE_CURRENT_BINARY_DIR = /somewhere/build
mysub: CMAKE_SOURCE_DIR = /somewhere/src
mysub: CMAKE_BINARY_DIR = /somewhere/build
mysub: CMAKE_CURRENT_SOURCE_DIR = /somewhere/src/mysub
mysub: CMAKE_CURRENT_BINARY_DIR = /somewhere/build/mysub
top: CMAKE_CURRENT_SOURCE_DIR = /somewhere/src
top: CMAKE_CURRENT_BINARY_DIR = /somewhere/build

7.1.2. 作用域

第5章中,简要提到了作用域的概念。add_subdirectory()的效果是CMake为处理该目录的CMakeLists.txt文件创建一个新的作用域。这个新的作用域相当于一个子作用域:

  • 调用作用域中定义的所有变量对子作用域是可见的,并且子作用域可以像其他变量一样读取它们的值。

  • 子作用域中创建的任何新变量对调用作用域都不可见。

  • 对子作用域内变量的任何更改都是该子作用域的局部更改。即使该变量存在于调用作用域中,调用作用域的变量也不会因此改变。在子作用域中修改的变量类似于在处理离开子作用域时丢弃的新变量。

换句话说,在进入子作用域时,会接收在调用作用域中定义的所有变量的副本。对子节点中变量的任何更改都将在子节点的副本上执行,而调用者的变量将保持不变。下面的例子可以很好地说明这种行为:

CMakeLists.txt

set(myVar foo)
message("Parent (before): myVar = ${myVar}")
message("Parent (before): childVar = ${childVar}")

add_subdirectory(subdir)

message("Parent (after): myVar = ${myVar}")
message("Parent (after): childVar = ${childVar}")

subdir/CMakeLists.txt

message("Child (before): myVar = ${myVar}")
message("Child (before): childVar = ${childVar}")

set(myVar bar)
set(childVar fuzz)

message("Child (after): myVar = ${myVar}")
message("Child (after): childVar = ${childVar}")

输出为:

Parent (before): myVar = foo ①
Parent (before): childVar = ②
Child (before): myVar = foo ③
Child (before): childVar = ④
Child (after): myVar = bar ⑤
Child (after): childVar = fuzz ⑥
Parent (after): myVar = foo ⑦
Parent (after): childVar = ⑧

myVar在父级定义。 ② childVar在父级没有定义,所以是一个空字符串。 ③ myVar在子作用域可见。 ④childVar在进入子作用域之前仍未定义。 ⑤ myVar在子作用域中进行了修改。 ⑥ childVar在子作用域中进行了赋值。 ⑦ 当返回到父作用域时,myVar仍然拥有add_subdirectory()之前的值。子范围内对myVar的修改在父作用域中不可见。 ⑧childVar是在子范围中定义的,所以在父作用域内不可见,结果为空字符串。

上述确定变量作用域的行为展示了add_subdirectory()的重要特征。它允许添加的目录更改它任何变量,而不影响调用范围外的变量。

然而,有时需要在添加的目录中对调用者可见的变量更改。例如,该目录可能负责收集一组源文件名,并将其作为文件列表传递回父文件。这就是set()命令中PARENT_SCOPE关键字的目的。使用PARENT_SCOPE时,变量设置在父作用域,并不是在当前作用域内。重要的是,它并不意味着在父作用域和当前作用域中都设置变量。稍微修改一下前面的例子,就能看到PARENT_SCOPE的效果了:

CMakeLists.txt

set(myVar foo)
message("Parent (before): myVar = ${myVar}")
add_subdirectory(subdir)
message("Parent (after): myVar = ${myVar}")

subdir/CMakeLists.txt

message("Child (before): myVar = ${myVar}")
set(myVar bar PARENT_SCOPE)
message("Child (after): myVar = ${myVar}")

输出为:

Parent (before): myVar = foo
Child (before): myVar = foo
Child (after): myVar = foo ①
Parent (after): myVar = bar ②

① 子作用域中的myVar不受set()的影响,因为PARENT_SCOPE关键字告诉CMake修改父作用域中的myVar,而不是本地的。 ② 父对象的myVar被子作用域中的set()修改。

使用PARENT_SCOPE可以防止命令修改同名的任何局部变量,所以如果局部作用域没有重用与父变量相同的变量名,这样可以减少混淆。

subdir/CMakeLists.txt

set(localVar bar)
set(myVar ${localVar} PARENT_SCOPE)

上面的示例很简单,但对于实际项目来说,可能有许多命令可以在设置父类的myVar变量之前设置localVar的值。

不仅变量受到作用域的影响,策略和某些变量的属性也会受到影响。对于策略,add_subdirectory()会创建一个新的作用域,在这个作用域中,可以在不影响父策略的情况下进行策略更改。类似地,可以在子目录的CMakeLists.txt文件中设置目录属性,这些属性不会影响父目录属性。后续的章节中会更详细地介绍。

7.2. include()

CMake提供的另一种从其他目录加入内容的方法是include()命令,它有以下两种形式:

include(fileName [OPTIONAL] [RESULT_VARIABLE myVar] [NO_POLICY_SCOPE])
include(module [OPTIONAL] [RESULT_VARIABLE myVar] [NO_POLICY_SCOPE])

第一种形式有点类似于add_subdirectory(),但有一些区别:

  • include()读入的是文件名,而add_subdirectory()读入一个目录,并在该目录中查找CMakeLists.txt文件。传递给include()的文件名通常具有扩展名.cmake,当然可以是其他后缀。

  • include()没有引入新的变量范围,而add_subdirectory()引入了新的变量范围。

  • 默认情况下,这两个命令都引入了新的策略范围,可以通过NO_POLICY_SCOPE选项告诉include()命令不要这样做(add_subdirectory()没有这样的选项)。

  • 当处理由include()的文件时,CMAKE_CURRENT_SOURCE_DIR和CMAKE_CURRENT_BINARY_DIR变量的值不会改变,而对于add_subdirectory()变量会有所改变。稍后将对此进行更详细的讨论。

第二种形式的include()命令用于完全不同的目的。它用于加载已命名的模块,这是第11章“模块”中的主题。除第一点外,上述所有要点也适用于第二种形式。

由于CMAKE_CURRENT_SOURCE_DIR的值在调用include()时没有改变,因此包含的文件要计算出它所在的目录似乎很困难。CMAKE_CURRENT_SOURCE_DIR将包含从include()调用的文件的位置,而不是包含所包含文件的目录。而且,不像add_subdirectory(),它的文件名总是CMakeLists.txt,在使用include()时,文件的名称可以随意,因此包含的文件很难确定自己的名称。为了解决这些情况,CMake提供了一组额外的变量:

CMAKE_CURRENT_LIST_DIR

类似于CMAKE_CURRENT_SOURCE_DIR,不同的是会在处理包含的文件时更新。需要处理的当前文件的目录时可以使用该变量,它总是使用绝对路径。

CMAKE_CURRENT_LIST_FILE

提供当前正在处理的文件的名称,终保存文件的绝对路径,而不仅仅是文件名。

CMAKE_CURRENT_LIST_LINE

保存当前正在处理的文件的行号。很少需要此变量,在某些调试场景中可能会用到。

值得注意的是,上述三个变量适用于CMake处理任何文件。即使对于通过add_subdirectory()加入的CMakeLists.txt文件,也具有与上面描述相同的值,CMAKE_CURRENT_LIST_DIR将与CMAKE_CURRENT_SOURCE_DIR具有相同的值。下面的例子演示了这种行为:

CMakeLists.txt

add_subdirectory(subdir)
message("====")
include(subdir/CMakeLists.txt)

subdir/CMakeLists.txt.

message("CMAKE_CURRENT_SOURCE_DIR = ${CMAKE_CURRENT_SOURCE_DIR}")
message("CMAKE_CURRENT_BINARY_DIR = ${CMAKE_CURRENT_BINARY_DIR}")
message("CMAKE_CURRENT_LIST_DIR = ${CMAKE_CURRENT_LIST_DIR}")
message("CMAKE_CURRENT_LIST_FILE = ${CMAKE_CURRENT_LIST_FILE}")
message("CMAKE_CURRENT_LIST_LINE = ${CMAKE_CURRENT_LIST_LINE}")

输出如下:

CMAKE_CURRENT_SOURCE_DIR = /somewhere/src/subdir
CMAKE_CURRENT_BINARY_DIR = /somewhere/build/subdir
CMAKE_CURRENT_LIST_DIR = /somewhere/src/subdir
CMAKE_CURRENT_LIST_FILE = /somewhere/src/subdir/CMakeLists.txt
CMAKE_CURRENT_LIST_LINE = 5
====
CMAKE_CURRENT_SOURCE_DIR = /somewhere/src
CMAKE_CURRENT_BINARY_DIR = /somewhere/build
CMAKE_CURRENT_LIST_DIR = /somewhere/src/subdir
CMAKE_CURRENT_LIST_FILE = /somewhere/src/subdir/CMakeLists.txt
CMAKE_CURRENT_LIST_LINE = 5

上面的示例还展示了include()的另一个有趣的特性。可用于包含已包含在构建中的文件中的内容。如果一个大型复杂项目的不同子目录都希望在项目的公共区域的某个文件中使用CMake代码,那么可以单独include()该文件。

7.3. 提前终止处理

某些情况下,项目可能希望停止处理当前文件的其余部分。return()命令可以用于此目的,但它不能向调用者返回值。它唯一的作用是结束当前范围的处理。如果不是从函数内部调用,那么return()将结束对当前文件的处理,而不管通过include()还是add_subdirectory()引入的。8.4节会讨论在函数内部调用return()的影响,包括可能导致当前文件返回的常见错误。

如前一节所述,项目的不同部分可能包含自多个位置的相同文件。有时可能需要检查这一点,只包含一次文件,提前返回后续包含,以防止多次重新处理文件。这与包含C/C++头文件的情况非常相似:

if(DEFINED cool_stuff_include_guard)
 return()
endif()

set(cool_stuff_include_guard 1)
# ...

CMake 3.10或更高版本中,可以用一个专用的命令来简洁、有力地表达,其行为类似于C/C++的#pragma once

include_guard()

与手动if-endif代码相比,这更加健壮,因为只在内部处理保护变量的名称。该命令还接受一个可选的关键字参数DIRECTORY或GLOBAL,以指定不同的范围,会在范围内检查以前处理过的文件,但在大多数情况下不太可能需要这些关键字。如果没有指定任何参数,则默认为变量作用域,其效果与上面的if-endif完全相同。如果文件在项目的其他地方处理过(即忽略变量作用域),GLOBAL可以让文件的处理结束。DIRECTORY仅在当前目录范围内及其以下进行检查。

7.4. 推荐

使用add_subdirectory()或include()将另一个目录引入构建过程中,哪个是最佳选择并好说。一方面,add_subdirectory()更简单,并且在保持目录自包含方面做得更好,因为它创建了自己的作用域。另一方面,一些CMake命令有一些限制,这些限制只允许它们对定义在当前文件范围内的内容进行操作,因此include()更适合这些情况。第28.5.1节会讨论这方面的内容。

大多数简单项目更倾向于使用add_subdirectory()而不是include()。这样有更清晰的项目定义,并允许给定目录的CMakeLists.txt更专注于该目录需要定义的内容。随着项目的发展,可能会开始在某些目录中使用include()。遵循此策略将促进整个项目中更好的进行信息本地化,并且将倾向于只在需要的地方引入复杂性。并不是说include()本身比add_subdirectory()更复杂,但是使用include()往往会需要更明确地说明文件的路径,因为CMake考虑的是当前源目录,而不是包含的文件的目录。还有一些其他原因,从不同目录调用某些命令会有一些限制,因此add_subdirectory()可能会更加灵活。

不管使用add_subdirectory()、include(),还是两者的组合,推荐使用CMAKE_CURRENT_LIST_DIR变量,而非CMAKE_CURRENT_SOURCE_DIR。养成使用CMAKE_CURRENT_LIST_DIR的习惯,当项目变得越来越复杂时,可以更容易地在add_subdirectory()和include()之间进行切换,也可以更容易地移动整个目录进行重构。

如果项目需要CMake 3.10或更高版本,防止文件的多个包含时,最好使用不带参数的include_guard()命令,而非使用显式的if-endif块。

Last updated