JavaRush /Курси /C++ SELF /Target‑підхід у CMake: властивості target

Target‑підхід у CMake: властивості target

C++ SELF
Рівень 31 , Лекція 2
Відкрита

1. Від глобальних прапорців до target‑підходу

Якщо ви раніше не працювали з реальними проєктами, то «глобальні прапорці» звучать привабливо: один раз написали set(...) — і все навколо нібито стало кращим, красивішим і швидшим. На практиці це частіше схоже на спробу одним вентилем керувати всією системою опалення багатоквартирного будинку, де кожен сусід любить свою температуру. Підсумок зазвичай один: комусь спекотно, комусь холодно, а винен, звісно ж, CMake.

У новачків у CMake часто зʼявляється спокуса писати так:

# ПОГАНО (для нашої дисципліни target‑підходу)
set(CMAKE_CXX_STANDARD 23)
add_definitions(-DDEBUG)
set(CMAKE_CXX_FLAGS "-Wall -Wextra")
include_directories(include)

Виглядає компактно. Але проблема в тому, що такі налаштування «розмазуються» по проєкту й із часом починають поводитися як невидима магія: ви вже не певні, хто саме отримав прапорець -Wall, чому якийсь файл раптом побачив заголовок і чому один target збирається, а інший — ламається.

Щоб відчути цей біль, достатньо уявити, що в проєкті зʼявилися:

  • два застосунки (наприклад, app і tool);
  • бібліотека (наприклад, core);
  • тести (вони зʼявляться пізніше в курсі, але ця думка корисна вже зараз).

І раптом зʼясовується: застосунку потрібні одні попередження й макроси, бібліотеці — інші, а тести взагалі хочуть жити власним життям.

У target‑підході ми розвʼязуємо це радикально: кожне налаштування привʼязуємо до конкретної цілі. Тоді CMake‑файл починає читатися не як «магічний набір глобальних заклять», а як звичайний опис: ось ціль, ось її вихідні файли, ось її вимоги й правила збирання.

Що таке target у CMake

Слово «target» у CMake спершу часто сприймається як «імʼя майбутнього бінарника». Частково це правда, але таке пояснення надто спрощене. Набагато корисніше думати так: target — це обʼєкт із властивостями. У нього є імʼя, список вихідних файлів, а ще ціла «папка» налаштувань: стандарт мови, макроси препроцесора, опції компіляції і, пізніше, include‑шляхи та залежності лінкування.

У цій лекції зафіксуємо головне правило:

У стилі target‑підходу ми намагаємося не писати «глобальні прапорці для всього проєкту».
Ми пишемо «властивості для конкретного target».

Тобто замість «десь угорі задати все для всіх» ми робимо так: «ось target app, і поруч із ним акуратно перелічено його вимоги».

На рівні синтаксису це виражається в родині команд target_*, наприклад:

  • target_compile_features(...) — вимоги до можливостей компілятора, зокрема до стандарту мови;
  • target_compile_definitions(...) — макроси препроцесора;
  • target_compile_options(...) — опції компілятора.

Сьогодні зосередимося саме на цих трьох командах: вони найкраще показують сенс target‑підходу й водночас іще не вимагають обговорювати заголовкові шляхи та лінкування.

2. Міні‑проєкт MiniCalc

Структура проєкту

Щоб не говорити абстрактно, розвиватимемо невеликий консольний застосунок. Нехай він називається MiniCalc і вміє додавати числа через окрему функцію. Застосунок простий, але він ідеально підходить, щоб на практиці відчути налаштування збирання: стандарт мови, макроси та попередження.

Структура на цьому етапі може бути такою:

MiniCalc/
  CMakeLists.txt
  src/
    main.cpp
    sum.cpp
    sum.hpp

Зверніть увагу: поки що ми тримаємо sum.hpp поруч із .cpp у папці src/, щоб завчасно не занурюватися в тему include‑каталогів. Перенесення заголовків у include/ — це окрема тема наступної лекції.

Вихідні файли

Код буде максимально простим.

src/sum.hpp:

#pragma once

int sum(int a, int b);

src/sum.cpp:

#include "sum.hpp"

int sum(int a, int b) {
    return a + b;
}

src/main.cpp:

#include <iostream>
#include "sum.hpp"

int main() {
    std::cout << "2 + 3 = " << sum(2, 3) << '\n'; // 2 + 3 = 5
    return 0;
}

Збирання почнемо з мінімального CMakeLists.txt, а потім поступово поліпшуватимемо його, суворо дотримуючись target‑стилю.

3. Налаштування цілі через target_*

Стандарт мови через target_compile_features

Коли проєкт маленький, здається, що стандарт мови можна «один раз задати зверху» і забути. Але це саме той випадок, коли сьогоднішня зручність перетворюється на завтрашню загадку. Target‑підхід пропонує простий принцип: стандарт — це вимога конкретної цілі, бо саме ціль компілюється, а не «проєкт узагалі».

Мінімальний CMakeLists.txt у target‑стилі може виглядати так:

cmake_minimum_required(VERSION 3.20)
project(MiniCalc LANGUAGES CXX)

add_executable(app
    src/main.cpp
    src/sum.cpp
)

target_compile_features(app PRIVATE cxx_std_23)

Тут важливо одразу пояснити зміст цих рядків без «магії».

add_executable(app ...) створює target app. Це наш майбутній виконуваний файл.

target_compile_features(app PRIVATE cxx_std_23) каже: «Щоб зібрати app, компілятор має підтримувати C++23, і ми хочемо, щоб збирання відбувалося саме в цьому стандарті».

Ключове слово PRIVATE поки що сприймайте як «це потрібно лише самому target app». Тонкощі PUBLIC/INTERFACE особливо добре видно на include‑каталогах і залежностях, але ми вже зараз звикаємо до важливої думки: у вимог є видимість, а не просто режим «увімкнено/вимкнено».

Чому це краще, ніж set(CMAKE_CXX_STANDARD 23)? Тому що так ви чітко бачите, яка саме ціль потребує C++23. Якщо в проєкті зʼявиться друга ціль, тобто інший застосунок, ви зможете задати для неї іншу вимогу — усвідомлено, а не випадково.

Макроси збирання через target_compile_definitions

Макроси препроцесора — річ підступна. Вони схожі на «перемикачі режиму роботи програми», але насправді це перемикачі режиму компіляції, тобто вони змінюють те, який код узагалі потрапить у підсумковий бінарник. Тому тримати такі перемикачі «глобально для всіх» — майже завжди погана ідея: так надто легко зробити різні частини проєкту несумісними одна з одною.

У target‑підході макроси задаються так:

target_compile_definitions(app PRIVATE APP_NAME="MiniCalc")
target_compile_definitions(app PRIVATE APP_VERSION=1)

Давайте вбудуємо це в наш CMakeLists.txt:

cmake_minimum_required(VERSION 3.20)
project(MiniCalc LANGUAGES CXX)

add_executable(app
    src/main.cpp
    src/sum.cpp
)

target_compile_features(app PRIVATE cxx_std_23)
target_compile_definitions(app PRIVATE APP_NAME="MiniCalc")
target_compile_definitions(app PRIVATE APP_VERSION=1)

Тепер використаємо ці макроси в main.cpp. Важливо: якщо макрос не визначений, компіляція може зламатися. Тому передбачимо запасний варіант — значення за замовчуванням через #ifndef.

src/main.cpp:

#include <iostream>
#include "sum.hpp"

#ifndef APP_NAME
#define APP_NAME "(unknown)"
#endif

#ifndef APP_VERSION
#define APP_VERSION 0
#endif

int main() {
    std::cout << APP_NAME << " v" << APP_VERSION << '\n'; // MiniCalc v1
    std::cout << "2 + 3 = " << sum(2, 3) << '\n';         // 2 + 3 = 5
    return 0;
}

Тепер у нас зʼявляється хороший місток між збиранням і кодом: CMake задає «параметри збирання» через target_compile_definitions, а C++‑код акуратно на них реагує.

І тут важлива дисципліна: не перетворюйте макроси на заміну введенню користувача. Макроси — не заміна std::cin. Вони потрібні для конфігурації збирання, наприклад щоб увімкнути налагоджувальний лог, вибрати режим або «зашити» назву продукту, а не для сценарію «ввести число і порахувати».

Опції компілятора через target_compile_options

Попередження компілятора — це як зауваження викладача на полях: ігнорувати їх можна, але потім на іспиті буде боляче. У реальних проєктах попередження майже завжди вмикають, але роблять це усвідомлено й акуратно. І тут target‑підхід знову виграє: попередження — це властивість конкретної цілі, а не абстрактного «збирання взагалі».

Найпростіший варіант:

target_compile_options(app PRIVATE -Wall -Wextra)

Але є нюанс: прапорці залежать від компілятора. GCC/Clang розуміють -Wall, MSVC — ні, у MSVC свої /W4 та подібні параметри. Ми не заглиблюємося в кросплатформеність, але покажемо мінімально коректний варіант:

target_compile_options(app PRIVATE
    $<$<CXX_COMPILER_ID:MSVC>:/W4>
    $<$<NOT:$<CXX_COMPILER_ID:MSVC>>:-Wall -Wextra>
)

Це виглядає страшніше, ніж є насправді. Зараз важливо зрозуміти не синтаксис «кутових дужок», а саму ідею: опції привʼязані до target app.

Якщо ви хочете зовсім базовий варіант без генераторних виразів і готові прийняти, що на MSVC все буде інакше, можна почати так:

if (NOT MSVC)
    target_compile_options(app PRIVATE -Wall -Wextra)
endif()

У підсумку наш CMakeLists.txt стає таким:

cmake_minimum_required(VERSION 3.20)
project(MiniCalc LANGUAGES CXX)

add_executable(app
    src/main.cpp
    src/sum.cpp
)

target_compile_features(app PRIVATE cxx_std_23)
target_compile_definitions(app PRIVATE APP_NAME="MiniCalc" APP_VERSION=1)

if (NOT MSVC)
    target_compile_options(app PRIVATE -Wall -Wextra)
endif()

Зверніть увагу на характерний стиль target‑підходу: ви читаєте файл згори вниз і бачите, що app — це не просто «бінарник», а ціль із чітким набором вимог.

Куди приклеюються налаштування в target‑підході

Коли ви вперше чуєте про «властивості цілі», може здаватися, що це все ті самі прапорці, тільки названі іншими словами. Щоб це не лишилося магією, корисно намалювати просту схему: target — це центр, а команди target_* «навішують» на нього властивості. Так ви буквально бачите, що відбувається, і перестаєте сприймати CMake як шаманство.

Ось спрощена діаграма, без заголовків і лінкування, бо це теми окремих лекцій:

flowchart TD
    T["target: app (виконуваний файл)"]

    S["вихідні файли: main.cpp, sum.cpp"] --> T
    F["можливості компіляції: cxx_std_23"] --> T
    D["макроси компіляції: APP_NAME, APP_VERSION"] --> T
    O["параметри компілятора: -Wall, -Wextra"] --> T

І саме тут проявляється головний «психологічний» ефект target‑підходу: налаштування перестають бути «десь у повітрі» і стають частиною опису конкретної цілі.

Якщо пізніше у вас зʼявиться другий target, наприклад tool, ви не думатимете: «А які там глобальні прапорці зараз активні?» Ви просто відкриєте CMakeLists.txt і побачите: tool — ось такий, app — ось такий.

4. Типові помилки

Помилка № 1: і далі жити в стилі CMAKE_CXX_FLAGS і «глобальної магії».
Дуже легко почати красиво: створити add_executable, написати пару target_*, а потім «швидко накидати» set(CMAKE_CXX_FLAGS "..."), бо так десь написано в інтернеті. Зазвичай це призводить до того, що ви самі перестаєте розуміти, чому один target збирається з попередженнями, а інший раптом — ні, або чому нові прапорці ламають збирання на іншій машині.

Помилка № 2: плутати target_compile_features, target_compile_definitions і target_compile_options.
Новачки інколи сприймають це як «три способи задати одне й те саме». Насправді вони відповідають за різне: features — за вимоги до мови й компілятора, definitions — за макроси препроцесора, options — за прапорці компілятора. Якщо все це змішувати, CMakeLists.txt перетворюється на нечитабельний «мішок прапорців».

Помилка № 3: використовувати макроси збирання як заміну введенню даних.
Іноді хочеться зробити -DINPUT=5 і вважати, що це «параметр програми». Насправді це параметр компіляції, а не параметр запуску. Ви вбудовуєте число в бінарник, а не читаєте його з консолі. Таке рішення може бути виправдане для версії, назви або режиму логування, але погано підходить для «даних користувача».

Помилка № 4: увімкнути опції компілятора «для всіх і назавжди», не розуміючи ціни.
Навіть корисні прапорці можуть стати проблемою, якщо ввімкнути їх глобально: десь вони викличуть попередження, яке для одного target допустиме, а для іншого — зламає збирання. Target‑підхід якраз і вчить дозовано вмикати якість збирання: конкретній цілі — конкретні правила.

Помилка № 5: соромитися довгого CMakeLists.txt і намагатися будь-якою ціною скоротити рядки.
Новачку часто здається, що хороший CMakeLists.txt має бути коротким. На практиці хороший CMakeLists.txt має бути зрозумілим. Якщо для зрозумілості потрібні кілька додаткових рядків поруч із add_executable(app ...) — це нормально. Страшно не те, що «рядків багато», а те, що «налаштування сховані невідомо де».

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ