A build gráf, amelyet az előző részben felépítettünk, önmagában még
nem mondja meg, hogy egy csomópont fordításához és linkeléséhez pontosan
milyen include-útvonalak, nyelvi beállítások és linkelt könyvtárak
tartoznak. A target-alapú (target-based) CMake éppen ezt a hiányt tölti
be: a követelményeket nem globális változókba szórja, hanem a gráf
csomópontjaihoz köti, és a gráf élein csak ott továbbítja, ahol a target
szerződése ezt indokolja. Ez a különbség a régi, könyvtárszintű
stílussal szemben (include_directories,
link_directories, CMAKE_CXX_FLAGS): utóbbiak
minden később létrehozott targetre hatnak, és a build gráf élei
elmosódnak. A mostani alfejezet a capstone (a C záróprojekt C++ portja)
core, CLI és teszt targetjein mutatja meg, hogyan lehet ezt a szerződést
átvihetően, más gépen és más fogyasztónál is újrahasználhatóan megírni.
A CMake pontos, verziófüggő viselkedését a hivatalos dokumentációban (cmake.org) kell
ellenőrizni; az itteni állítások nem tekintendők lefuttatott
mérésnek.
Target: a build gráf azon csomópontja, amelynek a projektben egyedi neve van, és amelyhez a CMake vagy kimenetet (statikus/megosztott könyvtárat, futtatható programot), vagy csak követelményhalmazt (INTERFACE library) rendel. A target tulajdonságai (property-jei) határozzák meg a fordítási és linkelési parancsot, valamint azt, hogy milyen követelményeket exportál a fogyasztói felé.
Használati követelmény (usage requirement): olyan
követelmény, amelyet a target a fogyasztója felé közvetít —
include-könyvtár, compile feature, nyelvi standard, linkelt könyvtár,
compile definition. A közvetítés feltétele, hogy a fogyasztó
target_link_libraries-szel kapcsolódjon a targethez (vagy
egy find_package-dzsel kapott imported targeten keresztül
tegye ezt). Az add_subdirectory önmagában még nem terjeszti
ki a követelményeket egy másik targetre; a kapcsolatot a linkelés hozza
létre.
A három kulcsszó jelentése pontosan a következő:
| Parancs | PRIVATE | PUBLIC | INTERFACE |
|---|---|---|---|
target_include_directories |
csak a saját fordítás | saját + fogyasztó | csak a fogyasztó |
target_compile_options |
csak a saját fordítás | saját + fogyasztó | csak a fogyasztó |
target_compile_definitions |
csak a saját fordítás | saját + fogyasztó | csak a fogyasztó |
target_compile_features |
csak a saját fordítás | saját + fogyasztó | csak a fogyasztó |
target_link_libraries |
csak a saját linkelés | saját + fogyasztó | csak a fogyasztó |
A PUBLIC nem „fontosabb” a többinél, hanem azt jelenti,
hogy a követelmény a target saját fordításában és a fogyasztóénál is
megjelenik. A választás mindig abból a kérdésből következik, hogy a
követelmény része-e a target nyilvános szerződésének. Ha egy headerben
szerepel egy típus, amelynek az include-útvonala a fogyasztónak is kell,
akkor include esetén PUBLIC (vagy INTERFACE) a
helyes válasz. Ha egy warning-kapcsoló csak a saját kód minőségét
szolgálja, akkor PRIVATE; a fogyasztóra nem tartozik.
Az INTERFACE library olyan target, amelynek nincs
fordítási kimenete, csak követelményei vannak. Ez nem trükk, hanem a
szerződés tiszta kifejezése: például a sanitizer kapcsolókészletét egy
ilyen targetbe csomagolva egyetlen névvel, több targethez is
hozzákapcsolhatjuk anélkül, hogy bármelyik target forráskódját
módosítanánk.
A target_* parancsok sorrendje általában nem számít: a
tulajdonságok a configure fázisban állnak össze, és a generátor a végén
írja ki a build fájlokat. Ezért lehet a targetet előbb létrehozni, és a
függőségét csak később deklarálni.
A capstone három targetje a diag_core könyvtár, a
diag_cli futtatható program és a diag_tests
teszt. A függőségi irány egyirányú: a core nem tud a CLI-ról; a CLI és a
teszt a core fogyasztója. Ez a build gráf éle, és egyben a szerződés
iránya is. Az alábbi példa önállóan fordítható, ha a
src/core.cpp, include/diag/core.hpp,
src/main.cpp és tests/test_core.cpp fájlok a
helyükön vannak.
// include/diag/core.hpp
#pragma once
#include <string>
namespace diag {
std::string greet(const std::string& name);
}// src/core.cpp
#include "diag/core.hpp"
namespace diag {
std::string greet(const std::string& name) { return "hello, " + name; }
}// src/main.cpp
#include "diag/core.hpp"
#include <iostream>
int main() { std::cout << diag::greet("devops") << '\n'; }// tests/test_core.cpp -- NDEBUG mellett sem tűnik el
#include "diag/core.hpp"
#include <cstdio>
#include <cstdlib>
#include <string>
int main()
{
const std::string got = diag::greet("x");
if (got != "hello, x") {
std::fprintf(stderr, "greet: váratlan eredmény: %s\n", got.c_str());
return EXIT_FAILURE;
}
return EXIT_SUCCESS;
}A teszt szándékosan nem assert-re épül. A CMake szokásos
Release és RelWithDebInfo beállítása
NDEBUG-ot definiál; ilyenkor az assert teljes
kifejezése, a benne lévő greet hívással együtt eltűnik, a
tesztprogram 0 kóddal kilép, és a CTest sikeresnek látja. A
fenti ellenőrzés minden konfigurációban lefut. Ha egy projekt mégis
assert-et használ a tesztekben, a teszttargeten explicit
módon vissza kell vonnia az NDEBUG-ot, és ezt egy
szándékosan hibás futással igazolnia kell.
Buildjegyzőkönyv (lab-gcc profil). A
fenti öt fájlt CMake 4.4.4, Ninja 1.13 és GCC 13.3.0 mellett
Debug, Release és RelWithDebInfo
konfigurációban lefordítottuk és a CTesttel lefuttattuk. A teszt
mindhárom konfigurációban sikeres volt; a greet szándékos
elrontásakor ("hullo, ") mindháromban bukott. A korábbi,
assert-alapú változat ugyanezzel az elrontással
Release és RelWithDebInfo alatt sikeresnek
mutatkozott, csak Debug alatt bukott.
A hozzá tartozó CMakeLists.txt:
cmake_minimum_required(VERSION 3.25)
project(diag LANGUAGES CXX)
include(GNUInstallDirs)
add_library(diag_core src/core.cpp)
add_library(diag::core ALIAS diag_core)
target_include_directories(diag_core PUBLIC
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
$<INSTALL_INTERFACE:${CMAKE_INSTALL_INCLUDEDIR}>)
target_compile_features(diag_core PUBLIC cxx_std_20)
set_target_properties(diag_core PROPERTIES
CXX_STANDARD_REQUIRED ON
CXX_EXTENSIONS OFF)
target_compile_options(diag_core PRIVATE
"$<$<CXX_COMPILER_ID:GNU,Clang>:-Wall;-Wextra;-Wpedantic>")
add_library(diag_sanitize INTERFACE)
target_compile_options(diag_sanitize INTERFACE
"$<$<CXX_COMPILER_ID:GNU,Clang>:-fsanitize=address,undefined;-fno-omit-frame-pointer>")
target_link_options(diag_sanitize INTERFACE
"$<$<CXX_COMPILER_ID:GNU,Clang>:-fsanitize=address,undefined>")
option(DIAG_SANITIZE "Enable ASan/UBSan" OFF)
if(DIAG_SANITIZE)
# A core saját fordítási egységei is instrumentáltak legyenek: a tesztelt
# kód a core-ban van. A PRIVATE kapcsolat a fordítási kapcsolókat a core-ra
# teszi; a linkkapcsolókat a futtatható targetek saját kapcsolata adja.
target_link_libraries(diag_core PRIVATE diag_sanitize)
endif()
add_executable(diag_cli src/main.cpp)
target_link_libraries(diag_cli PRIVATE diag::core)
if(DIAG_SANITIZE)
target_link_libraries(diag_cli PRIVATE diag_sanitize)
endif()
include(CTest)
if(BUILD_TESTING)
add_executable(diag_tests tests/test_core.cpp)
target_link_libraries(diag_tests PRIVATE diag::core)
if(DIAG_SANITIZE)
target_link_libraries(diag_tests PRIVATE diag_sanitize)
endif()
add_test(NAME core_greet COMMAND diag_tests)
endif()Az indoklás a három döntés mögött: az include-könyvtár
PUBLIC, mert a core.hpp-t a fogyasztó is
include-olja, tehát a fogyasztónak is látnia kell a header helyét. A
warning-kapcsolók PRIVATE-ek, mert a mi minőségpolitikánk
nem része a könyvtár szerződésének; a fogyasztó más figyelmeztetési
szintet használhat. A cxx_std_20 PUBLIC, mert
a core headerje C++20-at igényel, tehát a fogyasztó sem fordíthatja
régebbi módban. A BUILD_INTERFACE és
INSTALL_INTERFACE szétválasztása pedig azt biztosítja, hogy
build közben a forrásfából, telepítés után a telepített
include-könyvtárból dolgozzon a fogyasztó.
A standard beállításának három szintje van: a globális
CMAKE_CXX_STANDARD, a target CXX_STANDARD
tulajdonsága és a target_compile_features által kért
compile feature. A harmadik a legjobban illeszkedik a target-alapú
modellhez, mert nem konkrét flaget, hanem nyelvi követelményt fejez ki:
a CMake választja ki a toolchainhez tartozó kapcsolót. A
CXX_STANDARD_REQUIRED ON megakadályozza, hogy a compiler
csendben visszaessen egy régebbi módra, a
CXX_EXTENSIONS OFF pedig kizárja a GNU-specifikus
kiterjesztéseket. C esetén ugyanez a minta: C_STANDARD,
C_STANDARD_REQUIRED, C_EXTENSIONS OFF, illetve
c_std_17 compile feature.
A C és C++ targetjeit külön kell profilozni. Ha a projekt mindkét
nyelvet használja, a project(diag LANGUAGES C CXX)
deklaráció után a C target c_std_17-et, a C++ target
cxx_std_20-at kap; a C targetre nem kerülhet
cxx_std_*, és fordítva. A compile feature csak nyelvi
képességet fejez ki; a library-szintű követelményeket (például egy adott
header elérhetőségét) ez nem garantálja, azokat külön compile probe-dal
vagy feature-test makróval kell ellenőrizni — erre a korábbi részek
adnak mintát.
A target_link_libraries(diag_cli PRIVATE diag::core) azt
jelenti, hogy a CLI linkel a core-hoz, de a core-t nem terjeszti tovább,
mert a CLI-nek nincs fogyasztója. Ha a CLI egy headerjében szerepelne a
core valamely típusa, a kapcsolat PUBLIC lenne. A szabály
nem esztétikai: a PRIVATE linkelt függőség nem kerül be az
exportált diagTargets.cmake használati követelményei közé,
míg a PUBLIC és az INTERFACE igen.
A tranzitivitás tipikus esete: a core a nyilvános
core.hpp-ban egy harmadik fél foo targetjének
típusát használja, a core.cpp-ben pedig egy
bar könyvtárat. Ekkor foo esetén
target_link_libraries(diag_core PUBLIC foo) a helyes, mert
a fogyasztónak is látnia kell a foo include-útvonalait és
linkjét; bar esetén PRIVATE a helyes, mert az
csak a megvalósítás részlete. A statikus vagy megosztott könyvtár
választása (BUILD_SHARED_LIBS, illetve
add_library(diag_core SHARED ...)) ettől független döntés.
A linkhatár ABI-kérdés is: a CMake nem ellenőrzi, hogy a két oldal
ugyanazzal az ABI-val, ugyanazzal a standard library-vel és ugyanazzal
az allocatorral fordult-e; ezt a korábbi ABI- és FFI-labor tárgyalja (ABI- és FFI-labor).
Több konfigurációs generátor (például a
Ninja Multi-Config) esetén a konfiguráció nem a configure,
hanem a build idején dől el. Ezért nem szabad
CMAKE_BUILD_TYPE-ra építeni: az ilyen generátornál csendben
figyelmen kívül marad. A profilokat targetenként, feltételes
generátorkifejezéssel (generator expression) lehet kezelni; a CMake
beépített Debug/Release kapcsolóit azonban csak indokolt esetben érdemes
kézzel felülírni.
A sanitizer beállítás mintája a fenti diag_sanitize
INTERFACE target. Miért nem elég egy PRIVATE compile
option? Azért, mert a sanitizer runtime-nak a linkelésnél is jelen kell
lennie, tehát a compile és a link követelményét együtt kell hordozni, és
minden érintett targethez hozzá kell kapcsolni. „Minden érintett” itt a
core-t is jelenti: ha a diag_sanitize csak a CLI-hez és a
teszthez kapcsolódna, a teszt instrumentáltan futna, de a tesztelt
greet a core nem instrumentált fordítási egységében lenne,
és az ott elkövetett memóriahibát az ASan nem látná.
Buildjegyzőkönyv (lab-gcc profil, CMake
4.4.4,
-DDIAG_SANITIZE=ON -DCMAKE_EXPORT_COMPILE_COMMANDS=ON). A
compile_commands.json szerint a javított konfigurációban a
core.cpp, a main.cpp és a
test_core.cpp is -fsanitize=address,undefined
kapcsolóval fordul; a core nélküli változatban a core.cpp
nem. Egy szándékosan a greet-be tett, a kimenetet nem
befolyásoló tömbön kívüli olvasást a régi konfiguráció tesztje
átengedett (100% tests passed). A javítottban a teszt
AddressSanitizer: stack-buffer-overflow hibával bukott, a
hiba helyével a core.cpp-ben. A sanitizeres build külön
configure- és build-könyvtárban vagy külön presetben fusson, és a
release csomagba ne kerülhessen be: a sanitizerrel linkelt bináris nem
azonos a kiadott binárissal.
A buildkövetelmények csak addig hasznosak, amíg a projekt
forrásfájában vagyunk. A telepített csomagot a
install(TARGETS ... EXPORT ...), az
install(EXPORT ... NAMESPACE diag:: ...), valamint a
CMakePackageConfigHelpers modul
configure_package_config_file és
write_basic_package_version_file parancsai készítik el. A
generált diagConfig.cmake és diagTargets.cmake
együtt teszi lehetővé, hogy a fogyasztó
find_package(diag CONFIG REQUIRED) után
diag::core-t linkeljen. Az export a PUBLIC és
INTERFACE követelményeket viszi, a
PRIVATE-eket nem; ezért kell az include-útvonalat a
$<BUILD_INTERFACE:...> és
$<INSTALL_INTERFACE:...> párossal szétválasztani. Az
INSTALL_INTERFACE útvonala a telepítési prefixhez képest
relatív legyen; abszolút útvonal beírása nem hordozható.
A header-telepítés régebbi mintája az
install(DIRECTORY include/ DESTINATION ...); az újabb
CMake-verziókban a FILE_SET HEADERS (a CMake 3.23 körüli
kiadásoktól) ezt targethez kötötten oldja meg. Melyik verziótól melyik
érhető el, azt a telepített CMake dokumentációjában kell ellenőrizni; a
fejezet nem állítja, hogy a kód bármelyik konkrét kiadáson futott.
A CMakePresets.json a configure-, build- és
test-beállításokat nevesíti, és verziókövethető fájlban tartja, így a
„hogyan kell ezt a projektet buildelni” tudás nem a fejekben és nem a
CI-szkriptben szétszórva él. Egy minimális példa:
{
"version": 6,
"configurePresets": [
{
"name": "dev-debug",
"generator": "Ninja Multi-Config",
"binaryDir": "${sourceDir}/build",
"cacheVariables": {
"CMAKE_CONFIGURATION_TYPES": "Debug;Release",
"DIAG_SANITIZE": "ON"
}
}
],
"buildPresets": [
{ "name": "dev-debug", "configurePreset": "dev-debug", "configuration": "Debug" }
],
"testPresets": [
{
"name": "dev-debug",
"configurePreset": "dev-debug",
"configuration": "Debug",
"output": { "outputOnFailure": true }
}
]
}A hívás:
cmake --preset dev-debug
cmake --build --preset dev-debug
ctest --preset dev-debugA presets séma "version" mezője a CMake verziójához
kötött; a 6-os séma a 3.25 körüli kiadásokhoz tartozik, ezért a
telepített CMake-hez kell igazítani, és a dokumentációban ellenőrizni. A
ctest --test-dir szintén újabb CTest-verziókhoz kötött;
régebbi környezetben a build könyvtárba kell lépni.
Buildjegyzőkönyv (lab-gcc profil). A fenti
presets-fájllal a három parancs CMake 4.4.4 és Ninja 1.13 mellett
lefutott: a Ninja Multi-Config generátor Debug
és Release konfigurációt készített, a Debug
build DIAG_SANITIZE=ON miatt ASan/UBSan kapcsolókkal
fordult, és a CTest egyetlen tesztje sikeres volt.
include_directories és
CMAKE_CXX_FLAGS. Minden target örökli, a gráf élei
elmosódnak, és a később hozzáadott target is váratlanul megkapja.
Javítás: target_include_directories és
target_compile_features a konkrét targeten.PRIVATE include egy nyilvános
headernél. A fogyasztó nem találja a
diag/core.hpp-t. Javítás: PUBLIC, mert a
header a szerződés része.PUBLIC link ott, ahol elég lenne
PRIVATE. A fogyasztó fölöslegesen örökli a
függőséget, és az export is tartalmazza. Javítás: csak a nyilvános
headerben megjelenő típusok függősége legyen PUBLIC.-std=c++20 a
CMAKE_CXX_FLAGS-ben. Nem target-specifikus, és
felülírhatja a CMake által választott kapcsolót. Javítás:
target_compile_features(... cxx_std_20).CMAKE_BUILD_TYPE több konfigurációs
generátornál. Csendben figyelmen kívül marad. Javítás:
--config a buildnél, illetve presets
configuration mezője.diagConfig.cmake kézi szerkesztése a
build-könyvtárban. A következő configure felülírja. Javítás: a
.in template és a CMake parancsok módosítása.1. feladat (magyarázó). A diag_core a
nyilvános core.hpp-ban egy foo könyvtár
foo::handle típusát használja, a core.cpp-ben
pedig egy bar könyvtárat. Melyik függőség legyen
PUBLIC, melyik PRIVATE, és miért?
2. feladat (kódolási). Alakítsd át a példát úgy,
hogy a core headerje C++17-kompatibilis legyen, a CLI viszont C++20-at
igényeljen. Írd át a CMakeLists.txt érintett sorait, és
indokold, miért maradhat a core követelménye PUBLIC.
3. feladat (diagnosztikai). A fogyasztó a
find_package(diag CONFIG REQUIRED) után ezt írja:
target_link_libraries(app PRIVATE diag::core), de a fordító
nem találja a diag/core.hpp-t. Sorolj fel legalább három
lehetséges okot, és add meg az ellenőrzés lépéseit.
1. megoldás. A foo legyen
PUBLIC: a típusa a nyilvános headerben szerepel, tehát a
fogyasztónak is látnia kell a foo include-útvonalát és
linkjét. A bar legyen PRIVATE: csak a
megvalósításban kell, a fogyasztó szerződésének nem része. Ellenőrzési
feltétel: a telepített diagTargets.cmake-ben a
foo INTERFACE_LINK_LIBRARIES bejegyzésként
jelenik meg, a bar nem.
2. megoldás. A core-nál
target_compile_features(diag_core PUBLIC cxx_std_17), a
CLI-nél
target_compile_features(diag_cli PRIVATE cxx_std_20). A
CXX_STANDARD_REQUIRED ON és a
CXX_EXTENSIONS OFF marad. A core követelménye azért
maradhat PUBLIC, mert a header C++17-tel is fordul, tehát a
fogyasztónak nem kell C++20-at ígérni; a CLI saját magának kérhet
többet. Ellenőrzési feltétel: a compile_commands.json-ban a
core object fájljához tartozó parancs C++17-es, a CLI-é C++20-as módot
mutat — ez a tanuló laborjának mérése, nem a fejezet állítása.
3. megoldás. Lehetséges okok: (a) a core
include-könyvtára PRIVATE-ként van beállítva, ezért nem
kerül az exportba; (b) az INSTALL_INTERFACE útvonala hibás
vagy abszolút, ezért a telepített config rossz helyre mutat; (c) a
fogyasztó egy régi telepítést talál a diag_DIR miatt, nem a
frisset; (d) a header nem került telepítésre. Ellenőrzés:
cmake --install build --config Release --prefix /tmp/diag-install,
majd a telepített diagTargets.cmake
INTERFACE_INCLUDE_DIRECTORIES bejegyzésének megtekintése,
végül a fogyasztó CMakeCache.txt-jében a
diag_DIR értékének ellenőrzése. A hiba akkor javul, ha az
include PUBLIC/INTERFACE módon, helyes relatív
INSTALL_INTERFACE útvonallal és telepített headerrel
szerepel.
A target-alapú CMake lényege, hogy a buildkövetelmények nem globális
mellékhatásként, hanem targethez kötött szerződésként jelennek meg. A
PRIVATE, PUBLIC és INTERFACE
választása mindig abból a kérdésből következik, hogy a követelmény
része-e a target nyilvános szerződésének. A nyelvi standardot és a
compile feature-t targetenként, explicit módon kell rögzíteni, a C és
C++ targeteket külön profilozva. A sanitizer és a debug/release
különbségek INTERFACE targetekkel és presetekkel kezelhetők anélkül,
hogy a release csomag szennyeződne. Az install export és a generált
config viszi tovább ugyanezeket a követelményeket a fogyasztóhoz; a
build- és install-interface szétválasztása nélkül a telepített csomag
nem lesz hordozható. A CMake verziófüggő részleteit minden esetben a
telepített kiadás dokumentációjában kell ellenőrizni, és a fordítási
eredményeket mérés, nem feltevés alapján kell állítani.