1. Po co potrzebne jest linkowanie w CMake
Jeśli kompilacja to moment, kiedy kompilator patrzy na pojedynczy plik .cpp i mówi "tak, ten plik jest logicznie poprawny", to linkowanie to moment, kiedy wszystkie kawałki projektu muszą zostać złożone w jeden plik wykonywalny (albo bibliotekę). I tutaj zaczynają się historie w stylu "przecież zrobiłem #include, dlaczego nie działa?!". Dziś uporządkujemy to ostrożnie — bez magii i bez "po prostu włącz jeszcze jedną opcję w IDE".
Wyobraźcie sobie, że projekt to serial. Każdy plik .cpp to oddzielny odcinek, w którym bohaterowie wspominają o sobie nawzajem. Nagłówek (.hpp) to jak "lista postaci i jak wyglądają". Ale żeby serial był prawdziwy, potrzebne są same odcinki (implementacje w .cpp) i potrzebny jest "montażysta", który zmontuje wszystko w jeden sezon. Montażysta to linker.
Na poziomie budowania zwykle występują trzy duże kategorie problemów:
flowchart TD
A[Błąd CMake] -->|nieprawidłowe polecenie, target nie utworzony| X[configure/generate przerwane]
B[Błąd kompilacji] -->|syntax/type errors| Y[kompilacja przerwana]
C[Błąd linkowania] -->|undefined reference / unresolved external| Z[linkowanie przerwane]
To właśnie błędy linkowania najczęściej pojawiają się, gdy zaczynacie robić kilka targetów (aplikacja + biblioteki). Czyli wtedy, gdy CMake staje się naprawdę przydatny.
2. target_link_libraries: sens polecenia
Polecenie target_link_libraries(consumer … dependency …) wygląda przerażająco tylko pierwszy parę razy. Mówiąc prosto, oznacza ono: "target consumer musi być zbudowany razem z targetem dependency, ponieważ potrzebuje jego definicji (implementacji)".
I to ważne: chodzi nie o nagłówki i nie o #include, lecz o implementację — to, co linker umieszcza w finalnym binarium.
Z punktu widzenia "mózgu builda" każdy target ma dwie części:
- to, co trzeba skompiować (zwykle lista plików .cpp),
- to, co trzeba dołożyć na etapie linkowania (inne biblioteki/targety).
target_link_libraries zajmuje się drugą częścią.
Minimalny przykład: "powiązać aplikację z biblioteką":
add_library(calc src/calc.cpp)
target_include_directories(calc PUBLIC include)
add_executable(app src/main.cpp)
target_link_libraries(app PRIVATE calc)
Na tym rysunku app używa funkcji z calc, dlatego na etapie linkowania mówimy: "app, weź też calc".
Bardzo ważny nawyk: we współczesnym CMake linkujemy się z targetem (nazwą celu), a nie ze ścieżkami do plików. Czyli calc, a nie libcalc.a ani calc.cpp. To jest podejście oparte na targetach: zależność to nie "jakiś plik", tylko "cel z właściwościami".
3. Nagłówek ≠ implementacja
Przejdźmy przez typową sytuację początkującego. Napisałeś nagłówek, dołączyłeś go w main.cpp, wszystko ładnie, kompilator zadowolony… i nagle na linkowaniu pojawia się komunikat w stylu undefined reference. Zwykle chce się krzyknąć: "Ale przecież go dołączyłem!".
Ważne rozróżnienie: #include wstawia tekst nagłówka do pliku .cpp na etapie preprocessingu. Pomaga to kompilatorowi zobaczyć deklaracje (declarations): "taka funkcja gdzieś istnieje". Ale #include nie dodaje automatycznie definicji (definition) do builda.
Definicja żyje w pliku .cpp (albo w bibliotece) i musi trafić do targetu, inaczej linker po prostu nie znajdzie ciała funkcji.
Złożymy mały projekt edukacyjny, który będziemy rozwijać w przykładach.
Pliki projektu
MiniCalc/
CMakeLists.txt
include/
calc.hpp
cli.hpp
src/
calc.cpp
cli.cpp
main.cpp
Nagłówek z deklaracją
// include/calc.hpp
#pragma once
int add(int a, int b);
Implementacja
// src/calc.cpp
#include "calc.hpp"
int add(int a, int b) {
return a + b;
}
Użycie w main
// src/main.cpp
#include <iostream>
#include "calc.hpp"
int main() {
std::cout << add(2, 3) << '\n'; // 5
}
Jeśli zapomnisz dodać src/calc.cpp do builda (albo zapomnisz zlinkować bibliotekę, która go zawiera), kompilacja main.cpp przejdzie: kompilator widział int add(int,int);. Ale linkowanie się nie powiedzie: ciała add nie będzie skąd wziąć.
To bezpośrednio wiąże się z faktem, że w C++ istnieje pojęcie linkage (jak nazwy są powiązane między jednostkami translacji).
4. Widoczność zależności: PRIVATE, PUBLIC, INTERFACE
Gdy dodajesz zależność, pojawia się pytanie: kto jeszcze powinien o niej wiedzieć? Tylko sama biblioteka? A może także ten, kto będzie tę bibliotekę używać? Tutaj pojawiają się trzy słowa, które na początku wyglądają jak zaklęcia: PRIVATE, PUBLIC, INTERFACE.
Ustalmy ludzką interpretację: to nie jest o "bezpieczeństwie" ani o private/public w klasach C++. To o rozszerzaniu wymagań builda przez graf zależności targetów.
Warto mieć w głowie tabelkę:
| Słowo kluczowe | Co oznacza dla consumer -> dependency |
|---|---|
|
zależność jest potrzebna tylko do zbudowania samego consumer, dalej nie "przekazujemy" |
|
zależność jest potrzebna i consumer, i wszystkim, którzy będą używać consumer |
|
samemu consumer zależność nie jest potrzebna (albo jest header-only), ale konsumenci jej potrzebują |
Dla target_link_libraries czytamy to jako: "kto musi się zlinkować (lub otrzymać wymagania linkowania)".
Przykład:
target_link_libraries(app PRIVATE calc)
To znaczy: app linkuje się z calc, ale dalej (jeśli ktoś będzie się linkować z app) ta zależność nie musi iść tranzytywnie.
Dla executable'ów często to wystarcza: aplikacja zwykle nie jest "podłączana jako biblioteka". Ale dla bibliotek jest to krytyczne, bo biblioteki istnieją po to, by je używać.
5. Tranzytywność w praktyce
Tranzytywność to straszne słowo, które kryje bardzo prostą ideę: jeśli A zależy od B, a B zależy od C, to czasami A musi zależeć od C, nawet jeśli nie napisałeś tego wprost. CMake może to zrobić za ciebie — ale tylko, jeśli poprawnie oznaczysz widoczność zależności.
Schemat zależności
Chcemy zbudować aplikację app, która używa biblioteki cli, a cli wewnątrz używa calc.
flowchart LR
app[app executable] --> cli[cli library]
cli --> calc[calc library]
Teraz pytanie: czy app powinien jawnie linkować się z calc?
Odpowiedź: zależy od tego, co dokładnie cli obiecuje w swoim publicznym interfejsie i jak przebiega linkowanie (szczególnie dla bibliotek statycznych). Na naszym poziomie wystarczy zapamiętać praktyczną zasadę: jeśli cli używa calc, to najczęściej cli powinna linkować się z calc i prawidłowo przekazać tę zależność, aby konsument nie musiał zgadywać.
Praktyczny przykład: app + cli + calc
Złożymy teraz powiązany przykład, w którym app w ogóle nie dołącza calc.hpp. Zna tylko cli.hpp, a cli używa wewnętrznie calc. To klasyczny mini-wątek "wrapper nad biblioteką" — idealne miejsce, żeby poczuć tranzytywność.
Nagłówek cli.hpp
// include/cli.hpp
#pragma once
// cli obiecuje funkcję "obliczyć i ładnie wypisać"
void print_sum(int a, int b);
Implementacja cli.cpp (używa 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 (przykład wyjścia)
}
main.cpp zna tylko cli
// src/main.cpp
#include "cli.hpp"
int main() {
print_sum(2, 3); // 2 + 3 = 5
}
CMake: tworzymy dwie biblioteki i aplikację
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)
# WAŻNE: cli używa calc, więc deklarujemy zależność
target_link_libraries(cli PUBLIC calc)
add_executable(app src/main.cpp)
target_compile_features(app PRIVATE cxx_std_23)
# app linkuje się tylko z cli
target_link_libraries(app PRIVATE cli)
Dlaczego tu PUBLIC, a nie PRIVATE?
Ponieważ chcemy, żeby cli była "normalną" biblioteką, którą można podłączyć jedną linijką. Konsument (app) nie powinien zgadywać, że w środku potrzeba jeszcze calc. Przy PUBLIC CMake widzi graf zależności i potrafi przepuścić wymagania dalej.
Gdyby zrobić tak:
target_link_libraries(cli PRIVATE calc)
projekt może zacząć zachowywać się nieprzewidywalnie: czasem się zbuduje, czasem nie, a czasem — zepsuje się w najmniej oczekiwanym momencie (szczególnie przy dodaniu kolejnych zależności lub zmianie typu biblioteki).
Na starcie kursu wolimy przewidywalność: "podłączyłeś cli — działa".
Jak wybrać PRIVATE lub PUBLIC
Najczęstszy błąd początkującego to oznaczanie wszystkiego jako PUBLIC, "żeby na pewno działało". Rzeczywiście "działa", ale niepostrzeżenie zamieniasz projekt w kłębek zależności: wszystko ściąga się zewsząd, budowanie staje się cięższe, a każda zmiana psuje połowę projektu.
Praktyczny kryterium jest takie: jeśli zależność jest potrzebna dla publicznego interfejsu biblioteki, wtedy jest PUBLIC. Jeśli zależność jest potrzebna tylko wewnątrz implementacji (.cpp) i konsument nie musi jej znać, wtedy PRIVATE.
Jak to rozpoznać bez filozofowania:
- Jeśli twój nagłówek (include/twoja_biblioteka.hpp) wprost wymaga typów/symboli z innej biblioteki (np. zwracasz typ z zewnętrznej biblioteki, przyjmujesz go jako parametr, lub twój nagłówek musi dołączać cudzy nagłówek), to zależność często staje się częścią interfejsu. Wtedy sensowne jest, aby konsumenci też o niej wiedzieli, i PUBLIC ma sens.
- Jeśli w nagłówkach pokazujesz tylko własne typy i funkcje, a wewnątrz .cpp używasz cudzej biblioteki jako "narzędzia wewnętrznego", to konsument nie musi o niej wiedzieć. Wtedy PRIVATE utrzymuje zależność lokalną i porządną.
W naszym przykładzie cli.hpp nie dołącza calc.hpp i nie eksponuje funkcji add bezpośrednio. Ale cli nadal musi przy linkowaniu dać znać, że potrzebuje calc, inaczej konsument może niespodziewanie dostać błąd linkowania. Dlatego w modelu edukacyjnym wybieramy PUBLIC jako bardziej niezawodną i "samodzielną" opcję biblioteki.
6. Include‑ścieżki vs linkowanie: jak nie mylić
Teraz praktyczny punkt: dwa błędy wyglądają podobnie ("nie buduje się"), ale leczy się je w różnych miejscach. Dobra wiadomość: gdy nauczysz się rozróżniać je po symptomach, połowa cierpienia z CMake zniknie.
Gdy kompilator wypisuje coś w stylu file not found lub cannot open include file, to oznacza: nie znaleziono nagłówka. Naprawia się to przez target_include_directories.
Gdy linker pisze undefined reference (GCC/Clang) lub unresolved external symbol (MSVC), to oznacza: nie znaleziono definicji (implementacji). Naprawia się to przez target_link_libraries lub dodanie odpowiedniego .cpp do targetu.
Można to trzymać jako mini ściągawkę:
# Nagłówki (żeby kompilowało)
target_include_directories(x PUBLIC include)
# Implementacje/symbole (żeby linkowało)
target_link_libraries(x PUBLIC some_dependency)
I tak, normalne jest, że na początku chce się "wyleczyć linkowanie include'ami" albo "wyleczyć include'y linkowaniem". Prawie wszyscy przez to przechodzą.
7. Typowe błędy przy pracy z target_link_libraries
Błąd nr 1: próbować leczyć undefined reference przez dodanie #include lub katalogów include.
To najczęstsze pomieszanie. Nagłówek pomaga kompilatorowi zobaczyć deklarację, ale na etapie linkowania potrzebne są definicje. Jeśli widzisz błąd linkera, spójrz nie na #include, lecz na listę plików .cpp w target i na target_link_libraries.
Błąd nr 2: zapomnieć, że .hpp sam w sobie "nie bierze udziału w buildzie".
Początkujący czasami myśli: "dodałem include/calc.hpp, więc kalkulator jest podłączony". Nagłówki nie kompilują się same — tylko są dołączane do plików .cpp. Implementacja żyje w .cpp lub w bibliotece i to ona musi trafić do targetu, inaczej linker nie znajdzie symboli.
Błąd nr 3: ustawiać PUBLIC "na wszelki wypadek" i napompować graf zależności.
Gdy wszystko jest oznaczone jako PUBLIC, zależności zaczynają "przeciekać" dalej po projekcie. W małym przykładzie to niegroźne, ale w realnym projekcie nagle każdy target zaczyna ściągać połowę świata. Zdrowszą praktyką jest: domyślnie myśleć o PRIVATE, a PUBLIC wybierać świadomie, gdy zależność jest częścią interfejsu lub chcesz, żeby biblioteka była samodzielna dla konsumentów.
Błąd nr 4: linkować tam, gdzie nie trzeba.
Czasem pisze się target_link_libraries(calc PRIVATE app) zamiast target_link_libraries(app PRIVATE calc). Sens zostaje złamany: zależność powinna iść od konsumenta do dostawcy. Aplikacja używa biblioteki, a nie odwrotnie.
Błąd nr 5: otrzymywać cykliczne zależności między bibliotekami.
Jeśli A linkuje się z B, a B linkuje się z A, projekt zmienia się w kłębek. Czasem coś takiego "jakoś" się zbuduje, ale utrzymywanie tego jest niemożliwe. Na obecnym etapie wystarczy reguła: projektuj zależności w jedną stronę, a wspólny kod wyciągnij do osobnej biblioteki (np. core), tak aby obie strony zależały od niej, zamiast od siebie nawzajem.
GO TO FULL VERSION