44.1 import std és import std.compat

A C++20 nyelvi moduljai önmagukban még nem oldották meg azt a problémát, ami miatt a modulokat egyáltalán kitalálták: a szabványos könyvtár továbbra is headerekből állt, és minden fordítási egység újra és újra beolvasta, előfeldolgozta és értelmezte ugyanazokat a szöveges fejléceket. A C++23 egyik leginkább „üzemi” jellegű újdonsága, hogy a könyvtár nagy része elérhetővé vált modulként is: import std; és import std.compat;. Ez a rész azt tanítja meg, hogyan építsünk ilyen projektet buildrendszerrel együtt, milyen feltételek mellett érdemes, és – ami a gyakorlatban legalább ilyen fontos – mikor marad jobb választás a #include.

A tárgyalás az alapprofil szerint ISO C++23 (-std=c++23), a C++20-as laborprofil kiterjesztéseként. A generáló modell nem böngészett és nem futtatott semmit: ahol konkrét fordító-, könyvtár- vagy CMake-verzió szerepel, az ellenőrzendő hivatkozás, nem igazolt tény. A C++20-as modulmechanika alapjait itt nem tanítjuk újra; ezekre a C++20 modules és header unit (33.4) részben rögzítettek szerint építünk, a target-alapú buildet a Modern CMake target-alapú projekt (38.2), a laborprofil és feature-test szemléletet a C++23 laborprofil, szabványstátusz és feature-test (40.1) rész adja.

44.1.1 A szabványos könyvtár mint modul: definíció és feltételek

Az import std; egy import-deklaráció, amely a std nevű szabványos könyvtári modult importálja. A std modul tartalma definíció szerint a szabványos C++ headerek által biztosított deklarációk halmaza, a std névtérben – vagyis ugyanazok az entitások, amelyeket #include <vector> és társai deklarálnak, csak nem szövegesen újra beolvasva, hanem egy lefordított modul-interfész egységen (module interface unit) keresztül elérve.

Ez a megfogalmazás két dolgot rögzít. Egyrészt az import std; nem „varázslat”: nem ad hozzá semmit a nyelvhez, ugyanazokat a deklarációkat teszi láthatóvá, mint a megfelelő headerek. Másrészt viszont a hozzáférés módja más: a fogyasztó fordítási egysége nem a fejlécszöveget dolgozza fel, hanem a könyvtár által előre legyártott modul bináris interfészt (BMI, Binary Module Interface) olvassa be. A P2465 jelű WG21-javaslat („Standard Library Modules std and std.compat”) ezt a szerződést rögzíti.

A használat feltételei hármasak, és mindháromnak egyszerre kell teljesülnie. A fordítónak támogatnia kell a modulokat és konkrétan a std modult; a használt könyvtár-implementációnak (libstdc++ vagy libc++) szállítania kell a modul interfészét; végül a buildrendszernek kezelnie kell a BMI-t, mint köztes terméket, a megfelelő sorrendezéssel és függőségkövetéssel. Ha bármelyik hiányzik, az import std; nem „lassabb lesz”, hanem nem fordul le. Ezért ezt a döntést nem a forráskódban, hanem a buildkonfigurációban kell meghozni, és a laborprofilban rögzíteni.

44.1.2 import std és import std.compat

A két modul viszonya egyszerű, de könnyű félreérteni. Az import std; a std névtérbe exportálja a szabványos könyvtár deklarációit. Az import std.compat; ezen felül a C fejlécek globális névtérbeli deklarációit is elérhetővé teszi – mintha a <stdio.h>, <stdlib.h>, <string.h> stb. is be lennének húzva, nem csak a std::-be exportált <cstdio>, <cstdlib>, <cstring> változatok. Az std.compat a std modult is exportálja, tehát nem kell mindkettőt importálni; a std.compat önmagában tartalmazza a std-et.

A különbség lényegét egy táblázat rögzíti. A táblázat állításai a P2465 szerződésén alapulnak, de a konkrét könyvtárban elérhető névhalmazt a használt implementáció dokumentációjával kell igazolni.

Név import std; import std.compat;
std::vector, std::cout, std::string elérhető elérhető
std::printf, std::size_t, std::FILE elérhető elérhető
::printf, ::size_t, ::FILE, ::memcpy nem elérhető elérhető
assert, errno, offsetof, NULL (makró) nem elérhető nem elérhető
tartalmazza-e a std tartalmát – igen

A gyakorlati döntés: új kódban az import std; a természetes választás, mert a modern C++ kód úgyis std::-minősítést használ. Az import std.compat; átmeneti eszköz: akkor indokolt, ha egy régi, C-stílusú kódbázist (vagy harmadik féltől származó fejlécet) kell egy fordítási egységben továbbvinni anélkül, hogy a printf-hívásokat tömegesen std::printf-re írnánk át. A std.compat tehát migrációs híd, nem hosszú távú stílus.

44.1.3 Amit a modul nem ad: makrók és feature-test

A legtöbb meglepetés itt éri a fejlesztőt. A modulok nem exportálnak makrókat, és ez nem hiányosság, hanem a fordítási modell következménye: a preprocesszor a modulok feloldása előtt, a fordítási fázisok korábbi szakaszában fut, a makróknak pedig nincs névtere és nincs modul-hozzáférésük. Ezért az assert, az errno, az offsetof, a NULL, a NDEBUG, a setjmp, a va_arg és a __cpp_lib_* feature-test makrók továbbra is csak headerekből érhetők el.

Ez a következmény a gyakorlatban azt jelenti, hogy az import std; mellé a makrót igénylő fejléceket külön include-olni kell:

import std;          // vector, string, iostream, ...
#include <cassert>   // assert makró – modulból nem jön
#include <cerrno>    // errno makró
#include <version>   // __cpp_lib_* feature-test makrók

A feature-test itt külön figyelmet érdemel, mert a modulok elérhetőségének vizsgálata nem történhet az import std; „kipróbálásával” a forrásban. A szabvány a szabványos könyvtári modulokhoz a __cpp_lib_modules feature-test makrót rendeli, amelyet a <version> headerből lehet kiolvasni. A jelenléte azt jelzi, hogy a könyvtár szállítja a modulokat; a konkrét értéket és a tényleges fordíthatóságot azonban a használt könyvtár dokumentációjával és compile probe-bal kell igazolni, mert a makró jelenléte és a modul tényleges használhatósága nem feltétlenül esik egybe minden kapcsolóbeállítás mellett.

Egy tipikus feltételes szerkezet, amely ugyanazt a forrást fordítja modullal és headerrel:

#include <version>

#ifdef DEMO_USE_STD_MODULE
import std;
#else
#  include <iostream>
#  include <vector>
#endif

int main()
{
    std::vector<int> v{1, 2, 3};
    std::cout << v.size() << '\n';
}

A minta előnye, hogy a buildkonfiguráció dönti el a módot, a forrás pedig mindkét úton értelmes marad. Hátránya, hogy a #ifdef-ágak eltérő szemantikájú kódot rejthetnek: a header-ágban a deklarációk a globális modul-szegmensben, az import ágban a modulból érkeznek, és a két ág csak akkor ekvivalens, ha valóban ugyanazokat az entitásokat használja.

44.1.4 Build: CMake, generátor és BMI

A build oldalán három fogalmat kell együtt kezelni. A CMake-ben a szabványos könyvtári modul használatát a CMAKE_CXX_MODULE_STD változó, illetve a CXX_MODULE_STD target property vezérli; ezek a 3.30 körüli kiadásban jelentek meg, és a laborprofilban a pontos minimumverziót a használt CMake dokumentációja alapján kell rögzíteni. A modulok általános kezelése ennél korábbi, a CXX_SCAN_FOR_MODULES property és a hozzá tartozó scan-lépés révén.

A második fogalom a generátor. A modulok fordításához olyan buildgenerátor kell, amely dinamikusan fel tudja venni a BMI-k közötti függőségeket, és a fogyasztót a BMI elkészülte után indítja. A gyakorlatban ez a Ninja; a Makefile generátor ezt a függőségi mintát nem kezeli megfelelően. A pontos Ninja-minimumverziót szintén a CMake dokumentációja rögzíti, és ellenőrizni kell.

A harmadik fogalom a BMI természete. A BMI nem hordozható artefaktum: fordítóhoz, annak verziójához és a fordítási kapcsolókhoz kötött. Ha a -DNDEBUG, a -O2, a -std=c++23 vagy a standard library választása változik, a BMI újragenerálandó. Ez DevOps szempontból két következménnyel jár. Egyrészt a konténeres buildben érdemes a std modult a toolchain image részeként előre legyártani, különben minden tiszta build újra előállítja. Másrészt a gyorsítótár (ccache, sccache) csak akkor hasznos, ha a BMI-t is kezeli, és a kulcs tartalmazza a fordítót, a verziót és a releváns kapcsolókat.

Egy minimális, kétmódú CMake-projekt:

cmake_minimum_required(VERSION 3.30)
project(stdmod_demo LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 23)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)

option(DEMO_USE_STD_MODULE "Use import std; instead of headers" ON)

if(DEMO_USE_STD_MODULE)
  set(CMAKE_CXX_MODULE_STD ON)
endif()

add_executable(demo main.cpp)
target_compile_definitions(demo PRIVATE
  $<$<BOOL:${DEMO_USE_STD_MODULE}>:DEMO_USE_STD_MODULE>)
target_compile_options(demo PRIVATE -Wall -Wextra)

A hozzá tartozó buildparancsok, amelyek a Ninja-generátort és a két módot rögzítik:

cmake -S . -B build-import -G Ninja -DDEMO_USE_STD_MODULE=ON
cmake --build build-import

cmake -S . -B build-include -G Ninja -DDEMO_USE_STD_MODULE=OFF
cmake --build build-include

A fordítóoldali kapcsolókról csak annyit érdemes rögzíteni, amennyi a laborprofilhoz kell: GCC esetén a modulok kísérleti támogatását hagyományosan a -fmodules-ts jelzi, Clang esetén a std modult a libc++ szállítja. A konkrét kapcsolónevet, a kísérleti státuszt és a támogatott standard library párosítást minden esetben a választott fordító adott kiadásának dokumentációja rögzíti; ezek verziófüggő részletek, amelyeket nem szabad ellenőrzöttnek tekinteni.

44.1.5 #include és import keverése

A szabvány szerint a std modul deklarációi ugyanazokat az entitásokat írják le, mint a megfelelő headerek, így elvileg egy fordítási egységen belül a #include <vector> és az import std; együtt is konzisztens lehet. A gyakorlat azonban ettől eltér: a két út eltérő deklarációs környezetet hoz létre, a fordítók és a build-láncok viselkedése eltérő lehet, és a hibák nehezen olvashatók (duplikált deklaráció, ODR-hez közeli tünetek, modul-feloldási hibák).

A mérnöki szabály ezért az, hogy egy fordítási egységen belül lehetőleg egy utat használjunk. Ha a keverés elkerülhetetlen – például egy harmadik féltől származó fejlécet kell behúzni –, akkor azt a globális modul-szegmensben, az import std; előtt tegyük meg, és dokumentáljuk, hogy ez a határ. A harmadik, tisztább lehetőség a header unit (import <vector>;), amely a fejlécet modulként dolgozza fel, de ez a C++20-as rész C++20 modules és header unit (33.4) anyagához tartozik, itt csak a lehetőség nevesítése a cél.

44.1.6 Fordítási idő mérése

A modulok egyik ígérete a fordítási idő csökkenése: a fejlécek újraelemzésének elmaradása miatt, különösen sok fordítási egység esetén. Ez azonban nem automatikus, és nem is mérhető egyetlen futással. A BMI betöltése, a modul feloldása és a függőségi gráf kezelése is költség; kis projektekben a modul akár lassabb is lehet, mert a BMI előállítása egyszeri többletköltség.

A mérés módszertana ezért a következő. Rögzíteni kell a gépet, a fordítót és verzióját, a könyvtárat, a teljes kapcsolósort és a buildgenerátort. Külön kell mérni a hideg (üres build könyvtár, nincs BMI) és a meleg (BMI megvan, csak a fogyasztó fordul) esetet. A fordítási időt több futás mediánjaként kell megadni, mert az első futás torzít. A méréshez használható /usr/bin/time -v a falidőhöz és a csúcs-memóriához, GCC esetén a -ftime-report, Clang esetén a -ftime-trace a fázisok bontásához. A saját mérőszámunk a build teljes falideje legyen, ne a fordító belső számlálója.

A mérési mátrix, amelyet a labornak ki kell töltenie – itt szándékosan üres cellákkal, mert ezt a szöveget nem futtattuk:

Változat Konfiguráció Hideg futás (s) Medián, meleg, n=5 (s) Csúcs RSS (MB)
header DEMO_USE_STD_MODULE=OFF
modul DEMO_USE_STD_MODULE=ON

Az értelmezésnél három dolgot kell kimondani. A modul előnye a fordítási egységek számával nő, mert a megtakarítás egységenként jelentkezik. A BMI előállítása egyszeri költség, amely a toolchain image-be építve „ingyenessé” tehető. És a mérés csak akkor érvényes, ha a két változat ugyanazt a forráslogikát fordítja – ezért hasznos a fenti #ifdef-es szerkezet.

44.1.7 Gyakori hibák

A leggyakoribb hiba, hogy a fejlesztő az import std; után assert-et vagy errno-t használ, és a fordítás érthetetlen módon elszáll, mert a makró nem érhető el. A javítás mindig ugyanaz: a makrót adó fejlécet külön include-olni kell, és ezt a fejlécet a modul-import elé vagy mellé tenni.

A második hiba a std és a std.compat összekeverése: a fejlesztő mindkettőt importálja, holott a std.compat tartalmazza a std-et, majd a globális ::printf és a std::printf közötti különbségen akad el. A std.compat migrációs híd; új kódban nem indokolt.

A harmadik hiba a build oldalán jelentkezik: a CMAKE_CXX_MODULE_STD bekapcsolása Makefile generátorral, vagy a modul támogatásának elvárása olyan fordítóverziótól, amelynél a laborprofil nem igazolta. A tünet gyakran nem az, hogy „nem fordul”, hanem hogy a BMI nem generálódik, és a fogyasztó egy régi vagy hiányzó artefaktumot lát.

A negyedik hiba a mérés torzítása: egyetlen futás, meleg gyorsítótárral, összehasonlíthatatlan kapcsolókkal. Az így kapott szám nem mérés, hanem illusztráció, és nem alapozhat meg döntést.

Az ötödik hiba a BMI-t érinti: a fejlesztő a build cache-t változatlanul hagyja a kapcsolók módosításakor, és a cache egy nem kompatibilis BMI-t ad vissza. A javítás a cache-kulcs kiterjesztése a fordítóra, verzióra és a releváns kapcsolókra.

44.1.8 Feladatok

1. feladat (alap). Adott a fenti main.cpp, amely import std;-vel dolgozik. Egészítsd ki úgy, hogy assert-et használjon a vektor méretének ellenőrzésére, és magyarázd el, miért kell külön fejlécet behúzni. Add meg a pontos include-ot és a helyét.

2. feladat (középszintű). Egészítsd ki a CMake-projektet configure-time compile probe-bal, amely eldönti, hogy a std modul használható-e, és ennek megfelelően állítja a CMAKE_CXX_MODULE_STD értékét. A próbának a __cpp_lib_modules makrót és egy tényleges import std;-et is tartalmazó forrást kell fordítania. Fogalmazd meg, milyen esetben ad a próba hamis negatívot, és hogyan ellenőrizhető a döntés.

3. feladat (haladó). Tervezz mérési kísérletet a header- és a modulváltozat fordítási idejének összehasonlítására. Add meg a kontrollváltozókat, a hideg/meleg eset kezelését, a futásszámot, a statisztikát, a használt eszközöket, és azt, hogy milyen következtetés vonható le a kapott adatokból. Készítsd el a mérési jegyzőkönyv sablonját.

44.1.9 Megoldások

1. megoldás. Az assert makró, ezért nem érkezik a std modulból; a <cassert> fejlécet kell include-olni. A helyes forrás:

import std;
#include <cassert>

int main()
{
    std::vector<int> v{1, 2, 3};
    assert(v.size() == 3);
}

Az include helye azért az import után is lehet, mert a makró a preprocesszoré, nem a modulé; a lényeg, hogy az assert felhasználása előtt definiálva legyen. Indoklás: a preprocesszor a modul-feloldás előtt fut, a makróknak nincs modul-hozzáférésük. Ellenőrzési feltétel: a forrás -DNDEBUG nélkül és -DNDEBUG mellett is forduljon, és az utóbbi esetben az állítás ne szólaljon meg.

2. megoldás. A CMake configure-time próba vázlata:

include(CheckCXXSourceCompiles)

check_cxx_source_compiles([[
    #include <version>
    #if !defined(__cpp_lib_modules)
    #  error "no std module"
    #endif
    import std;
    int main() { std::vector<int> v; return static_cast<int>(v.size()); }
]] HAVE_STD_MODULE)

if(HAVE_STD_MODULE)
  set(CMAKE_CXX_MODULE_STD ON)
endif()

Az indoklás: a __cpp_lib_modules a <version>-ből olvasható, az import std; pedig azt igazolja, hogy a modul tényleg fordítható, nem csak a makró van definiálva. A hamis negatív kockázata az, hogy a check_cxx_source_compiles csak akkor tud modul-importot tartalmazó forrást kiértékelni, ha maga a próba a modulokat kezelő buildúton fut; ha nem, a próba „nem támogatott” eredményt ad akkor is, ha a fordítás egyébként működne. Ezért a döntést a CMakeOutput.log/CMakeError.log tartalmával együtt kell értékelni, és a laborprofilban rögzíteni kell a próba pontos körülményeit. Ellenőrzési feltétel: a configure naplója mutassa a próba forrását és annak fordítási eredményét, és a beállított CMAKE_CXX_MODULE_STD érték egyezzen a tényleges build sikerességével.

3. megoldás. A kísérlet kontrollváltozói: ugyanaz a gép, ugyanaz a fordító és verzió, ugyanaz a könyvtár, ugyanaz a teljes kapcsolósor (a módot kivéve), ugyanaz a buildgenerátor és verzió, ugyanaz a forráslogika. Két esetet kell mérni: hideg (üres build könyvtár, nincs BMI) és meleg (BMI megvan). Futásszám: legalább öt, a mediánt és a szórást jelenteni kell; az első futást a hideg esetbe kell számítani, nem a melegbe. Eszközök: /usr/bin/time -v a falidőhöz és a csúcs-RSS-hez, GCC-nél -ftime-report, Clang-nál -ftime-trace a fázisbontáshoz. A mérőszám a build teljes falideje, nem a belső számláló. Következtetés csak a mért tartományra vonatkozóan vonható le; a modul előnye a fordítási egységek számával nő, a BMI előállítása egyszeri költség, és kis projektekben a modul lehet lassabb is. A jegyzőkönyv sablonja a fenti táblázat, kiegészítve a fordító, a könyvtár, a CMake, a Ninja és a kapcsolók rögzítésével, valamint a medián és a szórás kiszámításának módjával. Ellenőrzési feltétel: a jegyzőkönyvből egyértelműen kiderüljön, melyik szám hideg és melyik meleg futásból származik, és a két változat kapcsolósora csak a módban térjen el.

44.1.10 Összefoglaló

Az import std; a szabványos könyvtár deklarációit teszi elérhetővé a std névtérben, modul-interfészen keresztül, a P2465 szerződése szerint. Az import std.compat; ugyanezt adja, kiegészítve a C fejlécek globális névtérbeli deklarációival; ez migrációs eszköz, nem új kód stílusa. A modulok nem exportálnak makrókat, ezért az assert, az errno, az offsetof és a feature-test makrók továbbra is headerekből jönnek; a __cpp_lib_modules a <version>-ből olvasható, de a tényleges támogatást compile probe-bal kell igazolni. A build oldalán a CMAKE_CXX_MODULE_STD és a CXX_MODULE_STD property, valamint a Ninja-generátor a feltétel; a BMI fordító-, verzió- és kapcsolófüggő, ezért a toolchain image és a gyorsítótár tudatos kezelést igényel. A kevert #include/import használat kerülendő, a fordítási idő pedig csak kontrollált, hideg/meleg esetet megkülönböztető méréssel értékelhető.