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 (executable)"]
S["sources: main.cpp, sum.cpp"] --> T
F["compile features: cxx_std_23"] --> T
D["compile definitions: APP_NAME, APP_VERSION"] --> T
O["compile options: -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 ...) — это нормально. Страшно не то, что «много строк», а то, что «настройки спрятаны неизвестно где».
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ