Ez az alfejezet a fordítást nem melléktermékként, hanem tervezett erőforrásként kezeli: a C++ két különböző dolgot enged meg ugyanazzal a kulcsszóval körülírni – hogy egy kifejezés lehetséges fordítási időben kiértékelni, illetve hogy kötelező azt fordítási időben kiértékelni. Egy DevOps-környezetben ez nem akadémiai kérdés: a konfigurációs kulcsszavak, protokoll-nevek, hibakód-táblák és a hozzájuk tartozó keresőkód tipikus jelöltjei annak, hogy a build már a hibás táblát is elutasítsa, ne a produkciós futás. A célunk az, hogy egy kis parser/lookup táblán meg tudjuk mondani minden egyes deklarációról: mi benne a lehetőség, mi a kötelezettség, és mi a garancia hiánya.
Az előfeltételek röviden átismétlendők: a statikus és thread-local objektumok inicializálási sorrendje és életciklusa (Statikus és thread-local objektumok életciklusa (20.5)), a fordítási idejű elágazás eszközei (Type traits, tag dispatch és if constexpr (27.1)), valamint a korábbi állapotgép/parser szerződés (Bounds-ellenőrzött parser és állapotgép (13.5)). Az itteni tábla nem új parser, hanem a korábbi állapotgép lexikális segédtáblája.
A négy kulcsszó gyakori félreértése abból fakad, hogy mindegyik
„fordítási időhöz” kötődik, de más-más oldalról. A const
kizárólag hozzáférési tulajdonság: az objektum nem
módosítható ezen a néven keresztül, attól még inicializálható
futásidőben (const int port = read_port();). A
constexpr változón ennél többet ígér: az
inicializáló kifejezés konstans kifejezés, a típus literal típus, és a
változó neve felhasználható konstans kifejezésben. A
consteval függvényre tett kötelezettség:
minden potenciálisan kiértékelt hívásának konstans kifejezésnek kell
lennie. A constinit pedig nem a változó használhatóságát,
hanem az inicializálás módját rögzíti: az objektumnak
statikus (vagy thread) tárolási idővel kell rendelkeznie, és konstans
inicializálást kell kapnia; cserébe a változó továbbra is módosítható,
és nem válik konstans kifejezésben használhatóvá.
| Eszköz | Hol áll | Mit garantál | Mit nem garantál |
|---|---|---|---|
const |
objektum | írásvédett hozzáférés ezen a néven | hogy az érték fordítási időben ismert |
constexpr változó |
objektum | konstans kifejezéssel inicializált, konstans kifejezésben használható | hogy a programban mindenhol kizárólag fordítási időben létezik |
constexpr függvény |
függvény | van legalább egy olyan argumentum-értékelés, amely konstans kifejezés | hogy minden hívás fordítási idejű |
consteval |
függvény | minden hívás fordítási idejű (immediate function) | hogy a futó programban egyáltalán jelen van |
constinit |
objektum | statikus inicializálás, nincs SIOF | hogy az érték konstans kifejezésben felhasználható |
Két fogalmat kell pontosítani. Konstans kifejezés
(constant expression): olyan kifejezés, amelynek kiértékelése megfelel a
nyelvi szabálynak – nincs benne tiltott művelet (többek között
reinterpret_cast, nem konstans függvény hívása,
definiálatlan memóriához nyúlás), és az eredmény típusa megengedett.
Literal típus: skalár, referencia, illetve olyan
osztálytípus, amelynek triviális destruktora van, minden nem statikus
adattagja és bázisa literal, és rendelkezik megfelelő
constexpr konstruktorral. A std::string_view
literal típus, a std::string nem – ez a különbség a
példánkban is számít.
A constexpr függvény implicit módon inline,
és két életet él. Ha konstans kifejezést váró helyen hívjuk (például
static_assert-ben, constexpr változó
inicializálójaként vagy consteval függvény törzsében), a
fordító köteles konstans kiértékelést végezni, és hibát jelez, ha ez nem
lehetséges. Ha futásidejű argumentummal hívjuk, ugyanaz a függvény
egyszerűen lefordul közönséges hívásként. Ez a kettősség a
constexpr lényege, és egyben a legtöbb félreértés forrása:
a constexpr nem „fordítási időben lefutó függvény”, hanem
„fordítási időben is lefutni képes függvény”.
A szabványi követelmény másik fele: a függvénynek léteznie
kell olyan argumentumra, amelyre konstans kifejezést ad; ha ilyen
nincs, a program ill-formed, de diagnosztika nem kötelező (ill-formed,
no diagnostic required – IFNDR). Ez a legrosszabb hibafajta: a kód
fordulhat az egyik fordítóval és elutasíthatja a másik, ezért
nemtriviális constexpr függvényhez érdemes legalább egy
static_assert-tel bizonyított konstans hívást is
mellékelni.
A consteval függvény (immediate function) minden
potenciálisan kiértékelt hívásának konstans kifejezésnek kell lennie; a
hívás nem „eshet vissza” futásidejű végrehajtásra. Ez akkor jó, ha a
szerződést nem lehet elég korán kideríteni: ha egy tábla érvényességét
csak a build tudja ellenőrizni, a hívás helyén azonnal fordítási hiba
keletkezik. A függvény címét is csak konstans kifejezésben lehet
megszerezni, tehát futásidejű függvénypointerként nem használható.
Ennek ára van: a consteval nem alkalmas olyan
API-ra, amelyet futásidőben is hívni akarunk. A táblánk keresője ezért
constexpr marad, és csak az érvényesség-ellenőrző, illetve
a kötelezően előre kiszámítandó belépőpontok
consteval-ek.
A statikus inicializálási sorrend problémája (static initialization
order fiasco, SIOF) akkor jelentkezik, amikor egy fordítási egység
globális objektumát egy másik fordítási egység globális objektuma
inicializálja, és a sorrend nem specifikált. A constinit
erre ad szűk, de pontos választ: az inicializáló kifejezésnek konstans
kifejezésnek kell lennie, tehát az objektum már a dinamikus
inicializálás előtt, konstans inicializálással létrejön. Fontos: a
constinit nem tesz const-tá,
a változó továbbra is írható, és nem használható konstans kifejezésben.
Ha mindkettő kell, constexpr-t írunk; ha csak az
inicializálási sorrend biztonsága kell, constinit-et.
Ugyanaz a constexpr függvény néha mást szeretne tenni
konstans és futásidejű kiértékeléskor: konstans kiértékelésben nincs
memcmp, reinterpret_cast, memcpy-szerű gyors
út; futásidőben viszont pont ezek lennének hatékonyak. C++20-ban a
std::is_constant_evaluated() (az
<type_traits> fejlécből) ad erre választ: igazat ad,
ha a hívás manifestly constant-evaluated kontextusban történik.
C++23-ban a nyelvi if consteval szerkezet ezt olvashatóbban
fejezi ki, és nem hagyja jóvá a gyakori hibát, amikor valaki
if constexpr-ként próbálja használni.
#include <cstring>
#include <string_view>
#include <type_traits>
constexpr bool same_bytes(std::string_view a, std::string_view b) {
if (a.size() != b.size()) { return false; }
if (a.empty()) { return true; }
if (std::is_constant_evaluated()) { // C++20
for (std::size_t i = 0; i < a.size(); ++i) {
if (a[i] != b[i]) { return false; }
}
return true;
}
return std::memcmp(a.data(), b.data(), a.size()) == 0; // csak futásidőben
}C++23-as változatban a feltétel helyére
if consteval { ... } else { ... } kerül. Mindkét esetben
kötelező a <cstring> include, mert a
std::memcmp deklarációja onnan jön, és a fordító csak akkor
fordul le, ha a nem konstans ág tényleg nem része egyetlen konstans
kiértékelésnek sem.
A verzióérzékenység itt tényleges: a nyelvi és library-támogatást a
__cpp_consteval (201811L), __cpp_constinit
(201907L), __cpp_if_consteval (202106L) és
__cpp_lib_is_constant_evaluated (201811L) feature-test
makrókkal kell ellenőrizni, nem a fordító verziószámából következtetni.
A pontos makróértékek és a támogatás a konkrét compiler/libstdc++/libc++
kiadás függvényei, ezeket a saját laborprofilodban kell rögzíteni; ebben
a fejezetben nem állítom, hogy bármelyik konkrét kiadás mit tud.
C++20 óta konstans kiértékelésben a
new/delete megengedett, de a lefoglalt
tárterületnek a kiértékelés befejeződése előtt fel kell szabadulnia, és
a konstans eredmény nem lehet mutató dinamikusan foglalt tárterületre.
Ezért használhatunk consteval függvényben
std::vector-t vagy std::string-et köztes
számításhoz, de a kimenet csak érték (szám, std::array,
rövid string_view literálra) lehet. A
malloc/free, a reinterpret_cast
és a placement new továbbra sem része a konstans kiértékelésnek. A
library-támogatás (mennyire constexpr a
std::vector adott művelete) implementációfüggő, ezt compile
probe-bal kell ellenőrizni.
A fordítási idő önálló erőforrás: a konstans kiértékelésnek
implementációs korlátai vannak (mélység, lépésszám, hurok-iteráció), és
a túllépés diagnosztizált hiba, nem csendes visszaesés. A GCC és a Clang
is kínál kapcsolókat ezek emelésére (GCC oldalon
-fconstexpr-depth, -fconstexpr-loop-limit,
-fconstexpr-ops-limit; Clang oldalon
-fconstexpr-depth, -fconstexpr-steps néven),
de a pontos nevek, alapértékek és szemantika kiadásonként változnak,
ezért a laborban --help-fel kell ellenőrizni őket. Ne
ezekkel „javítsd” a lassú buildet: a helyes válasz a konstans
kiértékelés alatti munka csökkentése.
A példa egy konfigurációs parser lexikális táblája: rendezett
kulcsszólista, bináris kereséssel. A tábla rendezettségét
consteval függvény ellenőrzi
static_assert-ben, a kereső constexpr, így
futásidőben is hívható. A g_last constinit,
mert globális, módosuló állapot, és nem akarunk SIOF-ot.
// classify.cpp -- ISO C++20
// Fordítás: g++ -std=c++20 -O2 -Wall -Wextra -pedantic -o classify classify.cpp
// vagy: clang++ -std=c++20 -O2 -Wall -Wextra -pedantic -o classify classify.cpp
#include <array>
#include <cstddef>
#include <cstdio>
#include <string_view>
enum class token_kind { ident, number, terminator, unknown };
struct keyword {
std::string_view spelling;
token_kind kind;
};
namespace {
constexpr std::array<keyword, 3> kKeywords{{
{"end", token_kind::terminator},
{"listen", token_kind::ident},
{"port", token_kind::number},
}};
// Kötelezettség: minden hívásnak konstans kifejezésnek kell lennie.
consteval bool strictly_sorted() {
for (std::size_t i = 1; i < kKeywords.size(); ++i) {
if (!(kKeywords[i - 1].spelling < kKeywords[i].spelling)) {
return false;
}
}
return true;
}
static_assert(strictly_sorted(), "a kulcsszotabla nem szigoruan rendezett");
// Lehetőség: konstans és futásidejű hívás is megengedett.
constexpr token_kind lookup(std::string_view s) {
std::size_t lo = 0;
std::size_t hi = kKeywords.size();
while (lo < hi) {
const std::size_t mid = lo + (hi - lo) / 2;
const std::string_view key = kKeywords[mid].spelling;
if (key == s) { return kKeywords[mid].kind; }
if (key < s) { lo = mid + 1; } else { hi = mid; }
}
return token_kind::unknown;
}
consteval token_kind lookup_immediate(std::string_view s) { return lookup(s); }
constexpr const char* kind_name(token_kind k) {
switch (k) {
case token_kind::ident: return "ident";
case token_kind::number: return "number";
case token_kind::terminator: return "terminator";
default: return "unknown";
}
}
} // namespace
// Statikus inicializálás, de futásidőben módosítható: nem constexpr, nem const.
constinit token_kind g_last = token_kind::unknown;
int main() {
static_assert(lookup("port") == token_kind::number);
constexpr token_kind ct = lookup_immediate("end"); // kötelezően CT
static_assert(ct == token_kind::terminator);
std::string_view runtime_key = "listen"; // futásidőben érkezne
g_last = lookup(runtime_key); // ugyanaz a függvény, futásidőben
std::printf("%s\n", kind_name(g_last));
std::printf("%s\n", kind_name(ct));
}A lookup_immediate itt demonstratív: futásidejű
argumentummal
// SZÁNDÉKOSAN HIBÁS: consteval hívás futásidejű argumentummal
std::string_view key = read_key_from_config();
token_kind k = lookup_immediate(key); // fordítási hiba: nem konstans kifejezésfordítási hibát ad, mert a hívás nem konstans kifejezés. Ez a kívánt
viselkedés: a szerződés a hívás helyén bukik el, nem a futás közben.
Ugyanezt a hibát lookup-pal nem kapjuk meg – ott a hívás
legálisan futásidejű.
Két dolgot nem állítok: hogy a fenti kód bármely konkrét fordítóval
lefordult, és hogy a lookup futásidejű hívása mérhetően
gyorsabb vagy lassabb egy kézzel írt táblánál. A példa nem tesztelt, a
fordítás és a mérés a tanuló laborjának feladata; a várt viselkedés a
fenti szerződésekből következik, nem mérési eredmény.
Az első és legdrágább hiba a constexpr és a
consteval felcserélése API-tervezéskor:
consteval-léptetett kereső nem hívható futásidejű kulcsra,
és a fordító ezt a hívás helyén, elsőre érthetetlen üzenettel jelzi. A
második: const-ot használunk ott, ahol konstans kifejezés
kell – a const int n = f(); attól még nem lesz
constexpr, és static_assert(n == 3) nem fordul
le. A harmadik: constexpr függvény, amelyhez egyetlen
konstans hívás sem létezik (IFNDR); ezt mindig kísérje
static_assert. A negyedik: constinit-et
constexpr helyett használni ott, ahol az értékre konstans
kifejezésben is szükség van – a constinit változó nem
használható static_assert-ben. Az ötödik:
is_constant_evaluated()-t if constexpr-ként
kezelni; ez futásidejű feltétel, csak a konstans kiértékelés tényéről
dönt. A hatodik: consteval függvényből visszaadni
dinamikusan foglalt tárolóra mutató pointert vagy string-t
– a konstans eredmény nem hordozhat magával allokált tárterületet.
1. feladat (azonosítás). Az alábbi négy deklaráció
közül melyik fordul le, és melyik nem? Indokold a szerződés alapján. a)
constexpr int a = 3 + 4; b)
const int b = std::rand(); static_assert(b > 0); c)
constinit int c = std::rand(); d)
consteval int f(int x) { return x * 2; } int main() { int y = std::rand(); return f(y); }
2. feladat (kódolás). Egészítsd ki a fejezet
classify.cpp példáját egy
consteval bool no_duplicates() függvénnyel, amely szigorúan
rendezett táblán ellenőrzi, hogy nincs két azonos spelling,
és kösd be static_assert-be. Ezután szándékosan tegyél a
táblába duplikátumot, és fogalmazd meg, milyen diagnosztikát vársz.
3. feladat (diagnosztika). A same_bytes
függvény egy változatából valaki kihagyta az a.empty()
ellenőrzést, és std::memcmp(a.data(), b.data(), 0)-t hív
üres string_view-kre. Magyarázd el, miért problematikus ez,
és hogyan javítja a std::is_constant_evaluated()-sel védett
ág a helyzetet. Add meg az ellenőrzési feltételt is.
1. megoldás. a) Le Fordul: 3 + 4
konstans kifejezés, az int literal típus. b) Nem: a
b inicializálója futásidejű hívás, tehát b nem
konstans kifejezés, és static_assert-ben nem használható –
itt a hiba nem a const-ban, hanem abban van, hogy a
lehetőség helyett kötelezettséget feltételeztünk. c)
Nem: a constinit konstans inicializálást követel, a
std::rand() nem konstans kifejezés; a
constinit nem „varázsolja” konstanssá a futásidejű értéket.
d) Nem: a consteval függvény hívása y-nal nem
konstans kifejezés, ezért fordítási hiba; a helyes minta az, hogy a
konstans hívást constexpr változóba tesszük, a futásidejű
utat pedig constexpr (nem consteval)
függvényen keresztül adjuk. Ellenőrzés: a négy esetből pontosan egy
fordul le.
2. megoldás. A no_duplicates a
rendezettséget kihasználja: szomszédos elemeket hasonlítunk, mert
rendezett sorozatban a duplikátum szomszédos.
consteval bool no_duplicates() {
for (std::size_t i = 1; i < kKeywords.size(); ++i) {
if (!(kKeywords[i - 1].spelling < kKeywords[i].spelling)) {
return false; // azonos vagy nem rendezett
}
}
return true;
}
static_assert(no_duplicates(), "duplikalt vagy nem rendezett kulcsszo");Duplikátum beírásakor a várt diagnosztika a
static_assert üzenete, benne a megadott szöveggel, és a
hiba a static_assert sorára mutat – nem futásidejű
viselkedés. Ellenőrzési feltétel: a duplikátumot tartalmazó tábla nem
fordul le, a duplikátum eltávolítása után pedig a build hibamentes.
Megjegyzés: a strictly_sorted és a
no_duplicates itt ugyanazt a feltételt fejezi ki; a
tanulság az, hogy a consteval + static_assert
kombináció a tábla invariánsát kódolja, nem a kereső
algoritmusát.
3. megoldás. A std::memcmp szerződése
szerint a pointereknek legalább n karakterből álló tömbökre
kell mutatniuk; üres string_view esetén a
data() nulla hosszú, vagy akár null pointer is lehet, és a
null pointer átadása n == 0 mellett sem garantáltan
megengedett. Ráadásul a konstans kiértékelésben a
std::memcmp eleve nem hívható, mert nem
constexpr. A javítás a korai
if (a.empty()) { return true; } visszatérés, ami mindkét
problémát megszünteti: a memcmp ág csak nem üres,
futásidejű hívásnál fut le, a konstans ág pedig karakterenként hasonlít.
Ellenőrzési feltétel: same_bytes("", "") konstans és
futásidejű kontextusban is true, és a függvény
static_assert(same_bytes("port", "port"))-tal fordítási
időben is hívható.
A const hozzáférést véd, a constexpr
változó konstans kifejezéssé tesz egy értéket, a constexpr
függvény lehetővé teszi a fordítási idejű kiértékelést, a
consteval kötelezővé, a constinit
pedig a statikus inicializálás tényét garantálja anélkül, hogy konstans
kifejezéssé tenné a változót. A termelékeny minta az, hogy a tábla- és
konfigurációs invariánsokat consteval +
static_assert zárja le a buildben, a kereső pedig
constexpr marad, hogy futásidőben is használható legyen. A
konstans kiértékelés nem ingyen van: implementációs korlátai vannak,
allokációja csak átmeneti lehet, és a támogatás fordító- és
library-verziófüggő, ezért feature-test makróval és compile probe-bal
kell ellenőrizni. A modulok mint bináris határ, valamint a C++26-os
reflection részletes tárgyalása a következő alfejezetekre tartozik; itt
csak annyit rögzítettünk, hogy a konstans kiértékelés szerződése
független attól, milyen fizikai formában adjuk át a fordítónak a
forrást.