1. Навіщо потрібне лінкування в CMake
Якщо компіляція — це етап, на якому компілятор дивиться на окремий .cpp і каже: «Так, цей файл коректний», то лінкування — це етап, на якому всі частини проєкту мають зібратися в один виконуваний файл або бібліотеку. Саме тут і починаються історії на кшталт: «Я ж зробив #include, чому не працює?!». Сьогодні ми спокійно розкладемо все по поличках — без шаманства і без порад у стилі «ну просто поставте ще одну галочку в IDE».
Уявіть, що проєкт — це серіал. Кожен .cpp — окрема серія, у якій герої згадують одне одного. Заголовок (.hpp) — це ніби «список персонажів і того, який вигляд вони мають». Але щоб серіал став справжнім, потрібні самі серії (реалізації в .cpp) і ще потрібен «монтажер», який збере все в один сезон. Цей монтажер — лінкер.
Під час збирання зазвичай виникають три великі категорії проблем:
flowchart TD
A[Помилка CMake] -->|неправильна команда, target не створено| X[configure/generate завершився з помилкою]
B[Помилка компіляції] -->|синтаксичні помилки / помилки типів| Y[compile завершився з помилкою]
C[Помилка лінкування] -->|undefined reference / unresolved external| Z[link завершився з помилкою]
Саме помилки лінкування найчастіше зʼявляються, коли ви починаєте працювати з кількома цілями (застосунок + бібліотеки). Тобто саме тоді, коли CMake стає справді корисним.
2. target_link_libraries: зміст команди
Команда target_link_libraries(consumer … dependency …) може лякати лише перші кілька разів. Якщо сказати простіше, вона означає: «ціль consumer треба зібрати разом із ціллю dependency, бо їй потрібна її реалізація».
І це важливо: йдеться не про заголовки й не про #include, а саме про реалізацію — те, що лінкер додає до підсумкового бінарника.
Якщо дивитися очима системи збирання, кожна ціль має дві складові:
- те, що потрібно скомпілювати (зазвичай список .cpp),
- те, що потрібно підʼєднати під час лінкування (інші бібліотеки/цілі).
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 ми лінкуємося з ціллю (назвою цілі), а не зі шляхами до файлів. Тобто з 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++‑класах. Це про поширення вимог до збирання графом залежностей між цілями.
Зручно тримати в голові таку таблицю:
| Ключове слово | Що це означає для звʼязку 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 — виконуваний файл] --> cli[cli — бібліотека]
cli --> calc[calc — бібліотека]
Тепер питання: чи має 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, залежності починають «витікати» далі по проєкту. У маленькому прикладі це не страшно, але в реальному проєкті раптово будь-яка ціль починає тягнути половину світу. Набагато корисніше мати таку звичку: за замовчуванням думати про PRIVATE, а PUBLIC обирати усвідомлено, коли залежність справді є частиною інтерфейсу або ви хочете зробити бібліотеку самодостатньою для споживачів.
Помилка № 4: лінкувати не туди.
Іноді пишуть target_link_libraries(calc PRIVATE app) замість target_link_libraries(app PRIVATE calc). Тут руйнується сам сенс: залежність має йти від споживача до постачальника. Застосунок використовує бібліотеку, а не навпаки.
Помилка № 5: отримувати циклічні залежності між бібліотеками.
Якщо A лінкується з B, а B лінкується з A, проєкт перетворюється на клубок. Іноді це ще може «якось» зібратися, але підтримувати таке неможливо. На поточному рівні достатньо простого правила: проєктуйте залежності в один бік, а спільний код виносьте в окрему третю бібліотеку (умовний core), щоб обидві сторони залежали від неї, а не одна від одної.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ