1. Навіщо потрібен multi-target проєкт
Якщо ви лише почали програмувати, дуже легко міркувати так: «у мене є проєкт — отже, є один main.cpp — отже, є один exe». Це нормально: мозок економить зусилля. Але далі починається доросле життя: той самий код хочеться використовувати і в застосунку, і в перевірках, а інколи — ще й у кількох утилітах. І саме тут multi-target — це не «складність заради складності», а спосіб знову не наступати на ті самі граблі.
Під multi-target проєктом будемо розуміти проєкт, у якому CMake збирає кілька незалежних артефактів збирання (кілька «цілей збирання», targets). Наприклад: бібліотеку зі спільною логікою, основний застосунок і окремий виконуваний файл із перевірками. Важливо: це один репозиторій, але не один бінарний файл.
Схематично це виглядає так:
flowchart LR
core["lib: core (спільна логіка)"]
app["exe: app (застосунок)"]
tests["exe: tests (перевірки)"]
app --> core
tests --> core
Зверніть увагу на напрямок стрілок: виконувані файли залежать від бібліотеки, а не навпаки. Це одне з правил, яке робить проєкт читабельним і не перетворює CMake на клубок макаронів.
2. Базова структура проєкту: ролі й папки
Ролі lib, app, tests
Коли ви чуєте «бібліотека», легко уявити собі щось величне й трохи нудне. Але в CMake бібліотека — це лише target, який збирає спільний код у модуль, що можна використовувати повторно. У межах навчального проєкту це зазвичай найрозумніший спосіб перестати копіювати той самий код у різні файли.
Чітко зафіксуймо ролі — без магії та пафосу:
| Роль | Що це | Що в ній є | Що НЕ повинно в ній бути |
|---|---|---|---|
| lib (наприклад core) | add_library(...) | функції, моделі, алгоритми, парсинг, обчислення | main(), діалог із користувачем, «друк меню» |
| app | add_executable(...) | тонкий main(), введення/виведення, сценарій запуску | реалізація бізнес-логіки «прямо в main» |
| tests | add_executable(...) | перевірки функцій і модулів бібліотеки | залежність від app та копіювання логіки з lib |
Чому tests — це теж «просто executable»? Тому що на цьому етапі курсу ми ще не підключаємо тестові фреймворки та іншу інфраструктуру тестування. Нам важлива головна ідея: перевірки — це окрема програма, яку можна запустити самостійно і яка повертає код завершення 0 (успіх) або != 0 (помилка).
Структура папок: API окремо від реалізації та точок входу
Коли проєкт маленький, файли можна складати «як завгодно» — і це навіть працюватиме. Але варто додати другий target, і раптом зʼясовується, що «як завгодно» — це архітектурний стиль під назвою «а чому воно вчора збиралося?». Тому зараз ми виберемо просту структуру: не ідеальну на всі випадки життя, зате дуже зрозумілу для новачків.
Запропонована структура — мінімальна й навчальна:
budget-tracker/
CMakeLists.txt
include/
budget/
budget.hpp
src/
budget.cpp
app/
main.cpp
tests/
tests_main.cpp
Ідея проста: усе, що публічне для бібліотеки, кладемо в include/ (тобто те, що інші targets підключатимуть через #include). Усе, що є реалізацією, кладемо в src/. Точки входу — в app/ і tests/. Це добре допомагає: відкрили папку — і одразу розумієте, де тут API, а де — внутрішня реалізація.
3. Приклад: робимо lib, app і tests
Зараз ми почнемо збирати мініпроєкт: Budget Tracker (консольний трекер балансу). Він буде дуже простим: ми не робимо бухгалтерську систему, а вчимося будувати структуру збирання. Бібліотека зможе парсити число з рядка й рахувати підсумковий баланс за операціями.
Публічний API: include/budget/budget.hpp
Важливо: заголовок має бути максимально «чистим» — лише оголошення, мінімум залежностей, зрозумілі імена. І обовʼязково #pragma once (або include guards — ви їх уже знаєте).
// include/budget/budget.hpp
#pragma once
#include <string>
#include <vector>
namespace budget {
int parse_amount(const std::string& s);
int calc_balance(const std::vector<int>& ops);
}
Тут немає ані main(), ані введення/виведення, ані «меню». Це чисті функції, які можна використовувати і в застосунку, і в перевірках.
Реалізація: src/budget.cpp
У реалізації можна дозволити собі більше: підключати все потрібне й писати сам код. Але тримаймо приклад коротким і зрозумілим.
// src/budget.cpp
#include "budget/budget.hpp"
#include <numeric> // std::accumulate
#include <stdexcept> // std::invalid_argument
namespace budget {
int parse_amount(const std::string& s) {
if (s.empty()) throw std::invalid_argument("порожня сума");
return std::stoi(s);
}
}
Окремо реалізуємо обчислення балансу:
// src/budget.cpp (продовження)
namespace budget {
int calc_balance(const std::vector<int>& ops) {
return std::accumulate(ops.begin(), ops.end(), 0);
}
}
Зверніть увагу на важливу «психологічну» деталь: ми не пишемо перевірки в main(). Натомість ми створюємо функції, які можна перевіряти окремо. Саме це і готує нас до target tests.
app: тонкий main() як сценарій
Коли ви тільки починаєте, main() часто перетворюється на «кухонний стіл»: тут і введення, і обчислення, і зберігання даних, і друк, і парсинг, і ще трохи відчаю. Multi-target структура буквально змушує робити main() тонким — і це корисний різновид самодисципліни, бо він окупається читабельністю.
Зробімо app/main.cpp, який демонструє роботу нашої бібліотеки. Нехай він читає кілька рядків з операціями, доки не зустріне "stop", і виводить баланс.
// app/main.cpp
#include "budget/budget.hpp"
#include <iostream>
#include <string>
#include <vector>
int main() {
std::vector<int> ops;
std::string s;
while (std::cin >> s && s != "stop") {
ops.push_back(budget::parse_amount(s));
}
std::cout << budget::calc_balance(ops) << '\n'; // наприклад: 15
}
Тут можуть виникнути питання щодо обробки помилок (stoi може викинути виняток). Але сьогодні наша тема — структура targets, а не політика роботи з помилками. Поки що нам достатньо побачити головне: app використовує бібліотеку через #include і виклики функцій.
tests: окремий executable, який перевіряє бібліотеку
Перевірки часто намагаються «прикрутити збоку»: хтось пише if (debug) ... усередині застосунку, хтось додає в меню спеціальну команду «перевірити все». Це шлях до того, що перевірки перестають запускатися регулярно й поступово занепадають. Значно корисніше — винести їх в окремий виконуваний файл: він невеликий, прямолінійний і не привʼязаний до UX застосунку.
Ми зробимо tests/tests_main.cpp. Він викликатиме функції бібліотеки й порівнюватиме отримане та очікуване. Якщо щось не збіглося, друкуємо повідомлення в std::cerr і повертаємо 1.
Спочатку — маленький помічник check_eq:
// tests/tests_main.cpp
#include <iostream>
#include <string>
bool check_eq(const std::string& name, int got, int expected) {
if (got == expected) return true;
std::cerr << "ПОМИЛКА: " << name << " отримано=" << got
<< " очікувалося=" << expected << '\n';
return false;
}
Тепер — сам main() тестів:
// tests/tests_main.cpp (продовження)
#include "budget/budget.hpp"
#include <vector>
int main() {
bool ok = true;
ok = ok && check_eq("parse_amount", budget::parse_amount("42"), 42);
ok = ok && check_eq("calc_balance", budget::calc_balance({10, -3, 8}), 15);
return ok ? 0 : 1;
}
Чим це добре в межах сьогоднішньої теми: target tests використовує ту саму бібліотеку, що й app. Якщо ви зламаєте budget::calc_balance, то «зламається» не лише застосунок, а й перевірки. І ви дізнаєтеся про це швидко — під час запуску tests, а не тоді, коли користувач напише вам о третій ночі: «у мене баланс полетів у космос».
4. CMake: звʼязуємо lib, app, tests
Тепер звʼяжемо все це в CMakeLists.txt. Важливо: ми не робимо «гарний CMake на 200 рядків». Ми робимо прозорий CMake: такий, щоб навіть людина, яка працює з ним лише другий день, могла з першого погляду зрозуміти, що саме збирається і від чого залежить.
Мінімальний «скелет» проєкту
Почнімо з базових речей. Зверніть увагу: я навмисно пишу коротко.
cmake_minimum_required(VERSION 3.23)
project(budget_tracker LANGUAGES CXX)
add_library(budget_core src/budget.cpp)
target_include_directories(budget_core PUBLIC include)
target_compile_features(budget_core PUBLIC cxx_std_23)
Тут ключовий рядок — target_include_directories(budget_core PUBLIC include). Він означає: «заголовки бібліотеки лежать в include/, і будь-який target, що лінкується з budget_core, має мати змогу їх підключати». Це прямий прояв ідеї транзитивності.
Додаємо app
Тепер — застосунок. Він залежить від бібліотеки, отже ми лінкуємо його з нею.
add_executable(budget_app app/main.cpp)
target_link_libraries(budget_app PRIVATE budget_core)
Значення PRIVATE тут просте: budget_app використовує бібліотеку, але ніхто не має автоматично успадковувати залежності «через budget_app». Загалом для executable варіант PRIVATE майже завжди є логічним.
Додаємо tests
І тести — ще один executable, який теж лінкується з бібліотекою.
add_executable(budget_tests tests/tests_main.cpp)
target_link_libraries(budget_tests PRIVATE budget_core)
І тепер ми маємо саме те, заради чого й була ця лекція: три targets, дві стрілки залежностей і жодної каші.
Схема targets для самоперевірки
Щоб не тримати все в голові, корисно іноді намалювати собі просту табличку:
| Target | Тип | Залежності | Призначення |
|---|---|---|---|
| budget_core | library | — | спільна логіка |
| budget_app | executable | budget_core | реальний застосунок |
| budget_tests | executable | budget_core | перевірки логіки |
Якщо ви бачите, що budget_core раптом залежить від budget_app, це означає, що десь почалася архітектурна містика. Зазвичай усе закінчується плачем лінковника.
5. Multi-target і режими Debug/Release
Після попередніх лекцій важливо скласти цілісну картину: multi-target — це структура проєкту, а Debug/Release та пресети — це спосіб стабільно збирати цю структуру в різних режимах. Тобто маємо два виміри: «скільки цілей збирання» і «у якій конфігурації ми їх збираємо».
Якщо у вас є, наприклад, дві build-папки (або два пресети), то в кожній із них будуть зібрані усі targets, але з різними налаштуваннями:
build-debug/ -> budget_core + budget_app + budget_tests (Debug)
build-release/ -> budget_core + budget_app + budget_tests (Release)
І тут стається приємна річ: якщо ви хочете перевірити, що «в Release теж усе нормально», вам не потрібно переписувати проєкт або «перемикати якийсь прапорець у IDE навмання». Ви просто збираєте іншим пресетом або в іншій build-папці — і запускаєте budget_tests. Психологічно це помітно зменшує шанс опинитися у світі «в Debug працювало».
6. Типові помилки під час проєктування multi-target структури
Помилка № 1: уся логіка живе в main.cpp, а бібліотека порожня або взагалі відсутня.
Так стається, коли «треба швидше зробити, а потім винесу». Зазвичай це «потім» не настає. Щойно вам знадобиться другий executable (перевірки або утиліта), доведеться або копіювати код, або робити дивні #include на кшталт "../app/main.cpp" (так, так теж іноді роблять — і так, це боляче). Рішення просте: бізнес-логіка й корисні функції мають жити в бібліотеці, а main() має залишатися сценарієм.
Помилка № 2: tests залежить від app, бо «там же вже все є».
Це дуже поширена пастка: ви написали функцію прямо в app/main.cpp, тестам вона потрібна, і ви починаєте додавати app у залежності. У підсумку тести починають залежати від UI-коду, введення/виведення і взагалі від усього підряд. Правильний напрямок залежностей майже завжди такий: tests → lib, app → lib. Якщо тестам потрібен код, то цьому коду місце в lib.
Помилка № 3: забули target_include_directories(core PUBLIC include) і почали лагодити #include дивними шляхами.
Новачок бачить помилку «не знайдено заголовок» і намагається виправити її хаками: писати #include як "../../include/budget/budget.hpp" або вручну додавати include-директорії в кожен executable. Це працює рівно до першого рефакторингу. Нормальний шлях інший: бібліотека має оголосити, де лежать її публічні заголовки, і зробити це через PUBLIC, щоб споживачі автоматично отримали include-шляхи.
Помилка № 4: змішали «файлову структуру» і «структуру збирання» та чекають магії.
Папки src/, app/, tests/ — це лише зручність для людей. CMake не зобовʼязаний «здогадатися», що папка tests/ — це тести. Якщо target не створено (немає add_executable(budget_tests ...)), то жоден tests/ сам собою не зʼявиться в збиранні. Тому тримайте в голові дві окремі карти: карту файлів і карту targets. Збіг між ними приємний, але не гарантований.
Помилка № 5: намагаються «для простоти» зробити один гігантський target і 20 #ifdef.
Це справді виглядає спокусливо: «нехай tests будуть просто режимом застосунку». Потім зʼясовується, що макроси починають впливати на логіку, різні конфігурації збирають різні програми, а поведінка «раптом відрізняється». Multi-target якраз і потрібен для того, щоб не перетворювати збирання на гру «вгадай активний #define».
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ