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 собирает несколько независимых outputs (несколько «целей сборки», 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/ (то, что будут #include-ить другие targets). Всё, что реализация, кладём в 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("empty amount");
        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(). Мы сделали функции, которые можно проверять отдельно. Это как раз и подготавливает нас к tests target.

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 << "FAIL: " << name << " got=" << got
              << " expected=" << 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;
}

Почему это классно (в рамках сегодняшней темы): этот tests target использует ту же библиотеку, что и app. Если вы сломали budget::calc_balance, то «сломается» не только приложение, но и проверки. И вы узнаете об этом быстро (при запуске tests), а не когда пользователь напишет вам в 3 ночи «у меня баланс улетел в космос».

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/, и любой, кто линкуется с 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
Задача
C++ SELF, 32 уровень, 4 лекция
Недоступна
Коробка приветствий
Коробка приветствий
1
Задача
C++ SELF, 32 уровень, 4 лекция
Недоступна
Ограничитель значений
Ограничитель значений
1
Задача
C++ SELF, 32 уровень, 4 лекция
Недоступна
Сумма строки
Сумма строки
1
Задача
C++ SELF, 32 уровень, 4 лекция
Недоступна
Температурный набор
Температурный набор
1
Опрос
CMake Presets, 32 уровень, 4 лекция
Недоступен
CMake Presets
CMake Presets
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ