Причины

Далеко не все разработчики имеют радость в настройке проекта посредством системы сборки 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 проектов. Ей же можно и воспользоваться.

Сбор ФЗ под систему сборку для проекта

Предположим, удалось собрать представление того, что должна будет делать наша система сборки, и какие общие черты имеют каждый из компонентов проекта.

Каждый из компонентов проекта может иметь:

  1. Собственные флаги и определения компилятора и линковщика;

  2. Исходные .cpp файлы, не участвующие в процессе сборки;

  3. Собственные определение препроцессора;

  4. Зависимости как от смежных компонентов проекта, так и от внешних библиотек;

  5. Исходные файлы (но не файлы ресурсов) участвующие в процессе сборке. Если таковых файлов не обозначено, то таргет является интерфейсом;

  6. Тесты, которые заведомо основаны на 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()

Нужно отдельно обозначить несколько моментов.

  1. Установка свойства set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS ...) также является обязательной, т.к. изменение любого из "module_config.json" должно вести к реконфигурации проекта;

  2. Во всех случаях работы с путями необходимо использовать кавычки. Например, если при if (NOT EXITS "${Path}") или file (READ "${Path}" Var) не использовать кавычки и в пути содержатся пробелы, то пробелы буду успешно заменены на ';';

  3. Такие преобразования как $<LIST:TRANSFORM,${PublicIncludeDirs},PREPEND,${ModuleDir}/> носят обязательный характер. Хоть и внутри функции считается, что CMAKE_CURRENT_SOURCE_DIR имеет значение директории, которая вызвала функцию, это значение не соответствует корню декларируемого компонента;

  4. Блок 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"
    ]
  }
}

Итог

Что получилось по итогу? Для определения компонента проекта используется следующий пайплан:

  1. Добавить в '<project_root>/libs/module_enum.json' имя директории компонента, относительно '<project_root>/lib';

  2. Создать в корне компонента файл 'module_config.json' для описания нового компонента. Согласно описанию в файле 'build_system.md' заполнить поля конфига;

  3. Запустить CMake конфигурацию.

При этом нужно отдельно отметить важность абстракции декларации единиц сборки:

  • Никто без веской причины не должен вносить изменения в AddFooModule модуль. Как минимум, это оставляет ответственность за баги на авторе. Как максимум, багов не будет ни в настоящем, ни в будущем;

  • Добавление новых полей в API (в приведенном примере - json, в других случаях - CMake функция) - процесс структурированный. Каждому аргументу соответствует свой блок;

  • Единообразие CMake кода. Предполагается, что разработчики не будут в принципе трогать CMake код, а будут работать только посредством API. Это и упрощает жизнь им, и сохраняет единоавторство существующего кода.

Для более удобного просмотра кодовой составляющей приведенного материала можете смотреть в репозиторий WorHyako/cmake-concealer.

Послесловие

Мой любимый CMake - это действительно не панацея в мире C++/C, хоть и очень на неё похож. В разобранном примере отражается, что можно полностью задекорировать CMake код от членов команды, не отходя от принятых в комьюнити стандартов, и выстроить железобетонную стену вокруг процесса сборки - разработчики, внося любые сумасбродные изменения в компоненты, не добавят баг в ядро.

Не на поверхности находится и мысль о том, чтобы в больших проектах для декларации компонентов необходимо и "жизненно" важно использовать функции, а не полные реализации. Вы можете подумать "Ну это же логично. Ты за кого меня держишь, WorHyako?". Однако, погуляв по GitHub в том числе и по известным репозиториям можно встретить или нечитабельный хаос, или просто отсутствие организованности сборки.

Абстрагируйте декларацию компонентов сборки, пишите аккуратно и заботьтесь о гигиене проекта.

Комментарии (1)


  1. NightShad0w
    13.12.2025 17:57

    А в чем добавленная ценность Json-представления тех же свойств, что CMake штатно предоставляет? На мой взгляд, предложенный подход крайне избыточно решает проблему боязни копипасты, но никак не улучшает поддерживаемость или модульность проекта.

    CMake достаточно многословный, и от проекта к проекту, от компонента к компоненты одни и те же функции будут использоваться постоянно. Я не вижу смысла в добавлении анемичной абстракции, и сопутствующего инструментария, если можно просто 100 раз использовать add_target, target_include_directories, etc... Боязнь дублирующихся параграфов - это не DRY. А в больших проектах, рано или поздно появится тот черный лебедь, на который эта абстракция не налезет из-за одного специального флага под Макось. И с этого момента начнется разложение и вонь системы сборки через добавление флагов, опций, аргументов и прочего тюнинга.

    Помимо этого использовать CMake как скриптовый язык общего назначения хоть и возможно, но крайне неудобно из-за той же многословности.

    Если хочется все-таки загнать себя в рамки предопределенной сборки компонентов, я бы использовал внешний лаунчер как точку входа для системы сборки, например шелл, или даже минимальный мейкфайл, который используя предложенные json описания сгенерирует нормальные CMakeLists.txt для каждого компонента используя более удобный инструмент - Jinja, Python, выберите любой приятный генератор по шаблонам. CMakeLists.txt верхнего уровня можно вручную написать с нужными опциями, или тоже генерировать. И только потом передавать сборку в руки собственно CMake, например даже с СMakePresets.txt. Использовать CMake для развертывания самого себя изнутри собственно проекта - это будет очень больно при поддержке кроссплатформенной сборки, создаст трудности по декомпозиции компонентов проекта от компонентов системы сборки, и увеличивает сложность в поддержке.