Причины
Далеко не все разработчики имеют радость в настройке проекта посредством системы сборки CMake. У кого-то это вызывает банальную неприязнь, а кого-то банально проблемы с тем, чтобы корректно написать CMake конфигурацию. Как минимум несколько разработчиков в разных частях проекта могут писать CMake код по разному стилю или по разному количеству ошибок, добавляю хаос и неопределенность в процесс сборки. Не поймите меня неправильно. Я не осуждаю, а лишь хочу показать распространенный способ, при котором можно организовать этот хаос и хотя бы попытаться уменьшить неопределенность.
Допустим, вы участвуете в проекте, которые разбит на несколько десятков компонентов (здесь и далее компонентом я называю внутреннюю библиотеку проекта, участвующую в сборке проекте как не сторонний ресурс, т.е. компилируемую или интерфейсную единицу сборки). Можно рассмотреть примеры проектов: storm-engine , OpenGothic, Hazel и так далее. Игровые движки - это особенно показательный пример, т.к. в них особенно трепетно нарезают исходный код на компоненты.
Каждый из приведенных примеров показывает свой подход.
У Hazel сборка идёт через premake. Выбор скорее творческий и эффектный, нежели эффективный;
-
У storm-engine в файле 'cmake/StormSetup.cmake' реализован макрос
STORM_SETUP(...). Разработчики явно знали своё дело, но кажется, что в нём большое количество хаоса и усложнения, да и время не пощадило их проект. Каждый компонент декларируется следующим макросом (пример из 'animals/CMakeLists.txt'):STORM_SETUP( TARGET_NAME animals TYPE storm_module DEPENDENCIES animation collide core geometry model renderer sea ship sound_service)В отличии от Hazel подход более эффективный;
А разработчики OpenGothic просто забили на систему сборки. Имеют право, собственно...
Кстати, для справедливости можно привести пример не из геймдева. Посмотрите реализацию и применение функции userver_module(...) из файла UserverModule.cmake. Синонимично с storm-engine, просто на порядки сложнее.
Подход, о котором мы сегодня поговорим, как раз и реализовали разработчики storm-engine и userver-framework. Абстракция при декларации компонентов сборки стала уже стандартом для больших CMake проектов. Ей же можно и воспользоваться.
Сбор ФЗ под систему сборку для проекта
Предположим, удалось собрать представление того, что должна будет делать наша система сборки, и какие общие черты имеют каждый из компонентов проекта.
Каждый из компонентов проекта может иметь:
Собственные флаги и определения компилятора и линковщика;
Исходные .cpp файлы, не участвующие в процессе сборки;
Собственные определение препроцессора;
Зависимости как от смежных компонентов проекта, так и от внешних библиотек;
Исходные файлы (но не файлы ресурсов) участвующие в процессе сборке. Если таковых файлов не обозначено, то таргет является интерфейсом;
Тесты, которые заведомо основаны на Catch2.
Представление проекта
В своём примере я намеренно внесу излишнее усложнение (прости меня, скорость сборки :D) как в целях творчества, так и для наиболее наглядного примера адаптивности самого инструмента CMake. Усложнение заключается в том, что вместо вызова CMake функции декларации компонента я приведу пример объявления модуля через json файл. При желании даже можно в проекте держать несколько инструментов сборки: CMake, Meson, Bazel. Каждый из них будет иметь абсолютно одинаковый API для клиента, т.к. клиент работает только с json.
Целевой вид json-а может быть представлен следующим образом:
{
"name": "example",
"source files": [],
"flags": {
"compiler": {
"public": [],
"private": []
},
"linker": {
"public": [],
"private": []
}
},
"definitions": {
"public": [],
"private": []
},
"include dirs": {
"private": [],
"public": []
},
"dependencies": {
"public": [],
"private": []
},
"testsuites": {
"sources": []
}
}
Конечно же, помимо полной реализации конфиг должен поддерживать и укороченный вид, т.к. не нужно держать поля, которые не используются.
Файловая организация компонентов проекта может быть представлена как:
<project_root>
|-- CMakeLists.txt
|-- cmake/
|-- libs/
| |-- CMakeLists.txt
| |-- module_enum.json
| |-- foo/
| | |-- CMakeLists.txt
| | |-- module_config.json
| | |-- src/
| | | `-- foo_src.cpp
| | |-- include/
| | | `-- foo_header.hpp
| | `-- testsuites/
| `-- bar/
| |-- CMakeLists.txt
| |-- module_config.json
| |-- src/
| | `-- bar_src.cpp
| |-- include/
| | `-- bar_header.hpp
| `-- testsuites/
Реализация системы сборки
Можно было бы подумать, что итерации по всем директориям в "<project_root>/libs" будет достаточно, но хотелось бы иметь возможно выборочно отключать компоненты из системы сборки, так что добавляем "module_enum.json" файл для перечисления подключаемых компонентов:
# <project_root>/libs/CMakeLists.txt
# Модуль `AddFooModule` содержит весь код по формированию таргета для
# нового компонента
include(AddFooModule)
if (NOT EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/module_enum.json")
message(WARNING "Wor:\n\tCan't find module enumeration file 'module_enum.json'.")
return()
endif ()
file(READ "${CMAKE_CURRENT_SOURCE_DIR}/module_enum.json" ModuleEnum)
string(JSON Modules ERROR_VARIABLE Error GET ${ModuleEnum} "modules")
if (Error)
message(WARNING "Wor:\n\tCan't find key 'modules' in module enumeration file 'module_enum.json'.")
return()
endif ()
# Внести 'module_enum.json' в индексацию CMake, чтобы его изменения
# требовали переконфигурации
set_property(DIRECTORY
APPEND
PROPERTY CMAKE_CONFIGURE_DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/module_enum.json")
string(JSON ModulesCount LENGTH ${Modules})
math(EXPR ModulesCount "${ModulesCount} - 1")
foreach (ModuleIdx RANGE ${ModulesCount})
string(JSON ModuleFolder GET ${Modules} ${ModuleIdx})
# Функция `add_foo_module()` взята из модуля `AddFooModule`
add_foo_module("${CMAKE_CURRENT_SOURCE_DIR}/${ModuleFolder}")
endforeach ()
"module_enum.json" может иметь самый тривиальный вид. Он нужен лишь для того, чтобы перечислить директории для парсинга.
{
"modules": [
"foo",
"bar"
]
}
Имея представление о файлах конфигурации можно реализовать модуль AddFooModule:
# <project_root>/cmake/AddFooModule.cmake
#[[`collect_list(OutputList Path...)`
Извлекает массив из JSON объекта по указанному пути и добавляет
все значения в выходной список.
Ожидает внешней (родительской) переменной `ModuleConfig`, содержащей JSON конфиг.
Аргументы:
OutputList - Имя переменной-списка для хранения результатов
Path... - Один или несколько сегментов пути (ключей) для навигации по
JSON объекту
Пример:
collect_list(Sources "source files" "public")
]]#
function(collect_list OutputList)
math(EXPR ArgLastIdx "${ARGC} - 1")
foreach (ArgIdx RANGE 1 ${ArgLastIdx})
list(APPEND JsonPath ${ARGV${ArgIdx}})
endforeach ()
string(JSON JsonObject ERROR_VARIABLE Error GET ${ModuleConfig} ${JsonPath})
if (Error)
return()
endif ()
string(JSON JsonLength LENGTH ${JsonObject})
if (JsonLength LESS 1)
return()
endif ()
math(EXPR JsonLength "${JsonLength} - 1")
foreach (ListIdx RANGE ${JsonLength})
string(JSON Value GET ${JsonObject} ${ListIdx})
list(APPEND ${OutputList} ${Value})
endforeach ()
return(PROPAGATE ${OutputList})
endfunction()
#[[`operator_and(A B Out)`
Выполняет логическую операцию AND.
Аргументы:
A - Участник операции
B - Участник операции
Out - Результат операции
Все аргументы должны быть переданы без развёртки.
]]#
function(operator_and A B Out)
if (${A} AND ${B})
set(${Out} ON)
else ()
set(${Out} OFF)
endif ()
return(PROPAGATE ${Out})
endfunction()
#[[`add_foo_module(ModuleDir)`
Добавляет Foo модуль из указанной директории, настраивая цель
на основе конфигурационного файла module_config.json.
Аргументы:
ModuleDir - Путь к директории модуля относительно CMAKE_CURRENT_SOURCE_DIR
Директория должна содержать файл module_config.json
Требования к module_config.json:
- Должен содержать поле "name" с именем целевой библиотеки
- Должен содержать поле "include dirs" со списком заголовочных директорий
- Может содержать другие поля (см. образец в build_system.md)
Поведение:
- Помечает module_config.json файл так, что при его изменении
вызовется тригер реконфига CMake
- Создает библиотеку
- Настраивает include директории, зависимости, флаги и определения
- Создает псевдоним библиотеки в формате Foo::{TargetName}
Пример:
add_foo_module(foo)
]]#
function(add_foo_module ModuleDir)
if (NOT EXISTS "${ModuleDir}/module_config.json")
message(WARNING "Wor:\n\tCan't find module config '${ModuleDir}/module_config.json'.")
return()
endif ()
set_property(DIRECTORY
APPEND
PROPERTY CMAKE_CONFIGURE_DEPENDS "${ModuleDir}/module_config.json")
file(READ "${ModuleDir}/module_config.json" ModuleConfig)
# --------------- #
# Target name #
# --------------- #
block(SCOPE_FOR VARIABLES PROPAGATE TargetName)
string(JSON JsonTargetName ERROR_VARIABLE Error GET ${ModuleConfig} "name")
if (Error)
message(FATAL_ERROR "Wor:\n\tCan't find module name in '${ModuleDir}/module_config.json'.")
return()
endif ()
set(TargetName ${JsonTargetName})
endblock()
# ---------------- #
# Source files #
# ---------------- #
collect_list(Sources "source files")
if (NOT Sources)
set(InterfaceTarget ON)
endif ()
# ----------------------- #
# Include directories #
# ----------------------- #
collect_list(PublicIncludeDirs "include dirs" "public")
collect_list(PrivateIncludeDirs "include dirs" "private")
# ---------------- #
# Dependencies #
# ---------------- #
collect_list(PublicDeps "dependencies" "public")
collect_list(PrivateDeps "dependencies" "private")
# --------- #
# Flags #
# --------- #
collect_list(PublicCompilerFlags "flags" "compiler" "public")
collect_list(PrivateCompilerFlags "flags" "compiler" "private")
collect_list(PublicLinkerFlags "flags" "linker" "public")
collect_list(PrivateLinkerFlags "flags" "linker" "private")
# --------------- #
# Definitions #
# --------------- #
collect_list(PublicDefs "definitions" "public")
collect_list(PrivateDefs "definitions" "private")
# -------------- #
# Testsuites #
# -------------- #
collect_list(TestsuiteSources "testsuites" "sources")
collect_list(TestsuiteDeps "testsuites" "dependencies")
# ------------------ #
# Target forming #
# ------------------ #
if (InterfaceTarget)
set(HasPrivateField OFF)
operator_and(InterfaceTarget PrivateDeps HasPrivateField)
operator_and(InterfaceTarget PrivateDefs HasPrivateField)
operator_and(InterfaceTarget PrivateLinkerFlags HasPrivateField)
operator_and(InterfaceTarget PrivateCompilerFlags HasPrivateField)
operator_and(InterfaceTarget PrivateIncludeDirs HasPrivateField)
if (HasPrivateField)
message(WARNING "Wor:\t\nInterface module can't contains private fields")
endif ()
endif ()
if (InterfaceTarget)
add_library(${TargetName} INTERFACE)
target_include_directories(${TargetName}
INTERFACE
$<LIST:TRANSFORM,${PublicIncludeDirs},PREPEND,${ModuleDir}/>
$<LIST:TRANSFORM,${PrivateIncludeDirs},PREPEND,${ModuleDir}/>)
target_link_libraries(${TargetName}
INTERFACE
${PublicDeps}
target_link_options(${TargetName}
INTERFACE
${PublicLinkerFlags})
target_compile_options(${TargetName}
INTERFACE
${PublicCompilerFlags})
target_compile_definitions(${TargetName}
INTERFACE
${PublicDefs})
else ()
add_library(${TargetName})
target_include_directories(${TargetName}
PUBLIC
$<LIST:TRANSFORM,${PublicIncludeDirs},PREPEND,${ModuleDir}/>
PRIVATE
$<LIST:TRANSFORM,${PrivateIncludeDirs},PREPEND,${ModuleDir}/>)
target_link_libraries(${TargetName}
PUBLIC
${PublicDeps}
PRIVATE
${PrivateDeps})
target_link_options(${TargetName}
PUBLIC
${PublicLinkerFlags}
PRIVATE
${PrivateLinkerFlags})
target_compile_options(${TargetName}
PUBLIC
${PublicCompilerFlags}
PRIVATE
${PrivateCompilerFlags})
target_compile_definitions(${TargetName}
PUBLIC
${PublicDefs}
PRIVATE
${PrivateDefs})
target_sources(${TargetName}
PRIVATE
$<LIST:TRANSFORM,${Sources},PREPEND,${ModuleDir}/>)
endif ()
add_library(Foo::${TargetName} ALIAS ${TargetName})
# ----------------------- #
# Test target forming #
# ----------------------- #
if (TestsuiteSources)
set(TestTargetName "${TargetName}_test")
add_executable(${TestTargetName})
target_link_libraries(${TestTargetName}
PRIVATE
Catch2::Catch2
Catch2::Catch2WithMain
${TestsuiteDeps}
${TargetName})
target_sources(${TestTargetName}
PRIVATE
$<LIST:TRANSFORM,${TestsuiteSources},PREPEND,${ModuleDir}/testsuites/>)
set_target_properties(${TestTargetName}
PROPERTIES
FOLDER "Tests")
add_custom_command(TARGET ${TestTargetName}
COMMENT "Run ${TestTargetName}"
POST_BUILD
COMMAND ${TestTargetName})
endif ()
endfunction()
Нужно отдельно обозначить несколько моментов.
Установка свойства
set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS ...)также является обязательной, т.к. изменение любого из "module_config.json" должно вести к реконфигурации проекта;Во всех случаях работы с путями необходимо использовать кавычки. Например, если при
if (NOT EXITS "${Path}")илиfile (READ "${Path}" Var)не использовать кавычки и в пути содержатся пробелы, то пробелы буду успешно заменены на ';';Такие преобразования как
$<LIST:TRANSFORM,${PublicIncludeDirs},PREPEND,${ModuleDir}/>носят обязательный характер. Хоть и внутри функции считается, чтоCMAKE_CURRENT_SOURCE_DIRимеет значение директории, которая вызвала функцию, это значение не соответствует корню декларируемого компонента;-
Блок
if (InterfaceTarget)под комментарием# Target forming #на первый взгляд дублируется, но это обосновано ключевыми словами INTERFACE / PUBLIC / PRIVATE.
Можно, например, сделать более "компактную" версию:# <project_root>/cmake/AddFooModule.cmake # ------------------ # # Target forming # # ------------------ # if (InterfaceTarget) set(HasPrivateField OFF) operator_and(InterfaceTarget PrivateDeps HasPrivateField) operator_and(InterfaceTarget PrivateLinkerFlags HasPrivateField) operator_and(InterfaceTarget PrivateDefs HasPrivateField) operator_and(InterfaceTarget PrivateCompilerFlags HasPrivateField) operator_and(InterfaceTarget PrivateIncludeDirs HasPrivateField) if (HasPrivateField) message(WARNING "Wor:\t\nInterface module can't contain private fields") endif () set(PublicVisibility INTERFACE) unset(PrivateVisibility) add_library(${TargetName} INTERFACE) else () add_library(${TargetName}) target_sources(${TargetName} PRIVATE $<LIST:TRANSFORM,${Sources},PREPEND,${ModuleDir}/>) set(PublicVisibility PUBLIC) set(PrivateVisibility PRIVATE) endif () target_include_directories(${TargetName} ${PublicVisibility} $<LIST:TRANSFORM,${PublicIncludeDirs},PREPEND,${ModuleDir}/> ${PrivateVisibility} $<LIST:TRANSFORM,${PrivateIncludeDirs},PREPEND,${ModuleDir}/>) target_link_libraries(${TargetName} ${PublicVisibility} ${PublicDeps} ${PrivateVisibility} ${PrivateDeps}) target_link_options(${TargetName} ${PublicVisibility} ${PublicLinkerFlags} ${PrivateVisibility} ${PrivateLinkerFlags}) target_compile_options(${TargetName} ${PublicVisibility} ${PublicCompilerFlags} ${PrivateVisibility} ${PrivateCompilerFlags}) target_compile_definitions(${TargetName} ${PublicVisibility} ${PublicDefs} ${PrivateVisibility} ${PrivateDefs}) add_library(Foo::${TargetName} ALIAS ${TargetName})Можно даже сделать обёртку через основной obj таргет
${TargetName}_objи последующим алиасом${TargetName}, который либо подключает курсы, либо нет. Но едва ли это можно быстро читать и является оправданным трудом.
В итоге, единственное, на что я был бы готов для унификации - это применение генераторов выражений при выборе ключевого слова доступа, но, увы, такое не поддерживается.
При таком парсинге получилось так, что минимальный вид выглядит следующим образом:
{
"name": "foo"
}
И действительно, в теории не должно быть запретов на создание пустого таргета, а имя оного - это и есть минимальная единица информации.
Пример не интерфейсной единицы сборки может иметь следующий вид:
{
"name": "bar",
"type": "module",
"source files": [
"src/bar_src.cpp"
],
"include dirs": {
"public": [
"include"
],
"private": [
"src"
]
},
"dependencies": {
"public": [
"Foo::dom"
]
}
}
Итог
Что получилось по итогу? Для определения компонента проекта используется следующий пайплан:
Добавить в '<project_root>/libs/module_enum.json' имя директории компонента, относительно '<project_root>/lib';
Создать в корне компонента файл 'module_config.json' для описания нового компонента. Согласно описанию в файле 'build_system.md' заполнить поля конфига;
Запустить CMake конфигурацию.
При этом нужно отдельно отметить важность абстракции декларации единиц сборки:
Никто без веской причины не должен вносить изменения в
AddFooModuleмодуль. Как минимум, это оставляет ответственность за баги на авторе. Как максимум, багов не будет ни в настоящем, ни в будущем;Добавление новых полей в API (в приведенном примере - json, в других случаях - CMake функция) - процесс структурированный. Каждому аргументу соответствует свой блок;
Единообразие CMake кода. Предполагается, что разработчики не будут в принципе трогать CMake код, а будут работать только посредством API. Это и упрощает жизнь им, и сохраняет единоавторство существующего кода.
Для более удобного просмотра кодовой составляющей приведенного материала можете смотреть в репозиторий WorHyako/cmake-concealer.
Послесловие
Мой любимый CMake - это действительно не панацея в мире C++/C, хоть и очень на неё похож. В разобранном примере отражается, что можно полностью задекорировать CMake код от членов команды, не отходя от принятых в комьюнити стандартов, и выстроить железобетонную стену вокруг процесса сборки - разработчики, внося любые сумасбродные изменения в компоненты, не добавят баг в ядро.
Не на поверхности находится и мысль о том, чтобы в больших проектах для декларации компонентов необходимо и "жизненно" важно использовать функции, а не полные реализации. Вы можете подумать "Ну это же логично. Ты за кого меня держишь, WorHyako?". Однако, погуляв по GitHub в том числе и по известным репозиториям можно встретить или нечитабельный хаос, или просто отсутствие организованности сборки.
Абстрагируйте декларацию компонентов сборки, пишите аккуратно и заботьтесь о гигиене проекта.
NightShad0w
А в чем добавленная ценность Json-представления тех же свойств, что CMake штатно предоставляет? На мой взгляд, предложенный подход крайне избыточно решает проблему боязни копипасты, но никак не улучшает поддерживаемость или модульность проекта.
CMake достаточно многословный, и от проекта к проекту, от компонента к компоненты одни и те же функции будут использоваться постоянно. Я не вижу смысла в добавлении анемичной абстракции, и сопутствующего инструментария, если можно просто 100 раз использовать add_target, target_include_directories, etc... Боязнь дублирующихся параграфов - это не DRY. А в больших проектах, рано или поздно появится тот черный лебедь, на который эта абстракция не налезет из-за одного специального флага под Макось. И с этого момента начнется разложение и вонь системы сборки через добавление флагов, опций, аргументов и прочего тюнинга.
Помимо этого использовать CMake как скриптовый язык общего назначения хоть и возможно, но крайне неудобно из-за той же многословности.
Если хочется все-таки загнать себя в рамки предопределенной сборки компонентов, я бы использовал внешний лаунчер как точку входа для системы сборки, например шелл, или даже минимальный мейкфайл, который используя предложенные json описания сгенерирует нормальные CMakeLists.txt для каждого компонента используя более удобный инструмент - Jinja, Python, выберите любой приятный генератор по шаблонам. CMakeLists.txt верхнего уровня можно вручную написать с нужными опциями, или тоже генерировать. И только потом передавать сборку в руки собственно CMake, например даже с СMakePresets.txt. Использовать CMake для развертывания самого себя изнутри собственно проекта - это будет очень больно при поддержке кроссплатформенной сборки, создаст трудности по декомпозиции компонентов проекта от компонентов системы сборки, и увеличивает сложность в поддержке.