1. Зачем нужна линковка в CMake
Если компиляция — это момент, когда компилятор смотрит на один .cpp и говорит «да, этот файл логически корректен», то линковка — это момент, когда все кусочки проекта должны собраться в один исполняемый файл (или библиотеку). И вот тут начинаются истории в духе «я же #include сделал, почему не работает?!». Сегодня мы аккуратно разложим это по полочкам — без шаманства и без «ну просто добавь ещё одну галочку в IDE».
Представьте, что проект — это сериал. Каждый .cpp — отдельная серия, где герои упоминают друг друга. Заголовок (.hpp) — это как «список персонажей и кто как выглядит». Но чтобы сериал был настоящим, нужны сами серии (реализации в .cpp) и нужен «монтажёр», который соберёт всё в один сезон. Монтажёр — это линкер.
На уровне сборки обычно есть три большие категории проблем:
flowchart TD
A[Ошибка CMake] -->|неверная команда, target не создан| X[configure/generate упал]
B[Ошибка компиляции] -->|syntax/type errors| Y[compile упал]
C[Ошибка линковки] -->|undefined reference / unresolved external| Z[link упал]
Именно ошибки линковки чаще всего появляются, когда вы начинаете делать несколько target’ов (приложение + библиотеки). То есть как раз тогда, когда CMake становится реально полезным.
2. target_link_libraries: смысл команды
Команда target_link_libraries(consumer … dependency …) выглядит пугающе только первые пару раз. Если сказать по‑простому, она означает: «target consumer должен быть собран вместе с target’ом dependency, потому что ему нужны его определения (реализация)».
И это важно: речь не про заголовки и не про #include, а именно про реализацию — то, что линкер кладёт в итоговый бинарник.
С точки зрения «мозга сборки» у каждого target’а есть две части:
- то, что нужно скомпилировать (обычно список .cpp),
- то, что нужно подложить на линковке (другие библиотеки/target’ы).
target_link_libraries занимается второй частью.
Минимальный пример: «связать приложение с библиотекой»:
add_library(calc src/calc.cpp)
target_include_directories(calc PUBLIC include)
add_executable(app src/main.cpp)
target_link_libraries(app PRIVATE calc)
В этой картинке app использует функции из calc, поэтому на этапе линковки мы говорим: «app, возьми ещё и calc».
И очень важная привычка: в современном CMake мы линкуемся с target’ом (именем цели), а не с путями к файлам. То есть calc, а не libcalc.a и не calc.cpp. Это и есть target‑подход: зависимость — это не «какой-то файл», а «цель со свойствами».
3. Заголовок ≠ реализация
Давайте мягко проживём типичную ситуацию новичка. Вы написали заголовок, подключили его в main.cpp, всё красиво, компилятор доволен… и вдруг на линковке вас встречает сообщение в стиле undefined reference. Тут обычно хочется закричать: «Но я же включил файл!».
Важный разрыв в голове такой: #include вставляет текст заголовка в .cpp на этапе препроцессинга. Он помогает компилятору увидеть объявления (declarations): «такая функция существует где-то». Но #include не добавляет определение (definition) в сборку автоматически.
Определение живёт в .cpp (или в библиотеке), и оно должно попасть в target, иначе линкер просто не найдёт тело функции.
Соберём маленький учебный проект, который будем развивать в примерах.
Файлы проекта
MiniCalc/
CMakeLists.txt
include/
calc.hpp
cli.hpp
src/
calc.cpp
cli.cpp
main.cpp
Заголовок с объявлением
// include/calc.hpp
#pragma once
int add(int a, int b);
Реализация
// src/calc.cpp
#include "calc.hpp"
int add(int a, int b) {
return a + b;
}
Использование в main
// src/main.cpp
#include <iostream>
#include "calc.hpp"
int main() {
std::cout << add(2, 3) << '\n'; // 5
}
Если вы забудете добавить src/calc.cpp в сборку (или забудете залинковать библиотеку, которая его содержит), компиляция main.cpp пройдёт: компилятор видел int add(int,int);. Но линковка упадёт: телу add неоткуда взяться.
Это напрямую связано с тем, что в C++ вообще существует понятие linkage (как имена связываются между единицами трансляции).
4. Видимость зависимостей: PRIVATE, PUBLIC, INTERFACE
Когда вы добавляете зависимость, следующий вопрос: кто ещё должен о ней знать? Только сама библиотека? Или и тот, кто будет эту библиотеку использовать? Вот тут и появляются три слова, которые поначалу выглядят как заклинания: PRIVATE, PUBLIC, INTERFACE.
Сразу договоримся о человеческой трактовке: это не про «безопасность» и не про private/public в C++ классах. Это про распространение требований сборки по графу зависимостей target’ов.
Удобно держать в голове табличку:
| Ключевое слово | Что означает для consumer -> dependency |
|---|---|
|
зависимость нужна только чтобы собрать сам consumer, дальше не «передаём» |
|
зависимость нужна и consumer, и всем, кто будет использовать consumer |
|
самому consumer зависимость не нужна (или он header-only), но потребителям она нужна |
Для target_link_libraries это читается так: «кому нужно линковаться (или получить требования линковки)».
Пример:
target_link_libraries(app PRIVATE calc)
Это значит: app линкуется с calc, но дальше (если кто-то вдруг будет линковаться с app) эта зависимость не обязана «ехать» транзитивно.
Для executable’ов это часто и нормально: приложение обычно никто не «подключает как библиотеку». Но для библиотек это становится критично, потому что библиотеки как раз и существуют, чтобы их использовали.
5. Транзитивность на практике
Транзитивность — это страшное слово, за которым прячется очень простая мысль: если A зависит от B, а B зависит от C, то иногда A вынужден зависеть от C, даже если вы это не написали явно. CMake может сделать это за вас — но только если вы правильно отметили видимость зависимости.
Схема зависимостей
Мы хотим собрать приложение app, которое использует библиотеку cli, а cli внутри использует calc.
flowchart LR
app[app executable] --> cli[cli library]
cli --> calc[calc library]
Теперь вопрос: должен ли app явно линковаться с calc?
Ответ: зависит от того, что именно cli обещает в своём публичном интерфейсе и как именно происходит линковка (особенно для статических библиотек). На нашем уровне достаточно запомнить практическое правило: если cli использует calc, то чаще всего cli должна линковаться с calc и правильно передать эту зависимость, чтобы потребителю не приходилось угадывать.
Практический пример: app + cli + calc
Сейчас мы соберём связанный пример, где app вообще не включает calc.hpp. Он знает только про cli.hpp, а cli уже внутри пользуется calc. Это классический мини‑сюжет «обёртка над библиотекой» — и идеальная площадка, чтобы почувствовать транзитивность.
Заголовок cli.hpp
// include/cli.hpp
#pragma once
// cli обещает функцию "посчитать и красиво вывести"
void print_sum(int a, int b);
Реализация cli.cpp (использует calc)
// src/cli.cpp
#include <iostream>
#include "cli.hpp"
#include "calc.hpp"
void print_sum(int a, int b) {
std::cout << a << " + " << b << " = " << add(a, b) << '\n';
// 2 + 3 = 5 (пример вывода)
}
main.cpp знает только про cli
// src/main.cpp
#include "cli.hpp"
int main() {
print_sum(2, 3); // 2 + 3 = 5
}
CMake: создаём две библиотеки и приложение
cmake_minimum_required(VERSION 3.20)
project(MiniCalc LANGUAGES CXX)
add_library(calc src/calc.cpp)
target_include_directories(calc PUBLIC include)
target_compile_features(calc PRIVATE cxx_std_23)
add_library(cli src/cli.cpp)
target_include_directories(cli PUBLIC include)
target_compile_features(cli PRIVATE cxx_std_23)
# ВАЖНО: cli использует calc, поэтому объявляем зависимость
target_link_libraries(cli PUBLIC calc)
add_executable(app src/main.cpp)
target_compile_features(app PRIVATE cxx_std_23)
# app линкуется только с cli
target_link_libraries(app PRIVATE cli)
Почему здесь PUBLIC, а не PRIVATE?
Потому что мы хотим, чтобы cli был «нормальной» библиотекой, которую можно подключить одной строчкой. Потребитель (app) не должен гадать, что там внутри ещё нужна calc. При PUBLIC CMake видит граф зависимостей и умеет протащить требования дальше.
Если сделать так:
target_link_libraries(cli PRIVATE calc)
то проект может начать вести себя непредсказуемо: иногда соберётся, иногда нет, а иногда — сломается в самый неподходящий момент (особенно когда появятся дополнительные зависимости или изменится тип библиотеки).
На старте курса нам важнее предсказуемость: «подключил cli — работает».
Как выбрать PRIVATE или PUBLIC
Самая частая ошибка новичка — делать всё PUBLIC, «чтобы точно работало». Оно действительно «работает», но вы незаметно превращаете проект в ком зависимостей: всё отовсюду тянется, сборка становится тяжелее, а любая перестановка ломает половину проекта.
Практический критерий такой: если зависимость нужна для публичного интерфейса библиотеки, тогда она PUBLIC. Если зависимость нужна только внутри реализации (.cpp), и потребителю она не должна быть обязательна, тогда PRIVATE.
Как это понять без философии:
- Если ваш заголовок (include/ваша_библиотека.hpp) в явном виде требует типы/символы из другой библиотеки (например, вы возвращаете тип из чужой библиотеки, принимаете его параметром, или ваш заголовок вынужден включать чужой заголовок), то зависимость часто становится частью интерфейса. Тогда логика «пусть потребители тоже знают про это» оправдана, и PUBLIC имеет смысл.
- Если же в заголовках вы показываете только свои типы и свои функции, а внутри .cpp вы используете чужую библиотеку как «внутренний инструмент», то потребитель не обязан о ней знать. Тогда PRIVATE делает зависимость локальной и аккуратной.
В нашем примере cli.hpp не включает calc.hpp и не «светит» add напрямую. Но cli всё равно при линковке должен донести, что ему нужна calc, иначе потребитель может неожиданно получить проблемы на этапе линковки. Поэтому для учебной модели мы выбираем PUBLIC как более надёжный и «самодостаточный» вариант библиотеки.
6. Include‑пути vs линковка: как не путать
Сейчас будет очень практичный момент: две ошибки выглядят похоже («не собирается»), но лечатся в разных местах. Хорошая новость: как только вы начнёте различать их по симптомам, половина боли от CMake исчезнет.
Когда компилятор пишет что-то вроде file not found или cannot open include file, это означает: не найден заголовок. Лечится target_include_directories.
Когда линкер пишет undefined reference (GCC/Clang) или unresolved external symbol (MSVC), это означает: не найдено определение (реализация). Лечится target_link_libraries или добавлением нужного .cpp в target.
Можно держать это как мини‑шпаргалку:
# Заголовки (чтобы компилировалось)
target_include_directories(x PUBLIC include)
# Реализация/символы (чтобы линковалось)
target_link_libraries(x PUBLIC some_dependency)
И да, это нормально, что на первых порах хочется «подлечить линковку инклудами» или «подлечить инклуды линковкой». Почти все через это проходят.
7. Типичные ошибки при работе с target_link_libraries
Ошибка №1: пытаться лечить undefined reference добавлением #include или include‑директорий.
Это самая частая путаница. Заголовок помогает компилятору увидеть объявление, но на линковке нужны определения. Если видите линковочную ошибку, взгляд должен идти не в #include, а в список .cpp в target и в target_link_libraries.
Ошибка №2: забыть, что .hpp не «участвует в сборке» сам по себе.
Новичок иногда думает: «я же добавил include/calc.hpp, значит калькулятор подключён». Заголовки не компилируются отдельно, они только включаются в .cpp. Реализация живёт в .cpp или в библиотеке, и именно она должна попасть в target, иначе линкер не найдёт символы.
Ошибка №3: ставить PUBLIC «на всякий случай» и раздувать граф зависимостей.
Когда всё помечено PUBLIC, зависимости начинают «утекать» дальше по проекту. В маленьком примере это не страшно, но в реальном проекте внезапно любой target начинает тащить половину мира. Гораздо здоровее иметь привычку: по умолчанию думать про PRIVATE, а PUBLIC выбирать осознанно, когда зависимость реально часть интерфейса или вы хотите сделать библиотеку самодостаточной для потребителей.
Ошибка №4: линковать не туда.
Иногда пишут target_link_libraries(calc PRIVATE app) вместо target_link_libraries(app PRIVATE calc). Смысл ломается: зависимость должна идти от потребителя к поставщику. Приложение использует библиотеку, а не наоборот.
Ошибка №5: получать циклические зависимости между библиотеками.
Если A линкуется с B, а B линкуется с A, проект превращается в клубок. Иногда это ещё может «как-то» собраться, но поддерживать такое невозможно. На текущем уровне достаточно правила: проектируйте зависимости в одну сторону, а общий код выносите в отдельную третью библиотеку (условный core), чтобы обе стороны зависели от неё, а не друг от друга.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ