Современный CMake: 10 советов по улучшению скриптов сборки

в 13:57, , рубрики: c++, c++17, cmake

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

Если вы хотите опробовать советы в деле, возьмите пример на github и исследуйте его по мере чтения статьи: https://github.com/sergey-shambir/modern-cmake-sample

Совет №1: указывайте высокую минимальную версию CMake

Совет не относится к тем, кто пишет публичные библиотеки, поскольку для них важна совместимость со старым окружением разработки. А если вы пишете проект с закрытым кодом либо узкоспециальное опенсорсное ПО, то можно потребовать от всех разработчиков поставить последнюю версию CMake. Без этого многие советы статьи работать не будут! На момент написания статьи мы имеем CMake 3.8.

cmake_minimum_required(VERSION 3.8 FATAL_ERROR)

Совет №2: не вызывайте ни make, ни make install

Современный CMake умеет сам вызывать систему сборки. В документации CMake такой режим называется Build Tool Mode.

# Переходим из каталога myproj в myproj-build
mkdir ../myproj-build && cd ../myproj-build

# Конфигурируем для сборки из исходников в ../myproj
cmake -DCMAKE_BUILD_TYPE=Release ../myproj

# Запускаем сборку в текущем каталоге
cmake --build .

# Запускаем сборку, передаём ключ '-j4' низлежащей системе сборки.
cmake --build . -- -j4

Если вы генерируете проект Visual Studio, вы также можете собрать его из командной строки, в том числе можно собрать конкретный проект в конкретной конфигурации:

cmake --build . 
    --target myapp 
    --config Release 
    --clean-first

На Linux не используйте make install, иначе вы засорите свою систему. Об этом есть отдельная статья Хочется взять и расстрелять, или ликбез о том, почему не стоит использовать make install

Совет №3: используйте несколько CMakeLists.txt

Вложенность CMakeLists.txt — это нормально. Если ваш проект разделён на 3 библиотеки, 3 набора тестов и 2 приложения, то почему бы не добавить CMakeLists.txt для каждого из них? Тогда вам потребуется создать ещё один центральный CMakeLists.txt, и в нём выполнить add_subdirectory. Так может выглядеть центральный CMakeLists:

cmake_minimum_required(VERSION 3.8 FATAL_ERROR)

project(opengl-samples)

# Лайфхак: объявленные в старшем CMakeLists функции
#  будут видны в подпроектах, включённых через add_subdirectory
include(scripts/functions.cmake)

add_subdirectory(libs/libmath)
add_subdirectory(libs/libplatform)
add_subdirectory(libs/libshade)

# Инструкция enable_testing неявно объявляет опцию BUILD_TESTING,
#  по умолчанию BUILD_TESTING=ON.
# Вызывайте `cmake -DBUILD_TESTING=OFF projectdir` из командной строки,
#  если не хотите собирать тесты.
enable_testing()

if(BUILD_TESTING)
    add_subdirectory(tests)
endif()

# ..остальные цели..

Совет №4: не засоряйте глобальную область видимости

Не заводите глобальных переменных без крайней необходимости. Не используйте link_directories(), include_directories(), add_definitions(), add_compile_options() и другие подобные инструкции.

  • Используйте target_link_libraries для добавления статических и динамических, внутренних и внешних библиотек, от которых зависит цель
  • Используйте target_include_directories вместо include_directories для добавления путей поиска заголовков, от которых зависит цель
  • Используйте target_compile_definitions вместо add_definitions для добавления макросов, с которыми собирается цель
  • Используйте target_compile_options для добавления специфичных флагов компилятора, с которыми собирается цель

# Добавляем цель-библиотеку
add_library(mylibrary 
    ColorDialog.h ColorDialog.cpp 
    ColorPanel.h ColorPanel.cpp)

# ! Осторожно - непереносимый код !
# Добавляем к цели путь поиска заголовков /usr/include/wx-3.0
# Лучше использовать find_package для получения пути к заголовкам.
target_include_directories(mylibrary /usr/include/wx-3.0)

Стоит заметить, что target_link_libraries может добавить пути поиска заголовков библиотеки, если библиотека находится в вашем проекте и к ней были прикреплены пути поиска заголовков через конструкцию target_include_directories(libfoo PUBLIC ...).

Есть пример схемы зависимостей, взятый из презентации Modern CMake / an Introduction за авторством Tobias Becker:

Схема

Совет №5: включите, наконец, C++17 или C++14!

В последние годы стандарт C++ обновляется часто: мы получили потрясающие изменения в C++11, C++14, C++17. Старайтесь по возможности отказаться от старых компиляторов. Например, для Linux ничто не мешает установить последнюю версию Clang и libc++ и начать собирать все проекты со статической компоновкой C++ runtime.

Лучший способ включить C++17 без игры с флагами компиляции — явно сказать CMake, что он вам нужен.

# Способ первый: затребовать от компилятора фичу cxx_std_17
target_compile_features(${TARGET} PUBLIC cxx_std_17)

# Способ второй: указать компилятору на стандарт
set_target_properties(${TARGET} PROPERTIES
    CXX_STANDARD 17
    CXX_STANDARD_REQUIRED YES
    CXX_EXTENSIONS NO
)

С помощью target_compile_features вы можете требовать не C++17 или C++14, а определённых фич со стороны компилятора. Полный список известных CMake фич компиляторов можно посмотреть в документации.

Совет №6: используйте функции

В CMake можно объявлять свои функциональные макросы и свои функции. Есть лишь одно различие между ними: переменные, установленные внутри функции, являются локальными.

Удобно писать функции, чтобы решать текущие проблемы кастомизации сборки либо упрощать добавление множества целей сборки. Пример ниже был написан для более корректного включения C++17 из-за того, что

  • CMake 3.8 ещё не умеет передавать Visual Studio флаг /std:c++latest для включения C++17
  • при использовании std::experimental::filesystem в Clang/libc++ нужно указать компоновщику, что проект надо линковать с libc++experimental.a, поскольку в libc++.a модуля filesystem пока ещё нет; также нужно линковать с pthread, поскольку реализация thread/mutex и т.п. опирается на pthread

# В текущей версии CMake не может включить режим C++17 в некоторых компиляторах.
# Функция использует обходной манёвр.
function(custom_enable_cxx17 TARGET)
    # Включаем C++17 везде, где CMake может.
    target_compile_features(${TARGET} PUBLIC cxx_std_17)
    # Включаем режим C++latest в Visual Studio
    if (CMAKE_CXX_COMPILER_ID STREQUAL "MSVC")
        set_target_properties(${TARGET} PROPERTIES COMPILE_FLAGS "/std:c++latest")
    # Включаем компоновку с libc++, libc++experimental и pthread для Clang
    elseif (CMAKE_CXX_COMPILER_ID MATCHES "Clang")
        set_target_properties(${TARGET} PROPERTIES COMPILE_FLAGS "-stdlib=libc++ -pthread")
        target_link_libraries(${TARGET} c++experimental pthread)
    endif()
endfunction(custom_enable_cxx17)

Каждая функция — это по сути хак, созданный для переопределения языка CMake или его поведения. Для других разработчиков смысл этого хака неясен. Поэтому старайтесь к каждой инструкции в функции добавлять комментарий, объясняющий её цель и смысл.

В крупных открытых проектах, например в KDE, применение своих функций может быть дурным тоном. Вы можете рассмотреть иные варианты: писать скрипт сборки явно по принципу "Explicit is better then implicit", либо даже предложить добавить свою функцию в upstream проекта CMake.

Совет №7 (спорный): не перечисляйте исходные файлы по одному

Мой коллега разрабатывает вне работы маленький 3D движок для рендеринга сцены с моделями и анимациями через OpenGL, GLES, DirectX и Vulkan. Однажды мы с ним обсуждали этот проект, и оказалось, что для сборки под все платформы (Windows, Linux, Android) он использует Visual Studio! Он недоволен тем, что Microsoft редко обновляет Android NDK, но не хочет отказываться от сборки через MSBuild по одной простой причине.

Ему не хочется сопровождать список файлов для сборки в двух системах сборки.

Когда-то я вёл портирование игры с iOS на Android, и мы поддерживали две системы сборки с помощью скрипта, который читал проект XCode и автоматически дополнял список файлов в Android.mk. Если вы используете CMake, то вам даже скрипт не нужно писать.

В CMake есть функция aux_source_directory, но она имеет недостаток: заголовки не добавляются в список и не появляются в любом сгенерированном проекте для IDE.

  • на выручку приходит file(GLOB ...), сканирующий файлы по маске
  • чтобы не замусорить новыми переменными глобальную область видимости, создадим функцию custom_add_executable_from_dir(name)
  • чтобы функция, размещённая в отдельном файле, использовала как точку отсчёта путь к текущему CMakeLists.txt, мы применим переменнуюCMAKE_CURRENT_SOURCE_DIR

function(custom_add_executable_from_dir TARGET)
    # Собираем файлы с текущего каталога
    file(GLOB TARGET_SRC "CMAKE_CURRENT_SOURCE_DIR/*.cpp" 
    # Добавляем исполняемый файл
    add_executable(${TARGET} ${TARGET_SRC})
endfunction()

Вы можете добавить функцию custom_add_library_from_dir для целей-библиотек аналогичным путём.

Если же вы — фанат ручной работы или создаёте публичную библиотеку, тогда, возможно, вам лучше добавлять файлы по одному. В этом случае используйте target_sources для добавления платформо-специфичных файлов:

add_library(libfoo Foo.h Foo_common.cpp)
if(WIN32)
    target_sources(libfoo Foo_win32.cpp)
endif(WIN32)

Совет №8: не запускайте утилиты bash, запускайте cmake -E

Наверняка вам хотелось ради автоматизации вызвать из cmake команду Bash, чтобы создать каталог, распаковать архив или подсчитать md5 сумму. Но вызов утилит командной строки может лишить проект кроссплатформенности. Более переносимый метод — вызывать cmake -E команда, пользуясь Command-Line Tool Mode.

Совет №9: оставляйте больше гибкости пользователям своих библиотек

Совет относится к вам, если вы пишете публично доступные библиотеки. В этом случае вам стоит упрощать следующие сценарии:

  • программист хочет добавить вашу библиотеку как подмодуль и включить ваш CMakeLists.txt через add_subdirectory
  • программист хочет сделать кастомизации в сборке вашей библиотеки; наиболее популярные — полностью статическая или динамическая компоновка, сборка с тестами и примерами либо только библиотеки

Добавляя библиотеку, создавайте ещё и уникальный синоним:

# Добавляем цель-библиотеку
add_library(foo ${FOO_SRC})

# Добавляем синоним, содержащий имя выпускающей библиотеку организации
add_library(MyOrg::foo ALIAS foo)

Оставляйте пользователям библиотеки право использования опции BUILD_SHARED_LIBS для выбора между сборкой статической и динамической версий библиотеки.

При установке настроек компоновки, поиска заголовков и флагов компиляции для библиотек используйте ключевые слова PUBLIC, PRIVATE, INTERFACE, чтобы позволить целям, зависящим от вашей библиотеки, наследовать необходимые настройки:

  • PUBLIC означает, что зависящему от библиотеки проекту тоже нужны эти опции
  • PRIVATE означает, что опции нужны лишь для сборки библиотеки
  • INTERFACE означает, что опции не нужны для сборки, но нужны для использования библиотеки

target_link_libraries(foobarapp
    PUBLIC MyOrg::libfoo
    PRIVATE MyOrg::libbar
)

Совет №10: регистрируйте автотесты в CTest

Подсистема CTest не заставляет вас использовать какие-то особые библиотеки для тестирования вместо привычных Boost.Test, Catch или Google Tests. Она всего лишь регистрирует автотесты так, чтобы CMake мог запустить все тесты или выбранные тесты одной командой ctest.

Чтобы включить поддержку CTest по всему проекту, есть инструкция enable_testing

# Инструкция enable_testing неявно объявляет опцию BUILD_TESTING,
#  по умолчанию BUILD_TESTING=ON.
# Вызывайте `cmake -DBUILD_TESTING=OFF projectdir` из командной строки,
#  если не хотите собирать тесты.
enable_testing()

if(BUILD_TESTING)
    add_subdirectory(tests/libhellotest)
    add_subdirectory(tests/libgoodbyetest)
endif()

Чтобы исполняемый файл с тестом был зарегистирован в CTest, нужно вызвать инструкцию add_test.

# Исполняемый файл теста - это обычная исполняемая цель сборки
add_executable(${TARGET} ${TARGET_SRC})

# Регистрируем исполняемый файл в CMake как набор тестов.
#  можно назначить тесту особое имя, но проще использовать имя исполняемого файла теста.
add_test(${TARGET} ${TARGET})

Что ещё почитать?

Перед созданием статьи были прочитаны, опробованы и переосмыслены несколько англоязычных источников:

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

Автор: sergey_shambir

Источник

Поделиться

* - обязательные к заполнению поля