38.2 Modern CMake target-alapú projekt

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.

38.2.1 A target mint szerződéshordozó

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.

38.2.2 A capstone targetjei

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ó.

38.2.3 Nyelvi standard és extension policy

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.

38.2.4 Függőségek és linkhatárok

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).

38.2.5 Debug-, release- és sanitizer-profilok

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.

38.2.6 Install interface és generált config

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.

38.2.7 CMake Presets és több konfigurációs generátor

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-debug

A 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.

38.2.8 Tipikus hibák és javításuk

  1. Globális 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.
  2. 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.
  3. 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.
  4. -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).
  5. 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.
  6. Sanitizer flag a release targetre. A kiadott bináris nem reprodukálható és sanitizer runtime nélkül nem futtatható. Javítás: külön preset és külön build-könyvtár.
  7. A generált 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.

38.2.9 Feladatok

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.

38.2.10 Megoldások

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.

38.2.11 Összefoglaló

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.