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.
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.
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.
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ókA 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.
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-includeA 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.
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.
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.
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.
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.
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.
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ő.