JavaRush /Курси /C++ SELF /Структура multi-target проєкту: app + lib + tests

Структура multi-target проєкту: app + lib + tests

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

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-коду, введення/виведення і взагалі від усього підряд. Правильний напрямок залежностей майже завжди такий: testslib, applib. Якщо тестам потрібен код, то цьому коду місце в 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».

1
Опитування
CMake Presets, рівень 32, лекція 4
Недоступний
CMake Presets
CMake Presets
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ