В этой статье я расскажу о том, как правильно писать современные CMakeLists.txt файлы для C++ библиотек. Идеи, используемые в ней, основаны на докладе Крейга Скотта (разработчик CMake) и докладе Роберта Шумахера (разработчик vcpkg) c CppCon 2019. Поскольку мне достаточно часто приходится разрабатывать С++ библиотеки, я создал для себя небольшой шаблон cpp-lib-template, который будет использоваться в этой статье в качестве примера.

Замечу, что я предполагаю, что читатель этой статьи имеет опыт работы с CMake, однако, как и я, часто задается вопросом, как правильно ему сделать сборку своей библиотеки.

Введение

Разработчик C++ библиотеки, очевидно, должен дать своим пользователям возможность легко ее использовать. И раз уж мы пишем на компилируемом языке, то к этому относится и то, насколько быстро пользователь сможет пройти путь от клонирования ваших исходников до получения бинарного файла библиотеки под свою платформу.

Когда мы говорим о пользователях библиотеки, то в первую очередь думаем о других разработчиках, которые захотят использовать функциональность библиотеки в своих проектах. Существует два способа, которыми библиотека может интегрироваться в проект:

  • С помощью find_package - в этом случае библиотека должна предоставить package configuration file, который импортирует в CMakeLists.txt приложения таргет библиотеки. Этот файл, вместе с собранной библиотекой, ее публичными заголовочными файлами и некоторой другой информацией устанавливается в директорию, которая потом указывается в переменной CMAKE_PREFIX_PATH при сборке проекта.

  • Как подпроект, добавленный в качестве git submodule или с помощью CMake модуля FetchContent - в этом случае приложение использует обычный (не импортированный) таргет библиотеки (find_package не вызывается), и сборка библиотеки становится этапом сборки самого проекта.

Еще одна категория пользователей библиотеки - мейнтейнеры различных пакетных менеджеров (например, vcpkg, conan и другие), которым нужно собирать библиотеку под десятки различных платформ и конфигураций. Для них важно, чтобы сборкой библиотеки можно было управлять извне, без необходимости внесения патчей в ее CMakeLists.txt.

Исходя из вышесказанного, хорошо сделанная библиотека должна удовлетворять следующим требованиям:

  • Единообразно интегрироваться и через find_package, и через add_subdirectory/FetchContent, т.е. импортированный таргет и обычный должны быть эквивалентны. В англоязычных источниках это требование часто формулируют как "build interface should match install interface".

  • В ее CMakeLists.txt не должны хардкодиться опции и флаги компиляции/компоновки кроме тех, которые абсолютно необходимы для сборки библиотеки. В противном случае мейнтейнерам менеджеров пакетов будет проблематично упаковывать библиотеку, так как с вероятностью, стремящейся к 1, на какой-то из платформ некоторые захардкоденные значения окажутся невалидны и придется делать патч для CMakeLists.txt.

Структура директорий

В своих библиотеках я придерживаюсь структуры директорий, представленной ниже. На мой взгляд, она является наиболее распространенной, кроме того, интуитивно разделяет файлы библиотеки на основные компоненты: публичные заголовки (include/<libname>), исходники (src), утилиты для сборки (cmake), примеры (examples) и тесты (tests).

repository root:

cmake/
examples/
include/<libname>/
src/
tests/

CMakeLists.txt
CMakePresets.json
...

Пример библиотеки

В дальнейшем в этой статье мы будем рассматривать простую библиотеку mylib, предоставляющую всего одну функцию add, которая возвращает результат сложения двух чисел:

#include <mylib/mylib.h>

namespace mylib {

int add(int a, int b)
{
    return a + b;
}
} // namespace mylib

Соответствующий заголовочный файл:

#pragma once

#include <mylib/export.h>

namespace mylib {

MYLIB_EXPORT int add(int a, int b);
}

Наконец, файл mylib/export.h:

#pragma once

#ifndef MYLIB_STATIC_DEFINE
#  include <mylib/export_shared.h>
#else
#  include <mylib/export_static.h>
#endif

Файлы export_shared.h и export_static.h генерируются CMake и будут рассмотрены ниже.

Определение проекта

Первое, что нужно сделать в CMakeLists.txt - это создать проект для библиотеки:

cmake_minimum_required(VERSION 3.14)
project(mylib
    VERSION 1.0.0
    DESCRIPTION "Template for C++ library built with CMake"
    LANGUAGES CXX)
    
add_library(mylib) # initialized below
add_library(mylib::mylib ALIAS mylib)

string(COMPARE EQUAL "${CMAKE_SOURCE_DIR}" "${CMAKE_CURRENT_SOURCE_DIR}"
    is_top_level)

Я предпочитаю заранее определить таргет для библиотеки, а инициализировать его ниже в коде, когда определены все необходимые параметры. Здесь же мы определим алиас для библиотеки, который должен иметь то же имя, что и импортируемый таргет. Это позволит пользователям библиотеки легко переключаться между ее подключением через find_package, в результате которого создается импортированный таргет с именем mylib::mylib, и подключением через add_subdirectory/FetchContent, который делает доступным алиас mylib::mylib в их проекте. Таким образом, приложение, использующее нашу библиотеку, в обоих случаях может линковаться к библиотеке с помощью команды:

target_link_libraries(app PRIVATE mylib::mylib)

Переменная is_top_level используется в дальнейшем в нескольких местах для определения, собирается ли библиотека как stand-alone проект или как подпроект. Версии CMake, начиная с 3.21, предоставляют переменную PROJECT_IS_TOP_LEVEL для этой же цели. Можно использовать и ее, но тогда в cmake_minimum_required придется указать версию не ниже 3.21.

Опции сборки

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

include(cmake/utils.cmake)
include(GNUInstallDirs)

# MYLIB_SHARED_LIBS determines static/shared build when defined
option(MYLIB_BUILD_TESTS "Build mylib tests" OFF)
option(MYLIB_BUILD_EXAMPLES "Build mylib examples" OFF)
option(MYLIB_INSTALL "Generate target for installing mylib" ${is_top_level})
set_if_undefined(MYLIB_INSTALL_CMAKEDIR 
    "${CMAKE_INSTALL_LIBDIR}/cmake/mylib-${PROJECT_VERSION}" CACHE STRING
    "Install path for mylib package-related CMake files")

if(DEFINED MYLIB_SHARED_LIBS)
    set(BUILD_SHARED_LIBS ${MYLIB_SHARED_LIBS})
endif()

Опции MYLIB_BUILD_* определяют, собирать или нет соответствующий компонент библиотеки. По умолчанию они выключены, потому что пользователи библиотеки, как правило, заинтересованы только в сборке самой библиотеки.

Если библиотека может быть собрана как статически, так и динамически, крайне не рекомендуется использовать кастомную переменную для определения типа сборки, потому что общепринятым стандартом является использование переменной BUILD_SHARED_LIBS, которая обрабатывается самим CMake. Следующий код является примером того, как делать не стоит:

if(SOMELIB_BUILD_AS_STATIC)
    add_library(somelib STATIC)
else()
    add_library(somelib SHARED)
endif()

BUILD_SHARED_LIBS, однако, влияет на все библиотеки, попадающие в сборку, а пользователям в редких случаях может понадобиться собрать несколько библиотек иначе, чем указано в BUILD_SHARED_LIBS. Поэтому среди опций библиотеки mylib есть MYLIB_SHARED_LIBS, которая может использоваться для переопределения значения BUILD_SHARED_LIBS.

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

Наконец, переменная MYLIB_INSTALL_CMAKEDIR позволяет указать, куда устанавливать файл конфигурации пакета (package configuration file), и предназначена в основном для мейнтейнеров менеджеров пакетов. Функция set_if_undefined определена в файлеcmake/utils.cmake и аналогична set, но устанавливает значение только если переменная еще не определена (напомню, что мы не хотим переопределять любые переменные, которые установлены через командную строку CMake или в проектах верхнего уровня).

Экспорт символов

В бинарном файле любой динамической библиотеки есть раздел, в котором хранится информация о том, какие функции экспортируются данной библиотекой. Эта информация используется динамическим загрузчиком - компонентом ОС, который занимается тем, что загружает в память динамические библиотеки, нужные приложению. Помимо этого, задача динамического загрузчика заключается в том, чтобы выполнить релокации - заменить заглушки в исполняемом файле приложения на реальные адреса в памяти, куда была загружена динамическая библиотека.

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

GCC и Clang по умолчанию экспортируют все символы, которые есть в библиотеке (в том числе те, что не являются частью API), что, как мы только что выяснили, негативно сказывается на скорости загрузки. CMake позволяет легко отключить это поведение, установив следующие переменные (замечу, что здесь я тоже использую функцию set_if_undefined, чтобы дать возможность пользователю при необходимости переопределить эти значения):

set_if_undefined(CMAKE_CXX_VISIBILITY_PRESET hidden)
set_if_undefined(CMAKE_VISIBILITY_INLINES_HIDDEN ON)

MSVC по умолчанию ничего не экспортирует, однако CMake предоставляет переменную CMAKE_WINDOWS_EXPORT_ALL_SYMBOLS, которая позволяет получить поведение, аналогичное GCC и Clang. Очевидно, что использовать ее не стоит.

Для экспорта конкретного символа из библиотеки разные компиляторы предоставляют разные директивы. Чтобы не углубляться в особенности компиляторов, можно воспользоваться функцией CMake generate_export_header. Эта функция создает файл, содержащий определение макроса MYLIB_EXPORT, который нужно указывать для экспортируемых символов. Процесс генерации файла элементарен:

include(GenerateExportHeader)
set(export_file_name "export_shared.h")

if(NOT BUILD_SHARED_LIBS)
    set(export_file_name "export_static.h")
endif()

generate_export_header(mylib EXPORT_FILE_NAME include/mylib/${export_file_name})

В результате в зависимости от типа сборки библиотеки CMake создаст в билд-директории один из двух файлов: export_shared.h или export_static.h.

Объясню, почему используются разные имена в зависимости от типа сборки. Это нужно, чтобы статическую и динамическую версию библиотеки при желании можно было установить в одну директорию. Для этого файлы должны иметь разные имена, чтобы не быть перезаписанными файлом для другого типа сборки. При этом выбор нужного можно сделать в отдельном файле (mylib/export.h, см. выше) с помощью идентификатора MYLIB_STATIC_DEFINE, который будет определяться только для статического таргета mylib.

Исходники библиотеки

Исходники библиотеки инициализируются с помощью следующих команд:

set(public_headers
    include/mylib/export.h
    include/mylib/mylib.h)
set(sources
    ${public_headers}
    src/mylib.cpp)
source_group(TREE "${CMAKE_CURRENT_SOURCE_DIR}" FILES ${sources})

list(APPEND public_headers
    "${CMAKE_CURRENT_BINARY_DIR}/include/mylib/${export_file_name}")
list(APPEND sources
    "${CMAKE_CURRENT_BINARY_DIR}/include/mylib/${export_file_name}")

Я предпочитаю использовать отдельную переменную для хранения публичных заголовков, потому что это лучше отражает разницу между этими файлами и может использоваться потом при создании install-таргета (см. ниже). Функция source_group заставляет CMake сгенерировать такую же структуру директорий в IDE, как и в самом репозитории библиотеки.

Таргет библиотеки

Теперь все готово для того, чтобы проинициализировать таргет библиотеки, который мы создали в самом начале. Делается это с помощью следующих команд:

include(CMakePackageConfigHelpers)

target_sources(mylib PRIVATE ${sources})
target_compile_definitions(mylib
    PUBLIC
        "$<$<NOT:$<BOOL:${BUILD_SHARED_LIBS}>>:MYLIB_STATIC_DEFINE")

target_include_directories(mylib
    PUBLIC
        "$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>"
        "$<BUILD_INTERFACE:${CMAKE_CURRENT_BINARY_DIR}/include>"
        "$<INSTALL_INTERFACE:${CMAKE_INSTALL_INCLUDEDIR}>"
    PRIVATE
        "${CMAKE_CURRENT_SOURCE_DIR}/src")

set_target_properties(mylib PROPERTIES
    PUBLIC_HEADER "${public_headers}"
    SOVERSION ${PROJECT_VERSION_MAJOR}
    VERSION ${PROJECT_VERSION})

Пояснения здесь требует, пожалуй, только момент связанный определением MYLIB_STATIC_DEFINE для статического таргета. Это нужно, чтобы файл mylib/export.h включал файл mylib/export_static.h, если используется статическая версия библиотеки, и файл mylib/export_shared.h в противном случае.

Обратите также внимание, что мы не пытаемся установить какие-то флаги компиляции или свойства таргета здесь. Как уже говорилось, это в дальнейшем создаст проблемы сторонним разработчикам, которые будут упаковывать вашу библиотеку под свою платформу. Например, во многих библиотеках, которые могут собираться под Windows, в свойствах таргета библиотеки часто указываются <CONFIG>_POSTFIX, чтобы бинарные файлы библиотеки, собранные под разные конфигурации (static/shared, debug/release), получали разные имена и могли устанавливаться в одну директорию. Если это сделать в CMakeLists.txt библиотеки, отказаться от выбранной разработчиком схемы будет проблематично. Вместо этого, постфиксы можно задать на этапе конфигурирования проекта с помощью переменных CMAKE_<CONFIG>_POSTFIX, указанных явно в командной строке или в пресете (preset), о которых мы поговорим позже.

Install-таргет библиотеки

Поскольку мы держим в уме тот факт, что библиотека может собираться как подпроект, мы предоставляем специальную переменную MYLIB_INSTALL, с помощью которой можно отключить генерацию install-таргета. Кроме того, самому CMake также можно указать, чтобы он не генерировал таргет для install команд с помощью переменной CMAKE_SKIP_INSTALL_RULES. По этой причине, код для создания install-таргета помещен внутрь условия:

if(MYLIB_INSTALL AND NOT CMAKE_SKIP_INSTALL_RULES)
...
endif()

Сам код для создания install-таргета имеет такой вид:

configure_package_config_file(cmake/mylib-config.cmake.in mylib-config.cmake
    INSTALL_DESTINATION "${MYLIB_INSTALL_CMAKEDIR}")

write_basic_package_version_file(mylib-config-version.cmake
    COMPATIBILITY SameMajorVersion)

install(TARGETS mylib EXPORT mylib_export
    RUNTIME COMPONENT mylib
    LIBRARY COMPONENT mylib NAMELINK_COMPONENT mylib-dev
    ARCHIVE COMPONENT mylib-dev
    PUBLIC_HEADER COMPONENT mylib-dev
        DESTINATION "${CMAKE_INSTALL_INCLUDEDIR}/mylib")

set(targets_file "mylib-shared-targets.cmake")

if(NOT BUILD_SHARED_LIBS)
    set(targets_file "mylib-static-targets.cmake")
endif()

install(EXPORT mylib_export
    COMPONENT mylib-dev
    FILE "${targets_file}"
    DESTINATION "${MYLIB_INSTALL_CMAKEDIR}"
    NAMESPACE mylib::)

install(FILES
    "${CMAKE_CURRENT_BINARY_DIR}/mylib-config.cmake"
    "${CMAKE_CURRENT_BINARY_DIR}/mylib-config-version.cmake"
    COMPONENT mylib-dev
    DESTINATION "${MYLIB_INSTALL_CMAKEDIR}")

В этом коде все достаточно традиционно, отмечу лишь, что в команде install(TARGETS) пути, куда нужно устанавливать основные файлы, можно опустить. В этом случае CMake по умолчанию использует те, которые предоставляются модулем GnuInstallDirs (что правильно, так как их легко переопределить на этапе конфигурации при необходимости).

Кроме того, я рекомендую явно указывать компоненты библиотеки с помощью параметра COMPONENT. В любой библиотеке можно выделить как минимум два компонента:

  • runtime - то, что нужно, чтобы приложение, использующее библиотеку, в принципе могло запуститься (so или dll файл); в своих библиотеках я как правило называю этот компонент также, как и саму библиотеку, т.е. <libname>

  • development - то, что нужно пользователю библиотеки (заголовочные файлы, библиотека импорта, файл конфигурации пакета и т.д.); для этого компонента я использую имя <libname>-dev

Разделение на компоненты позволяет в дальнейшем выполнять установку только необходимых файлов. Например, следующая инструкция установит только runtime-компонент библиотеки mylib:

cmake --install . --component mylib

Замечу, что файлы с определением импортируемых таргетов имеют разные имена в зависимости от типа сборки библиотеки. Это нужно для того, чтобы иметь возможность устанавливать статическую и динамическую версию библиотеки в одну директорию.

При создании install-таргета под Windows дополнительно указывается, как будут устанавливаться pdb-файлы, нужные для отладки библиотеки:

if(MSVC)
    set(pdb_file "")
    set(pdb_file_destination "")

    if(BUILD_SHARED_LIBS)
        set(pdb_file "$<TARGET_PDB_FILE:mylib>")
        set(pdb_file_destination "${CMAKE_INSTALL_BINDIR}")
    else()
        # TARGET_PDB_FILE does not work for pdb file generated by compiler
        # during static library build, need to determine it another way
        set(pdb_file "$<TARGET_FILE_DIR:mylib>/$<TARGET_FILE_PREFIX:mylib>$<TARGET_FILE_BASE_NAME:mylib>.pdb")
        set(pdb_file_destination "${CMAKE_INSTALL_LIBDIR}")
    endif()

    install(FILES "${pdb_file}"
        COMPONENT mylib-dev
        CONFIGURATIONS Debug RelWithDebInfo
        DESTINATION "${pdb_file_destination}"
        OPTIONAL)
endif()

Здесь нужно учесть один момент. Дело в том, что msvc при компиляции генерирует pdb-файл для каждого объектного файла .obj(compiler pdb-файл), а потом компоновщик объединяет их в один pdb-файл (linker pdb-файл) для итогового исполняемого файла (например, .dll). Когда вы собираете статическую библиотеку (которая по сути своей является таким же объектным файлом), компоновщик не вызывается вообще, только компилятор, который создает compiler pdb-файл.

CMake предоставляет простой способ получить linker pdb-файл: выражение-генератор TARGET_PDB_FILE. К сожалению, с помощью него вы не сможете получить compiler pdb-файл, когда собираете статическую версию библиотеки. В качестве хоть какого-то варианта решения этой проблемы, я предполагаю, что compiler pdb-файл лежит по тому же пути, что и статическая библиотека и имеет то же имя. Тем не менее, это не обязательно должно быть так, поэтому команда для установки pdb-файлов отмечена как OPTIONAL. Если вы можете предложить лучший вариант для обработки compiler pdb-файлов, поделитесь им в комментариях.

Файл конфигурации пакета

Файл конфигурации пакета предоставляется разработчиком библиотеки (я обычно помещаю его в директорию cmake) и устанавливается в одну директорию с файлами, сгенерированными командой install(EXPORT) и содержащими определения импортированных таргетов библиотеки (mylib-shared-targets.cmake и mylib-static-targets.cmake). В этом файле как правило делаются две вещи:

  • включается mylib-shared-targets.cmake или mylib-static-targets.cmake

  • с помощью find_dependency находятся все зависимости импортированного таргета

Наша тривиальная библиотека ни от чего не зависит, поэтому ее файл конфигурации имеет такой вид:

macro(import_targets type)
    if(NOT EXISTS "${CMAKE_CURRENT_LIST_DIR}/mylib-${type}-targets.cmake")
        set(${CMAKE_FIND_PACKAGE_NAME}_NOT_FOUND_MESSAGE
            "mylib ${type} libraries were requested but not found")
        set(${CMAKE_FIND_PACKAGE_NAME}_FOUND OFF)
        return()
    endif()

    include("${CMAKE_CURRENT_LIST_DIR}/mylib-${type}-targets.cmake")
endmacro()

if(NOT TARGET mylib::mylib)
    set(type "")

    if(DEFINED MYLIB_SHARED_LIBS)
        if(MYLIB_SHARED_LIBS)
            set(type "shared")
        else()
            set(type "static")
        endif()
    elseif(BUILD_SHARED_LIBS AND
           EXISTS "${CMAKE_CURRENT_LIST_DIR}/mylib-shared-targets.cmake")
        set(type "shared")
    elseif(EXISTS "${CMAKE_CURRENT_LIST_DIR}/mylib-static-targets.cmake")
        set(type "static")
    else()
        set(type "shared")
    endif()

    import_targets(${type})
    check_required_components(mylib)
    message("-- Found ${type} mylib (version ${${CMAKE_FIND_PACKAGE_NAME}_VERSION})")
endif()

Единственное, что заслуживает в нем внимания, это простой алгоритм, по которому определяется, какой таргет (для статической или динамической версии библиотеки) импортировать. Вкратце алгоритм можно описать так: MYLIB_SHARED_LIBS > BUILD_SHARED_LIBS > static > shared.

Другие таргеты

Большинство проектов C++ библиотек содержат примеры использования и тесты. Как правило эти компоненты размещаются в отдельных директориях (обычно /examples и /tests), в которые я рекомендую поместить CMakeLists.txt для их сборки, чтобы не засорять основной CMakeLists.txt в корне библиотеки. В основной CMakeLists.txt остается добавить лишь вызов add_subdirectory для нужных директорий:

if(MYLIB_BUILD_TESTS)
    enable_testing()
    add_subdirectory(tests)
endif()

if(MYLIB_BUILD_EXAMPLES)
    add_subdirectory(examples)
endif()

Таргет для тестов

Я считаю полезным, когда с тестами библиотеки можно работать не только как с частью библиотеки, но и как с самостоятельным проектом. Например, это позволяет легко собрать тесты для экземпляра библиотеки, установленного где-то в системе. Поэтому файл tests/CMakeLists.txt начинается с определения проекта для тестов, а также содержит опциональный вызов enable_testing и find_package(mylib), если тесты собираются как stand-alone проект:

cmake_minimum_required(VERSION 3.14)
project(mylib-tests)
include("../cmake/utils.cmake")

string(COMPARE EQUAL "${CMAKE_SOURCE_DIR}" "${CMAKE_CURRENT_SOURCE_DIR}"
    is_top_level)

if(is_top_level)
    enable_testing()
    find_package(mylib REQUIRED)
endif()

Я использую googletest, который предпочитаю подключать с помощью CMake модуля FetchContent. Документация googletest содержит подробные инструкции как это сделать, поэтому я не буду останавливаться на этом в своем руководстве (тем более, что вы возможно используете другой фреймворк).

Само определение таргета для тестов тривиально и следует тем же принципам, что и определение таргета библиотеки (замечу, что gtest_main это библиотека фреймворка googletest):

set(sources
    add_test.cpp)
source_group(TREE "${CMAKE_CURRENT_SOURCE_DIR}" FILES ${sources})

add_executable(mylib-tests)
target_sources(mylib-tests PRIVATE ${sources})

target_link_libraries(mylib-tests
    PRIVATE
        mylib::mylib
        gtest_main)

В Unix-подобных ОС в бинарном файле есть специальная запись rpath, в которой можно прописать путь к используемым динамическим библиотекам. Когда вы собираете библиотеку и тесты где-то в своей билд-директории, CMake в исполняемый файл mylib-tests добавляет rpath, указывающий на файл библиотеки. Таким образом при запуске mylib-tests динамический компоновщик сумеет найти библиотеку mylib, несмотря на то, что она не установлена по системному пути и не прописана в переменной среды PATH.

К сожалению, в Windows нет аналогичного rpath механизма. Поэтому при сборке тестов для динамической библиотеки возникает проблема с тем, что тесты не запускаются из IDE или с помощью CTest, поскольку DLL-файл библиотеки находится в билд-директории, о которой динамический загрузчик как правило ничего не знает. Чтобы обойти это ограничение, в файле cmake/utils.cmake определена функция win_copy_deps_to_target_dir, которая копирует dll-файл (а также pdb, если мы собираем дебаг-версию) в директорию с исполняемым файлом mylib-tests. В tests/CMakeLists.txt эта функция вызывается после определения таргета следующим образом:

if(NOT is_top_level)
    win_copy_deps_to_target_dir(mylib-tests mylib::mylib)
endif()

Замечу, что когда mylib-tests собирается как stand-alone проект, я предпочитаю не копировать зависимости в его билд-директорию, поскольку dll-файл может находится в директории, куда не будет доступа на копирование.

Рассмотрим функцию win_copy_deps_to_target_dir поподробнее:

function(win_copy_deps_to_target_dir target)
    if(NOT WIN32)
        return()
    endif()

    set(has_runtime_dll_genex NO)

    if(CMAKE_MAJOR_VERSION GREATER 3 OR CMAKE_MINOR_VERSION GREATER_EQUAL 21)
        set(has_runtime_dll_genex YES)

        add_custom_command(TARGET ${target} POST_BUILD
            COMMAND ${CMAKE_COMMAND}
                -P "${mylib_SOURCE_DIR}/cmake/silent_copy.cmake"
                "$<TARGET_RUNTIME_DLLS:${target}>" "$<TARGET_FILE_DIR:${target}>"
            COMMAND_EXPAND_LISTS)
    endif()

    foreach(dep ${ARGN})
        get_target_property(dep_type ${dep} TYPE)

        if(dep_type STREQUAL "SHARED_LIBRARY")
            if(NOT has_runtime_dll_genex)
                add_custom_command(TARGET ${target} POST_BUILD
                    COMMAND ${CMAKE_COMMAND}
                        -P "${mylib_SOURCE_DIR}/cmake/silent_copy.cmake" 
                        "$<TARGET_FILE:${dep}>"
                        "$<TARGET_PDB_FILE:${dep}>"
                        "$<TARGET_FILE_DIR:${target}>"
                    COMMAND_EXPAND_LISTS)
            else()
                add_custom_command(TARGET ${target} POST_BUILD
                    COMMAND ${CMAKE_COMMAND}
                        -P "${mylib_SOURCE_DIR}/cmake/silent_copy.cmake"
                        "$<TARGET_PDB_FILE:${dep}>" "$<TARGET_FILE_DIR:${target}>"
                    COMMAND_EXPAND_LISTS)
            endif()
        endif()
    endforeach()
endfunction()

Эта функция создает кастомную команду для указанного таргета, которая будет запускаться после его сборки. Собственно копирование выполняется CMake скриптом cmake/silent_copy.cmake, который после парсинга своих аргументов вызывает:

# парсинг аргументов в переменные 'files' и 'dest'...

execute_process(COMMAND ${CMAKE_COMMAND} -E copy_if_different
    ${files} "${dest}" ERROR_QUIET)

Возможно у вас возникнет вопрос, почему потребовалось вынести конструкцию ${CMAKE_COMMAND} -E copy_if_different <files> <dest> в отдельный скрипт вместо того, чтобы просто указать ее при определении кастомной команды. Дело в том, что в режиме -E CMake выдаст ошибку, если список содержит несуществующие файлы. А это может произойти если, например, библиотека собиралась без отладочной информации (т.е. pdb-файл не был сгенерирован). Можно правильно обработать такие ситуации, но мне показалось проще написать скрипт, который просто игнорирует ошибки, связанные с отсутствующими файлами.

Также обратите внимание, что начиная с версии 3.21 CMake предоставляет выражение генератора TARGET_RUNTIME_DLLS, которое возвращает абсолютные пути ко всем динамическим библиотекам, от которых зависит таргет (напрямую или транзитивно). Это выражение позволяет значительно упростить использование функции win_copy_deps_to_target_dir, поскольку можно не передавать в нее явным образом зависимости таргета - функция сумеет определить их самостоятельно.

В заключение нам остается только сообщить CTest, какие тесты есть в нашем проекте. Для фреймворка googletest это можно выполнить следующим образом:

include(GoogleTest)
gtest_discover_tests(mylib-tests)

Функция gtest_discover_tests определенным образом запускает исполняемый файл mylib-tests, чтобы узнать имена всех тестов, которые в нем содержатся (при этом сами тесты на этом этапе не выполняются), а затем интегрировать их в CTest.

Таргеты для примеров

Таргеты для примеров библиотеки (в директории /examples) как правило отличаются только именами, поэтому достаточно рассмотреть один:

cmake_minimum_required(VERSION 3.14)
project(mylib-add LANGUAGES CXX)
include("../../cmake/utils.cmake")

string(COMPARE EQUAL "${CMAKE_SOURCE_DIR}" "${CMAKE_CURRENT_SOURCE_DIR}"
    is_top_level)

if(is_top_level)
    find_package(mylib REQUIRED)
endif()

set(sources main.cpp)
source_group(TREE "${CMAKE_CURRENT_SOURCE_DIR}" FILES ${sources})

add_executable(mylib-add)
target_sources(mylib-add PRIVATE ${sources})
target_link_libraries(mylib-add PRIVATE mylib::mylib)

if(NOT is_top_level)
    win_copy_deps_to_target_dir(mylib-add mylib::mylib)
endif()

Как и в случае с тестами, я предпочитаю дать пользователям возможность собирать примеры как отдельные проекты. Кроме того, для удобной отладки примеров под Windows используется знакомая нам по предыдущему разделу функция win_copy_deps_to_target_dir.

Пресеты (presets)

Пресеты (извините, не подобрал хорошего перевода) являются относительно новой возможностью CMake (появились в CMake 3.19), позволяющей вынести параметры сборки из CMakeLists.txt. Как уже говорилось в начале статьи, делать это нужно для того, чтобы ваши проекты без проблем собирались под разные платформы и тулчейны.

Пресет представляет из себя простой json-файл, в котором задаются различные параметры, влияющие на сборку проекта (опции конфигурации, флаги компилятора и т.д.). Существует два типа пресетов:

  • CMakePresets.json для хранения глобальных настроек - этот файл обычно хранится в репозитории проекта

  • CMakeUserPresets.json для локальных настроек разработчика - этот файл не нужно хранить в репозитории (у каждого разработчика он как правило свой), и поэтому он указывается в .gitignore

Многие IDE (например, CLion) поддерживают пресеты, позволяя выбирать нужный в своем GUI. Подробнее о пресетах можно почитать в официальной документации, здесь я лишь приведу простой пример для библиотеки mylib:

{
  "version": 3,
  "cmakeMinimumRequired": {
    "major": 3,
    "minor": 14,
    "patch": 0
  },
  "configurePresets": [
    {
      "name": "dev",
      "description": "Base preset for library developers",
      "binaryDir": "${sourceDir}/build",
      "hidden": true,
      "cacheVariables": {
        "MYLIB_BUILD_TESTS": "ON",
        "MYLIB_BUILD_EXAMPLES": "ON"
      }
    },
    {
      "name": "dev-win",
      "description": "Windows preset for library developers",
      "hidden": false,
      "inherits": ["dev"],
      "cacheVariables": {
        "CMAKE_CXX_FLAGS": "/W4 /EHsc /w14242 /w14254 /w14263 /w14265 /w14287 /w14289 /w14296 /w14311 /w14545 /w14546 /w14547 /w14549 /w14555 /w14640 /w14826 /w14928 /WX"
      }
    },
    {
      "name": "dev-linux",
      "description": "Linux preset for library developers",
      "hidden": false,
      "inherits": ["dev"],
      "cacheVariables": {
        "CMAKE_CXX_FLAGS": "-Wall -Wextra -Wpedantic -Wshadow -Wconversion -Wsign-conversion -Wcast-align -Wcast-qual -Wnull-dereference -Woverloaded-virtual -Wformat=2 -Werror"
      }
    }
  ]
}

Ссылки

В заключение приведу некоторые полезные ссылки:

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


  1. LunaticRL
    18.08.2022 00:27
    +8

    Огромное спасибо за статью, очень интересная и с точки зрения объяснения того "как должно быть" и с точки зрения формулировки задач о которых в принципе нужно помнить, когда готовишь библиотеку на экспорт.

    Хоть будет с чем сверяться когда снова буду библиотеку в cmake заворачивать.

    Единственный момент, после прочтения возникает фрустрирующее ощущение от того, сколько же нужно усилий и файлов с текстом для того чтобы всего лишь правильно собрать библиотеку. Хотя казалось бы, эта задача периодически возникает уже последние лет 40, но стандартного компактного решения всё нет и нет


    1. Bladegreat
      18.08.2022 05:35
      +1

      В конце статьи есть ссылка на проект cmake-init, который помогает создать проект из шаблона. С ним все упрощается до ответа на несколько вопросов о типе проекта, использемом стандарте и пакетном менеджере. При этом он сразу поможет настроить CI/CD в GitHub.


    1. pananton Автор
      18.08.2022 06:25
      +1

      Не совсем согласен с вами. Решение есть, оно достаточно типовое, так что его вполне можно загнать в какой-то генератор, например упоминавшийся уже cmake-init. Проблема однако в том, что лично мне не удалось найти его сформулированным в одном месте, пришлось ознакомиться с большим количество источников, в которых раскрывались отдельные вопросы, но не все целиком. Не понимаю, почему CMake не может обновить свой tutorial с учетом всех новых фич. Может для того, чтобы докладчиков на CppCon без хлеба не оставлять, которые из года в год рассказывают, как надо правильно делать.


      1. playermet
        19.08.2022 13:37
        +3

        C++ наверное единственный язык, для которого есть генератор проекта для генератора проектных файлов для разных систем сборки использующих разные компиляторы.


  1. SGrek
    18.08.2022 11:08
    +1

    Здравствуйте! Так как только осваиваю CMake, хотел задать вопрос по своей текущей задаче.
    Как можно модернизировать Ваш шаблон для библиотеки, которая в свою очередь является оберткой для другой библиотеки различных конфигураций (win/linux, x86/x64, static/shared, release/debug), которая имеет только заголовочные файлы и бинарные (dll/lib/so).
    Хотелось бы раскрыть вопросы:

    • каким образом организовать сборку и под Linux, и под Windows, с учетом того, что атрибуты экспортируемых функций для Linux/Windows разный, т.е. можно ли это как-то автоматизировать (есть ли такой инструмент у CMake) или это надо делать вручную;

    • как правильно ссылаться (прописывать пути к include/libs) к оборачиваемой библиотеке;

    • что лучше определять в CMakePresets.json, а что в CMakeLists.txt;

    • установка разных конфигураций библиотеки.


    1. pananton Автор
      18.08.2022 11:31
      +2

      1. Если вы про то, что для того, чтобы пометить функцию как экспортируемую, в Linux и Windows используются разные директивы компилятора (__attribute__ и __declspec), то CMake предоставляет функцию generate_export_header, которая сгенерирует файл, в котором за макросом вида <LIBNAME>_EXPORT спрячет платформо-зависимую директиву. Этот файл надо включать в свои исходники и устанавливать вместе с вашей библиотекой. В статье есть этот вопрос освещается в разделе "Экспорт символов"

      2. В своей библиотеке вы делаете find_package(otherLib), потом target_link_libraries(myLib PUBLIC|PRIVATE otherLib). PUBLIC вы определяете, если нужно, чтобы проекты, которые будут линковаться с myLib видели заголовочные файлы otherLib (как правило, из-за того, что заголовки myLib включают заголовки otherLib), в противном случае - PRIVATE. Еще потребуется в файле конфигурации вашей библиотеки выполнить команду find_dependency(otherLib), чтобы проинициализировать зависимости для импортируемого таргета. Этот способ сработает, если разработчик otherLib все сделал правильно и предоставил файл конфигурации для своей библиотеки. Если нет, то вам надо написать свой find module для otherLib, который будет находить ее в системе и создавать для нее импортированный таргет. Этот модуль затем вы будете вероятно устанавливать вместе со своей библиотекой, чтобы использовать его в своем файле конфигурации при вызове find_dependency.

      3. В CMakeLists.txt нужно хардкодить только build requirements, т.е. опции, без которых ваш проект в принципе не соберется. Все остальное - в пресетах. Например, флаг Werror, который делает предупреждения компилятора ошибками, никогда не должен указываться в вашем CMakeLists.txt (ну или по крайней мере должна быть опции в вашем проекте, которая позволит его отключить, но лучше использовать пресет). Причины, по котором это необходимо делать, объясняются в статье. Если вкратце, любой захардкоденный флаг в вашем проекте - проблема на мейнтейнеров пакетных менеджеров, т.к. им придется патчить ваши CMakeLists.txt, когда они будут собирать вашу либу под какую-нибудь платформу, на которой захардкоденная опция невалидна.


      1. SGrek
        18.08.2022 23:45

        Спасибо за разъяснение!
        А если нет возможности воспользоваться find_package, т.е. обертываемая библиотека была предоставлена только заголовочными и бинарными файлами (нет ни файла конфигурации и в системе никаким образом не зарегистрирована). Какова правильная практика указать искомый путь к этим файлам? Сейчас я в пресете создаю переменную, которая хранит путь к папке обертываемой библиотеки, затем в CMakeLists через target_include_directories подключаю заголовки, target_link_directories папку с бинарными файлами и target_link_libraries имя библиотеки. Догадываюсь, что неправильно.


        1. pananton Автор
          19.08.2022 04:18

          Я вам ответил - нужно написать find module, см. п. 2


          1. SGrek
            19.08.2022 10:25

            П.2 почитал внимательно, но не сразу разобрался, что именно find module решается моя задача. Спасибо!


            1. klirichek
              20.08.2022 08:31

              Обычно (если либа по известному пути) проще даже не find module а config module. Find подразумевает именно поиск через glob и другие интроспекции. Это небыстро. Но если путь известен (например, сами собрали эту третью либу через external_project), то можно просто создать imported target и положить в его свойства нужные известные пути/флаги. И вот это уже будет config.

              А "правильно ссылаться" - это как раз imported target. Сейчас в ходу двоякий путь - остался как старый вариант, где модуль ставит разные переменные вроде XXX_LIBRARIES, XXX_INCLUDE_DIRS и т.д., и вы потом их используете, так и современный - когда всё нужное инкапсулировано в таргет. В последнем случае вы просто с ним линкуетесь, а вся внутренняя кухня с инклюдами, флагами и т.д. подтягивается автоматически. Плюс, в этом случае можно сразу разделить разные сборки (debug/release) в единственном таргете.


              1. pananton Автор
                20.08.2022 11:01

                Ммм, не уверен, что полностью вас понимаю. Из своей библиотеки нужно как-то найти чужую. find_package при этом не работает, т.к. third-party либа не экспортирует файл конфигурации пакета, и не достаточна популярна, чтобы CMake сам по себе имел для нее find-модуль (как, например, для буста и опенссл). Поэтому, чтобы find_package заработал, нужно написать свой find-модуль. Что вы подразумеваете под config module я до конца не понял, т.к. в документации CMake такого понятия нет, насколько мне известно. Если вы предлагаете просто где-то руками создать импортированный таргет для third-party либы и потом без всякого find_package его использовать - то для каких-то частных случаев это может и сгодиться, но в общем случае - это костыльный подход, с которым потом вероятно вылезут проблемы. Например, когда вы свою библиотеку инсталлируете, вам придется в своем файле конфигурации как-то искать third-party либу, которая непонятно где в системе может быть установлена. Если вы напишите свой find-модуль, то можете его просто ставить вместе со своей библиотекой и использовать в файле конфигурации пакета, чтобы find_dependency отрабатывал, как надо.

                Насчет переменных, выставляемых в файлах конфигурации библиотек, про которые вы писали. Когда я готовил статью, я некоторое время раздумывал на тему того, нужно ли мне тоже показать, как получить эти пути, потому что вообще говоря там не все просто. Но в итоге пришел к выводу, что раз уж использование этих путей приводит к плохому стилю чужих CMakeLists.txt, не стоит мне искушать пользователей моих библиотек и давать им такую возможность. Поэтому сейчас я придерживаюсь мнения, что импортированного таргета должно быть достаточно всем. Если кто-то все еще не использует CMake 3.0 и старше, то это дополнительный сигнал, что пора)


                1. klirichek
                  20.08.2022 11:31

                  Ну, чтобы найти - зовётся find_package.

                  Он пытается найти по известным путям файл FindXXX.cmake - если таковой найден, он выполняется. Если нет - файлы XXXConfig.cmake и/или xxx-config.cmake.

                  Скрипты findXXX лежат в самом cmake, а также конфигурируются в CMAKE_MODULE_PATH. Скрипты config ищутся тоже по общим путям, + по списку из CMAKE_PREFIX_PATH.

                  Можно поменять порядок поиска через CMAKE_FIND_PACKAGE_PREFER_CONFIG. Вроде всё.

                  Смысл в том, что можно использовать любой из перечисленных файлов, чтобы реализовать свою логику поиска. И даже писать в них одно и то же.

                  Но обычно скрипты FindXXX написаны руками, и там может быть весьма витиеватая логика с find_path/find_library, интроспекцией всевозможных путей и запуском разных помогалок вроде mysql_config для FindMysql. Заканчивается всё стандартно вызовом find_package_handle_standard_args, и выставлением XXX_INCLUDE_DIRS и XXX_LIBRARY. Сейчас туда же дописывают создание imported target. Этот скрипт предполагается использовать "вообще" для поиска либы - т.е. его можно положить отдельно или даже запульнуть пулл-реквест в cmake.

                  А ConfigXXX/xxx-config - это обычно результат экспорта cmake. Там нет никаких интроспекций, всё чётко лежит по известным (относительно скрипта) путям. Чётко - создал imported target, прописал свойства - и вуаля. Непосредственно искать ничего не надо, надо всего лишь обернуть уже известное. И для предсобранных либ без cmake можно такой скрипт написать руками и положить их все рядом с папками самих либ. Всё будет так же находиться и собираться, но поскольку это не скрипты поиска, а всего лишь обёртки - их не очень верно называть FindXXX. Они именно конфиги.


    1. pananton Автор
      18.08.2022 12:00
      +2

      Про то, как можно работать с multi-config генераторами расскажу в отдельном ответе.

      Тут вообще говоря обычно используется два пути: установка разных конфигураций (static/shared, Debug/Release/..) в разные директории (тогда ничего специального делать не нужно), или же установка разных конфигураций в одну директорию, т.е. по одному CMAKE_INSTALL_PREFIX.

      Во втором случае вам придется решить проблему с одинаковыми именами файлов. В качестве примера можно рассмотреть Windows. При установке обычно копируются такие файлы:

      1. Публичные заголовки библиотеки - они должны быть одинаковыми для всех конфигураций, поэтому без разницы, сколько разных конфигураций вы установите в одну директорию. Единственный момент здесь - это файл, генерируемый generate_public_header. Он имеет разный вид для static и shared, поэтому в статье я называю эти файлы по-разному для static и shared

      2. Файл версии проекта (генерируется write_basic_package_version_file) - одинаковый для разных конфигураций, можно смело перезаписывать

      3. Файл конфигурации проекта можно (и нужно) писать так, чтобы он не зависел от конфигурации сборки

      4. Файл с определением таргета библиотеки (создается install(EXPORT)) - он разный для static и shared, но НЕ зависит от типа билда (Debug, Release и т.д.). Поэтому нужно называть их по-разному, если вы планируете устанавливать статическую и динамическую версию в одну директорию. При этом install(EXPORT) кроме этих файлов генерирует еще для текущей конфигурации файл <targets-file-name>-<config>.cmake (например, mylib-static-targets-debug.cmake, mylib-static-targets-release.cmake и т.д.) Когда ваш файл конфигурации библиотеки (mylib-config.cmake) включает файл с импортируемым таргетом библиотеки (например, mylib-static-targets.cmake), последний дополнительно включает все файлы с именем mylib-static-targets-*.cmake. В этих файлах, если не вдаваться в детали, для импортируемого таргета библиотеки устанавливается свойство IMPORTED_LOCATION_<CONFIG>, в которое записывается путь до бинари библиотеки для конкретной конфигурации. В проекте, который использует вашу библиотеку, потом можно менять конфигурацию, и линковаться будет та, что нужно.

      5. Бинари библиотеки - они очевидно разные, поэтому нужно продумать какую-то схему по их переименованию. Например, я иногда использую постфиксы "" (пустой) для Release, "d" для Debug, "m" для MinSizeRel и "r" для RelWithDebInfo. Для статической версии дополнительно в начало постфикса ставится буква "s". Не надо только свою схему навязывать всем, прописывая эти постфиксы прямо в set_target_properties в CMakeLists.txt. Вместо этого можно в пресете или в командной строке использовать переменную CMAKE_<CONFIG>_POSTFIX.


      1. Nipheris
        20.08.2022 01:10
        +1

        установка разных конфигураций (static/shared, Debug/Release/..) в разные директории (тогда ничего специального делать не нужно)

        Мне кажется это самый разумный и единственный работающий подход. Спасибо Conan-у за то, что именно такой путь они и выбрали, по сути стандартизировав сборку в разных конфигурациях. Все остальные велосипеды жутко усложняет работу.

        Тот же GenerateExportHeader генерирует экспортный хедер, который вполне готов к использованию и в STATIC-варианте. В случае размещения только одной конфигурации в одной директории, никакие дополнительные ухищрения не нужны.

        Кстати, поддержка мультиконфигурационности в самом CMake - тоже не сахар. Она сделана по сути только для нескольких генераторов, причём прежде всего для IDE вроде Visual Studio, и сильно усложняет код скриптов кучей одинаковых переменных, но для разных конфигураций. Мне кажется гораздо правильнее реализовать такое по append-принципу "несколько прогонов cmake-скрипта -> один солюшен/проектный файл", чтобы новые конфигурации просто "дописывались" в уже сгенерированный проект. Но имеем что имеем.

        Спасибо за статью! Отсутствие best practices в документации самого CMake - это, пожалуй, его главная проблема сегодня. Даже синтаксис - и тот можно стерпеть.


      1. klirichek
        20.08.2022 08:18
        +1

        На винде критически важно различать debug и release версии, потому что там ещё и ABI у рантайма разный. Какой-нибудь std::string передать из либы или в либу - и это сразу вылезает. А вот разные варианты релизных - это уже на любителя. Обычно вполне достаточно одной, а остальные на неё замапить (через MAP_IMPORTED_CONFIG_<XXX>).

        В разные пути ставить или нет - ну, по дефолту cmake в билде делает в разные. Причём по крайней мере для ninja и msbuild структура этих папок совпадает, что позволяет легко делать финт с тестами после кросс-компиляции (собираешь кроссом с помощью ninja-multi-config, передаёшь собранные артефакты в виртуалку с виндой, прямо 1-в-1 где собрались. В виртуалке запускаешь ctest без стадии сборки - и оно сразу работает).

        Вот если инсталлировать на конечную систему - там да, проще по дефолтному пути, и тогда приходится патчить имена.


        1. pananton Автор
          20.08.2022 10:43

          Да, вы верно говорите. В том руководстве, о котором я написал в комментарии не возникает проблем с разными версиями рантайма (библиотеки-то в итоге все с разными именами и линкуется с нужной конфигурацией/рантаймом).

          Замечание про MAP_IMPORTED_CONFIG_<CONFIG> я уверен полезно для читателей, спасибо.


  1. bfDeveloper
    18.08.2022 11:24
    +1

    Огромное спасибо за статью. Давно сам хотел написать что-то подобное, потому что постоянно сталкиваюсь с библиотеками на CMake, которыми совершенно невозможно пользоваться. То как subdirectory не работает, то свои переменные выставляет сложно.


    1. pananton Автор
      18.08.2022 12:02
      +2

      Пожалуйста, я вот раньше не вытерпел)


  1. Sazonov
    18.08.2022 21:05
    +2

    Спасибо. Я вроде неплохо владею cmake, но кое что новое для себя почерпнул.

    Если у вас будет вдохновение и силы написать отдельную статью про CPack, то будет вообще супер. А то у нас сборка deb пакета с systemd сервисом выполняется вручну из выхлопа cmake.


    1. pananton Автор
      19.08.2022 04:27

      Честно говоря, у меня опыта по CPack особого нет. Мне он вообще представляется довольно простым - устанавливай всякие переменные, и все дела. Вероятно я просто не в курсе о всяких подводных камнях, которые можно было бы раскрыть в статье.

      В данный момент я больше задумываюсь о туториале по рецептам для Conan.


      1. dmanikhine
        19.08.2022 20:41

        Вот за этот туториал Вам заранее ОГРОМНОЕ спасибо. )


      1. Sazonov
        20.08.2022 13:00

        Лучше сразу vcpkg


  1. klirichek
    20.08.2022 08:07

    Возможно, в пресет не стоит писать мин. версию 3.14, покуда, как вы сами говорите, поддержка появилась только в 3.19


    1. pananton Автор
      20.08.2022 10:37

      Имхо в пресете надо все-таки указывать минимальную версию, нужную именно для сборки проекта. Я не вижу причин, по которым было бы предпочтительнее указывать версию, нужную для поддержки вашего варианта пресета (которых тоже уже 5 штук), а вот обратное в общем случае неверно. Потому что чисто теоретически, ничто не мешает IDE самой распарсить пресет и при вызове CMake вообще его не указывать, а, скажем, переменные cacheVariables просто передавать в командной строке. Тогда вам не нужен именно CMake 3.19 и выше, потому что `cmake --preset ...` просто не используется. У других авторов я находил именно такой вариант (т.е. в пресете запросто указывается именно версия, нужная для сборки, а не для поддержки самого пресета).


  1. Ingulf
    20.08.2022 17:10
    +1

    Отличная статья, выражаю благодарности автору. Нахожусь на этапе подготовки предложения по рефакторингу файлов сборки текущего проекта, и как вовремя ваша статья выходит..
    Если будет интересна тема, напишете о кросскомпиляции, интересуют не только Linux / Windows, но и мобильные платформы, как в этом случае лучше конфигурировать зависимости: особенно когда многие было бы проще сначала собрать, и они разбросаны по кастомным путям


    1. pananton Автор
      20.08.2022 19:10

      Признаться мне не доводилось организовывать кросс-компиляция сколько-нибудь серьезных CMake-проектов, поэтому особо делиться нечем. Очевидно, вам понадобится создать тулчейн-файл - это не должно вызвать сложностей. Общие рекомендации те же - никаких захардкоденных настроек в CMakeLists.txt. Собственно, все настройки целевой платформы в тулчейн-файле можно указать, а сам тулчейн файл для красоты добавить в пресет (см. поле toolchainFile). Тогда вы сможете вызывать что-то вроде `cmake .. --preset linux`, `cmake .. --preset android`, `cmake .. --preset win` и т.д.

      По зависимостям так не смогу подсказать, мало конкретики) Могу однако порекомендовать посмотреть в сторону новой фичи Cmake (появилась в 3.24) - провайдеры зависимостей (dependency providers). Провайдер зависимости - это по сути просто функция (или, что чаще, макрос), которая перехватывает все вызовы find_package и/или FetchContent и дальше может делать с ними все, что угодно, тем более, что все параметры, с которыми вызывалась find_package/FetchContent в нее передаются. Называется она так по тому, что все-таки в основном предполагается, что делать она будет следующее: дергать какую-то внешнюю команду, которая подтянет каким-то образом зависимость.

      Например, ваш провайдер при вызове функции find_package(Boost 1.77.0) может выполнить (с помощью стандартных средств CMake, например, execute_process) команду conan install boost/1.77.0@ --generator cmake_paths, а потом вызвать уже "обычный" find_package, который найдет только что установленный буст.

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