Причины
Далеко не все разработчики имеют радость в настройке проекта посредством системы сборки 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 в том числе и по известным репозиториям можно встретить или нечитабельный хаос, или просто отсутствие организованности сборки.
Абстрагируйте декларацию компонентов сборки, пишите аккуратно и заботьтесь о гигиене проекта.
