CMake і vcpkg

Структура великого проєкту: підпапки, модулі CMake, PCH та install

Рекомендована структура папок C++ проєкту, ієрархія CMakeLists.txt, внутрішні бібліотеки, власні модулі .cmake для попереджень компілятора, попередньо скомпільовані заголовки, команда install.

Структура великого проєкту: підпапки, модулі CMake, PCH та install

🎯 Мета лекції

  • Розмістити код проєкту за усталеною структурою папок.
  • Розділити CMakeLists.txt на кореневий і підпапкові та зв'язати їх через add_subdirectory.
  • Винести повторювані налаштування у власний .cmake модуль.
  • Прискорити компіляцію попередньо скомпільованими заголовками.
  • Встановити готовий проєкт командою cmake --install.

🔑 Ключові терміни

  • Підпроєкт (subdirectory): папка з власним CMakeLists.txt, підключена через add_subdirectory.
  • Модуль CMake: файл .cmake, який підключається командою include().
  • PCH (precompiled header): заголовок, який компілюється один раз і використовується в усіх файлах.
  • Інсталяція (install): копіювання результатів збірки у цільову папку (/usr/local, C:\Program Files).

Одного CMakeLists.txt вистачає, доки проєкт складається з кількох файлів. Коли з'являються внутрішні бібліотеки, тести та приклади, один файл у кілька сотень рядків стає важко читати. Розв'язок: розділити конфігурацію так само, як розділено код.

Рекомендована структура папок

Призначення папок:

ПапкаВміст
include/myproject/Публічні заголовки, які бачать користувачі проєкту. Підпапка з назвою проєкту унеможливлює конфлікти імен файлів
src/Реалізація (.cpp) та закриті заголовки
libs/Внутрішні бібліотеки. Кожна має власні include/, src/ та CMakeLists.txt
external/Сторонній код, скопійований або підключений як submodule
cmake/Власні модулі та допоміжні скрипти CMake
tests/Тести з окремим CMakeLists.txt
examples/Приклади використання, які збираються за опцією
docs/, scripts/Документація й допоміжні скрипти збірки
build/Результат збірки, додається в .gitignore

Структура не є вимогою CMake. Це домовленість, якої дотримується більшість open-source проєктів, і саме її очікують побачити нові учасники команди.

Кореневий CMakeLists.txt

Кореневий файл задає загальні налаштування і підключає підпапки. Конкретні цілі в ньому майже не описуються.

cmake_minimum_required(VERSION 3.15)

# Опис проєкту
project(MyProject
    VERSION 1.2.3
    DESCRIPTION "Awesome C++ project"
    LANGUAGES CXX
)

# Налаштування C++
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)

# Власні модулі CMake
list(APPEND CMAKE_MODULE_PATH "${CMAKE_SOURCE_DIR}/cmake")
include(CompilerWarnings)

# Опції проєкту
option(BUILD_TESTS "Build tests" ON)
option(BUILD_EXAMPLES "Build examples" ON)
option(BUILD_SHARED_LIBS "Build shared libraries" OFF)

# Виведення інформації
message(STATUS "=================================")
message(STATUS "  ${PROJECT_NAME} v${PROJECT_VERSION}")
message(STATUS "=================================")
message(STATUS "Build type: ${CMAKE_BUILD_TYPE}")
message(STATUS "C++ standard: ${CMAKE_CXX_STANDARD}")
message(STATUS "Compiler: ${CMAKE_CXX_COMPILER_ID} ${CMAKE_CXX_COMPILER_VERSION}")
message(STATUS "Build tests: ${BUILD_TESTS}")
message(STATUS "Build examples: ${BUILD_EXAMPLES}")
message(STATUS "=================================")

# Куди класти результати збірки
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin)
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib)
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib)

# Внутрішні бібліотеки
add_subdirectory(libs/mylib1)
add_subdirectory(libs/mylib2)

# Головна програма
add_subdirectory(src)

# Тести додаються на вимогу
if(BUILD_TESTS)
    enable_testing()
    add_subdirectory(tests)
endif()

# Приклади додаються на вимогу
if(BUILD_EXAMPLES)
    add_subdirectory(examples)
endif()

Порядок add_subdirectory має значення: бібліотеки йдуть перед програмою, яка їх використовує. Хоча CMake дозволяє посилатися на ціль, оголошену пізніше, послідовне розташування читається легше.

Змінні CMAKE_RUNTIME_OUTPUT_DIRECTORY та суміжні збирають усі виконувані файли в build/bin, а бібліотеки в build/lib. Без них кожен файл лежить у «своїй» підпапці всередині build/, і шукати їх незручно. Підтвердження на реальній збірці:

cmake -S . -B build && cmake --build build
$ cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug
-- =================================
-- MyProject v1.2.3
-- =================================
-- C++ standard: 17
-- Build tests: ON
$ cmake --build build
[ 33%] Linking CXX static library ../../lib/libMyLib1.a
[ 33%] Built target MyLib1
[ 55%] Linking CXX static library ../../lib/libMyLib2.a
[ 55%] Built target MyLib2
[100%] Linking CXX executable ../bin/myapp_d
[100%] Built target MyProject
$ ./build/bin/myapp_d
feature1=1 feature2=2 helper2=20 utils=7

CMakeLists.txt у підпапках

src/CMakeLists.txt: головна програма

# Список вихідних файлів
set(SOURCES
    main.cpp
    utils.cpp
)

# Виконуваний файл
add_executable(${PROJECT_NAME} ${SOURCES})

# Підключення include-папок
target_include_directories(${PROJECT_NAME}
    PRIVATE
        ${CMAKE_CURRENT_SOURCE_DIR}
    PUBLIC
        ${PROJECT_SOURCE_DIR}/include
)

# Зв'язування з внутрішніми бібліотеками
target_link_libraries(${PROJECT_NAME}
    PRIVATE
        MyLib1
        MyLib2
)

# Властивості цілі
set_target_properties(${PROJECT_NAME}
    PROPERTIES
        OUTPUT_NAME "myapp"   # файл називатиметься myapp, а не MyProject
        DEBUG_POSTFIX "_d"    # Debug-версія отримає суфікс: myapp_d
)

# Попередження компілятора (функція з модуля cmake/CompilerWarnings.cmake)
set_project_warnings(${PROJECT_NAME})

Три моменти заслуговують на увагу:

  • CMAKE_CURRENT_SOURCE_DIR і PROJECT_SOURCE_DIR. Перша вказує на папку цього CMakeLists.txt (src/), друга на корінь проєкту. Тому публічний include/ береться від кореня.
  • set_target_properties. Змінює властивості конкретної цілі. OUTPUT_NAME задає ім'я файлу, відмінне від імені цілі, а DEBUG_POSTFIX додає суфікс до Debug-збірки, щоб Debug і Release можна було тримати в одній папці.
  • Ім'я цілі ${PROJECT_NAME}. Тут воно збігається з назвою проєкту. Фактичний виконуваний файл при цьому називається myapp_d (див. вивід вище).
Кожна підпапка отримує копію змінних батьківського файлу: PROJECT_NAME, CMAKE_CXX_STANDARD та інші. Тому в src/CMakeLists.txt не потрібно повторно викликати project() або встановлювати стандарт C++.

libs/mylib1/CMakeLists.txt: внутрішня бібліотека

# Файли бібліотеки
set(LIB_SOURCES
    src/feature1.cpp
    src/feature2.cpp
)

set(LIB_HEADERS
    include/mylib1/feature1.h
    include/mylib1/feature2.h
)

# Створення бібліотеки
add_library(MyLib1 ${LIB_SOURCES} ${LIB_HEADERS})

# Псевдонім у стилі Проєкт::Ціль
add_library(MyProject::MyLib1 ALIAS MyLib1)

# Include-папки
target_include_directories(MyLib1
    PUBLIC
        $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
        $<INSTALL_INTERFACE:include>
    PRIVATE
        ${CMAKE_CURRENT_SOURCE_DIR}/src
)

# Версія бібліотеки (діє для SHARED)
set_target_properties(MyLib1
    PROPERTIES
        VERSION ${PROJECT_VERSION}
        SOVERSION ${PROJECT_VERSION_MAJOR}
)

Вирази $<BUILD_INTERFACE:...> і $<INSTALL_INTERFACE:...> потрібні, бо шлях до заголовків різний для збірки та після встановлення. Під час збірки це папка в дереві джерел (libs/mylib1/include), а після install заголовки лежать у <префікс>/include. Вираз вибирає правильний варіант залежно від ситуації.

Структура libs/mylib1/include/mylib1/feature1.h (ім'я бібліотеки повторюється в шляху) означає, що користувач пише #include <mylib1/feature1.h>. Завдяки цьому заголовки різних бібліотек не конфліктують між собою, навіть якщо називаються однаково.

VERSION і SOVERSION стосуються лише динамічних бібліотек: вони задають суфікси libMyLib1.so.1.2.3 та libMyLib1.so.1. Для статичних бібліотек ці властивості безпечно ігноруються.

Власні модулі CMake

Налаштування, які повторюються між цілями (попередження, санітайзери), виносять у файли .cmake у папці cmake/ і підключають командою include().

cmake/CompilerWarnings.cmake

function(set_project_warnings target)
    set(MSVC_WARNINGS
        /W4           # Рівень попереджень 4
        /permissive-  # Строга відповідність стандарту
    )

    set(CLANG_WARNINGS
        -Wall
        -Wextra
        -Wpedantic
        -Wshadow               # Змінна затіняє іншу
        -Wnon-virtual-dtor     # Базовий клас без віртуального деструктора
        -Wold-style-cast       # Приведення типів у стилі C
        -Wcast-align
        -Wunused
        -Woverloaded-virtual
        -Wconversion           # Неявні перетворення зі втратою даних
        -Wsign-conversion
    )

    set(GCC_WARNINGS
        ${CLANG_WARNINGS}
        -Wmisleading-indentation
        -Wduplicated-cond
        -Wlogical-op
    )

    if(MSVC)
        set(PROJECT_WARNINGS ${MSVC_WARNINGS})
    elseif(CMAKE_CXX_COMPILER_ID MATCHES ".*Clang")
        set(PROJECT_WARNINGS ${CLANG_WARNINGS})
    elseif(CMAKE_CXX_COMPILER_ID STREQUAL "GNU")
        set(PROJECT_WARNINGS ${GCC_WARNINGS})
    endif()

    target_compile_options(${target} PRIVATE ${PROJECT_WARNINGS})
endfunction()
У вихідному варіанті цього модуля до списків додавалися також /WX та -Werror, які перетворюють попередження на помилки. Тут їх прибрано: у навчальному коді нове попередження не повинно блокувати збірку. Якщо потрібна жорстка перевірка, увімкніть -Werror лише у CI за допомогою окремої опції.

Підключення модуля

cmake_minimum_required(VERSION 3.15)
project(MyProject)

# Додаємо шлях до власних модулів
list(APPEND CMAKE_MODULE_PATH "${CMAKE_SOURCE_DIR}/cmake")

# Підключаємо модуль: CMake шукає CompilerWarnings.cmake
include(CompilerWarnings)

add_executable(MyApp main.cpp)

# Використовуємо функцію з модуля
set_project_warnings(MyApp)

Команда include(Ім'я) шукає файл Ім'я.cmake у шляхах з CMAKE_MODULE_PATH і виконує його. Після цього функція set_project_warnings доступна як звичайна команда. Тепер списки попереджень змінюються в одному місці, а всі цілі проєкту отримують однакові правила.

Типові кандидати на власні модулі: попередження компілятора, санітайзери (-fsanitize=address), підключення статичного аналізу (clang-tidy), пошук залежностей.

Попередньо скомпільовані заголовки

Кожний .cpp, який підключає <iostream>, <vector> або <string>, змушує компілятор заново розбирати тисячі рядків цих заголовків. У великому проєкті це основна частина часу компіляції. Попередньо скомпільований заголовок розбирається один раз, а результат повторно використовується:

# PCH для великих проєктів
target_precompile_headers(MyApp
    PRIVATE
        <iostream>
        <string>
        <vector>
        <memory>
)

Команда з'явилась у CMake 3.16, тому проєкт, що її використовує, має вимагати cmake_minimum_required(VERSION 3.16) або новішу версію. Вона сама створює службовий заголовок і вмикає потрібні прапорці для кожного компілятора. Складати його вручну не потрібно.

PCH дає помітний виграш там, де десятки або сотні файлів повторно підключають однаковий набір великих заголовків. У проєкті з трьома файлами різниці не буде. Вкладайте в PCH лише стабільні заголовки (стандартна бібліотека, зовнішні бібліотеки): зміна PCH-заголовка викликає перекомпіляцію всього проєкту.

Встановлення проєкту: install

Після збірки результати можна скопіювати у цільову папку, звідки програма запускається системно або бібліотека підключається іншими проєктами:

# Виконуваний файл
install(TARGETS MyApp
    RUNTIME DESTINATION bin
)

# Бібліотеки
install(TARGETS MyLib1 MyLib2
    LIBRARY DESTINATION lib
    ARCHIVE DESTINATION lib
)

# Заголовкові файли
install(DIRECTORY include/
    DESTINATION include
)

# Інші файли
install(FILES README.md LICENSE
    DESTINATION share/doc/MyProject
)

Слова RUNTIME, LIBRARY, ARCHIVE — це види артефактів. До RUNTIME належать виконувані файли та .dll, до LIBRARY динамічні бібліотеки .so/.dylib, до ARCHIVE статичні бібліотеки .a/.lib. Для кожного виду задається своя папка призначення.

Запуск установки (проєкт має бути вже зібраний):

# Встановити в системну папку (потрібні права адміністратора)
cmake --install build

# Встановити у власну папку
cmake --install build --prefix /custom/path

На прикладі нашого проєкту з префіксом out:

cmake --install build --prefix out
$ cmake --install build --prefix out
-- Install configuration: "Debug"
-- Installing: out/lib/libMyLib1.a
-- Installing: out/include
-- Installing: out/include/mylib1
-- Installing: out/include/mylib1/feature1.h
-- Installing: out/include/mylib1/feature2.h
-- Installing: out/lib/libMyLib2.a
-- Installing: out/bin/myapp_d

Відносні шляхи DESTINATION додаються до префікса (CMAKE_INSTALL_PREFIX), за замовчуванням /usr/local на Unix. Для розробки зручно використовувати власний префікс і не чіпати системні папки.

Замість жорстко прописаних bin, lib, include у зрілих проєктах використовують модуль GNUInstallDirs і змінні ${CMAKE_INSTALL_BINDIR}, ${CMAKE_INSTALL_LIBDIR}, ${CMAKE_INSTALL_INCLUDEDIR}. Вони враховують відмінності між дистрибутивами (наприклад, lib64).

Питання для самоперевірки

Практичне завдання

Резюме

🗂️ Структура

include/, src/, libs/, external/, cmake/, tests/, examples/, docs/. build/ не потрапляє в git.

🌲 Ієрархія файлів

Кореневий CMakeLists.txt задає загальне і підключає підпапки через add_subdirectory. Кожна підпапка описує свої цілі.

🧩 Модулі CMake

Повторювані налаштування виносяться в cmake/*.cmake і підключаються include().

⚡ PCH та install

target_precompile_headers прискорює компіляцію, install і cmake --install розкладають результат у цільову папку.
Далі перевіримо, що проєкт працює правильно: підключимо до збірки тести за допомогою CTest, Google Test і виміряємо покриття коду.
Copyright © 2026