33.1 constexpr, consteval és constant evaluation

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.

33.1.1 const, constexpr, consteval, constinit: négy különböző szerződés

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.

33.1.2 constexpr függvény: lehetőség, nem kötelezettség

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.

33.1.3 consteval: az immediate function

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.

33.1.4 constinit: a statikus inicializálási sorrend ellenszere

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.

33.1.5 is_constant_evaluated és if consteval

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.

33.1.6 Fordítási idejű allokáció és a költség korlátai

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.

33.1.7 Kidolgozott példa: kulcsszótábla compile-time ellenőrzéssel

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és

fordí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.

33.1.8 Gyakori hibák

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.

33.1.9 Feladatok

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.

33.1.10 Megoldások

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

33.1.11 Összefoglaló

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.