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 ...) — це нормально. Страшно не те, що «рядків багато», а те, що «налаштування сховані невідомо де».
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ