33.2 Compile-time adatok és típusos konfiguráció

A fix konfiguráció — protokoll-státuszkódok, metrika- és labelnevek, portok, feature flag-ek, syscall-táblák — szinte minden DevOps-komponensben megjelenik, és jellemzően kétszer ugyanaz a hiba forrása: vagy minden ilyen adatot futásidőben, unordered_map-ben és string-összehasonlítással keresünk (drága és későn derül ki a hiba), vagy a fordítóra bízzuk, és észre sem vesszük, hogy a fordítási idő és a bináris méret exponenciálisan nő. Ez az alfejezet a kettő közötti tervezést tárgyalja: mit érdemes fordítási időben kiszámítani és validálni, mit nem, és hogyan mérjük a költséget, mielőtt döntünk.

A constexpr kiértékelés nyelvi gépezetét részletesen a constexpr, consteval és constant evaluation rész tárgyalta; ott szerepel a constant expression pontos definíciója és a kiértékelés lépései. A pack- és fold-technikát a variadic template részből feltételezzük. Itt a hangsúly a konfigurációs adat alakján, a validálás helyén és a költségvetésen van.

33.2.1 constexpr lookup és fix adat

Motiváció: egy diagnosztikai szolgáltatás negyvennél is több metrikanevet publikál, és minden minta rögzítésekor le kell képeznie a belső azonosítót a kimeneti névre. Ha ez std::unordered_map<std::string, ...> lookup, akkor hívásonként néhány száz nanoszekundum és egy dinamikus hash-számítás ára a minta-útban van; ha viszont a leképezés fix és rendezett, akkor fordítási időben generálható bináris keresés vagy switch, és a tábla a .rodata szegmensbe kerül.

Fontos terminológiai pontosítás: a constexpr függvény azt jelenti, hogy a függvény képes constant expression részeként kiértékelődni, nem azt, hogy a fordító ezt meg is teszi. Ha a hívás eredményét nem használjuk fel konstans kontextusban, a fordító jogosult — és tipikusan így is tesz — normál futásidejű függvényként lefordítani. A garantált fordítási idejű kiértékeléshez vagy consteval (immediate function) kell, vagy egy konstans kontextus, például static_assert vagy constexpr változó inicializálása. Feltétel mindkét esetben, hogy az argumentumok maguk is constant expressionök legyenek, és a visszaadott típus literal típus legyen.

Kidolgozott példa: fix HTTP-státusztábla és constexpr keresés.

// status.cc — C++20, GCC/Clang, -std=c++20
#include <array>
#include <cstdint>
#include <string_view>

namespace app {

struct StatusEntry {
    std::uint16_t code;
    std::string_view text;
};

inline constexpr std::array<StatusEntry, 4> kStatus{{
    {200, "OK"},
    {400, "Bad Request"},
    {404, "Not Found"},
    {500, "Internal Server Error"},
}};

constexpr std::string_view status_text(std::uint16_t code) {
    for (const auto& e : kStatus) {
        if (e.code == code) return e.text;
    }
    return "unknown";
}

} // namespace app

static_assert(app::status_text(404) == "Not Found");
static_assert(app::status_text(418) == "unknown");

A static_assert azért kell, mert enélkül a status_text csak lehetne fordítási idejű — a static_assert kényszeríti ki, hogy tényleg az legyen. A inline constexpr (C++17) biztosítja, hogy a táblának egyetlen definíciója legyen a programban; ha a headerben static constexpr vagy névtelen névtér szerepelne, fordításonként külön példány keletkezne, amit a linker sok esetben összevon, de ez nem garantált, és a .rodata mérete emiatt TU-számmal nőhet.

33.2.2 A fordítási költség mint erőforrás

A fordítási idő ugyanolyan erőforrás, mint a futásidejű memória: mérni kell, és költségvetést kell rá adni. Két beépített eszköz terjedt el: a GCC -ftime-report kapcsolója, amely szöveges bontást ad (köztük a konstans-kiértékelés idejét is), és a Clang -ftime-trace kapcsolója, amely Chrome-trace formátumú JSON-t ír, és template-instanciálásonként külön blokkokat tartalmaz. Mindkettő implementációfüggő: a pontos szakasznevek és a kimenet felbontása a fordító kiadásától függ, ezért a labornaplóban rögzíteni kell a konkrét verziót.

A mérés alapkövetelménye a fix mérési terv, nem az egyszeri benyomás. Az alábbi tábla a mérési terv váza; a cellákba csak a saját gépen, dokumentált kapcsolókkal előállított számok kerülhetnek be.

Dimenzió Hol jelenik meg Mit mérünk Mérési eljárás
Konstans kiértékelés -ftime-report konstans-kiértékelési sora fordítási másodperc azonos TU, 10 / 100 / 1000 elemű tábla
Template-instanciálás -ftime-trace blokkok instanciálások száma std::variant / std::tuple méret növelése
Bináris méret size, nm -S .rodata és .text bájt 1 TU vs. 10 TU, inline constexpr vs. static
Inkrementális build ccache/sccache statisztika, ninja -d explain cache-találati arány egy header módosítása, egy .cc módosítása

Ehhez tartozik egy kvalitatív, de határozott táblázat arról, mi hogyan skálázódik:

Tényező Növekedés Következmény
Táblaméret (N elem) lineáris konstans-kiértékelési idő N > néhány ezer esetén a buildidő érezhetően nő
Headerben lévő constexpr adat minden include-oló TU újra feldolgozza a teljes újrafordítás drágul, a cache könnyen invalidálódik
Pack-expansion mélység kombinatorikus instanciálás a -ftime-trace blokkok száma robbanásszerűen nő
inline constexpr tábla egy definíció kisebb .rodata, de minden TU-nak látnia kell a fejlécet

Fontos következmény a cache-elt buildre: ha egy gyakran include-olt fejlécben méretes constexpr táblát tartunk, akkor a fejléc bármilyen apró módosítása az összes rá épülő TU-t újrafordítja, és a gyorsítótár nem segít. Ilyenkor érdemes a táblát egy külön, ritkán változó fejlécbe tenni, vagy extern const adatként egy .cc-be helyezni, és csak a kereső constexpr függvényt tartani a fejlécben.

33.2.3 User-defined literal és compile-time validálás

A fordítási idejű validálás akkor éri meg, ha az érvénytelen érték így nem futásidejű hibaágat, hanem fordítási hibát okoz. A user-defined literal (UDL) erre jó eszköz: a literál helyén a fordító a megadott operátort hívja, és ha az operátor konstans kontextusban értékelődik ki, a hiba a forráskód sorára mutat.

// port.cpp — C++20
#include <cstddef>
#include <cstdint>
#include <stdexcept>
#include <string_view>

namespace app {

constexpr std::uint16_t parse_port(std::string_view s) {
    if (s.empty() || s.size() > 5) throw std::invalid_argument("port length");
    std::uint32_t v = 0;
    for (char c : s) {
        if (c < '0' || c > '9') throw std::invalid_argument("port: non-digit");
        v = v * 10u + static_cast<std::uint32_t>(c - '0');
        if (v > 65535u) throw std::invalid_argument("port: out of range");
    }
    return static_cast<std::uint16_t>(v);
}

} // namespace app

// Numerikus literálhoz: 8080_port. A literál értéke unsigned long long.
constexpr std::uint16_t operator""_port(unsigned long long v) {
    if (v > 65535u) throw std::invalid_argument("port: out of range");
    return static_cast<std::uint16_t>(v);
}

// String literálhoz: "8080"_port, ugyanazzal a validálással.
constexpr std::uint16_t operator""_port(const char* s, std::size_t n) {
    return app::parse_port(std::string_view{s, n});
}

static_assert(8080_port == 8080u);
static_assert("8080"_port == 8080u);
static_assert(app::parse_port("65535") == 65535u);
// static_assert(99999_port == 0u);    // szándékosan fordítási hiba
// static_assert("99999"_port == 0u);  // szándékosan fordítási hiba

int main() {}

A literáloperátor szignatúrája határozza meg, milyen literállal hívható ([C++20] [over.literal], [lex.ext]). A (const char*, std::size_t) paraméterlista a string literálé ("8080"_port). Egy numerikus literál (8080_port) az unsigned long long paraméterű operátort keresi, vagy a nyers (const char*) alakot, illetve a literáloperátor-sablont. Ha csak a string változat létezik, a 8080_port fordítási hiba: unable to find numeric literal operator 'operator""_port'. A fenti kód ezért mindkét alakot megadja, azonos validálással.

Buildjegyzőkönyv (lab-gcc profil). A program g++ -std=c++20 -Wall -Wextra -Wpedantic beállítással figyelmeztetés nélkül fordult, mindhárom static_assert teljesül. A két kikommentezett sor bármelyikét visszakapcsolva a fordítás non-constant condition for static assertion hibával leáll, a constexpr kiértékelés operator""_port(99999), illetve operator""_port("99999", 5) pontján. A csak string literálos operátort tartalmazó eredeti változat a 8080_port sorra a fenti unable to find numeric literal operator hibát adta.

A minta lényege a két réteg: parse_port constexpr, tehát futásidőben is hívható; a _port operátor szintén constexpr, és a static_assert kényszeríti ki a konstans kiértékelést. A throw constexpr függvényben megengedett, de ha konstans kiértékelés során ténylegesen kiértékelődik, az nem constant expression, ezért fordítási hibát ad. A hibaüzenet szövege azonban nem szabályozott, csak a hiba ténye.

Korlátok, amelyeket a tervezésnél figyelembe kell venni. Négy különálló szabály játszik szerepet:

// udl_namespaces.cpp — C++20: ugyanaz a _port suffix két névtérben
#include <cstdint>

namespace net::literals {
    constexpr std::uint16_t operator""_port(unsigned long long v) {
        return static_cast<std::uint16_t>(v);          // TCP/UDP port
    }
}
namespace serial::literals {
    constexpr int operator""_port(unsigned long long v) {
        return static_cast<int>(v);                    // soros port sorszáma
    }
}

constexpr auto tcp() { using namespace net::literals;    return 8080_port; }
constexpr auto tty() { using namespace serial::literals; return 2_port; }

static_assert(tcp() == 8080 && tty() == 2);

#ifdef AMBIGUOUS
using namespace net::literals;
using namespace serial::literals;
auto both = 1_port;                                    // itt kétértelmű
#endif

int main() {}

Buildjegyzőkönyv (lab-gcc profil). g++ -std=c++20 -Wall -Wextra -Wpedantic udl_namespaces.cpp figyelmeztetés nélkül fordul: a két névtér deklarációja önmagában rendben van, és mindkettő külön használható. -DAMBIGUOUS mellett, ahol mindkét névtér egyszerre látszik, a 1_port sor call of overloaded 'operator""_port(long long unsigned int)' is ambiguous hibát ad. Nagy literáloknál a konstans kiértékelés mérete a fordítási időben jelenik meg, nem a futásidőben.

33.2.4 static_assert és hibadiagnosztika

A static_assert(feltétel, üzenet) deklaráció: a feltételnek constant expressionnek kell lennie, hamis érték esetén a program fordítása hibával leáll. C++17 óta az üzenet elhagyható, C++20 ezt megtartja. Elhelyezhető névtérben, osztályban és függvényen belül is.

A diagnosztika minősége gyakorlati kérdés. A fordító az üzenetbe beemeli a feltétel forrásszövegét, de ha a feltétel egy nagy tábla egy elemére hivatkozik, a hasznos információ elvész. Bevett — bár implementációfüggő — trükk, hogy a hibás értéket template-argumentumba csomagoljuk, mert a template-argumentum megjelenik a hibaüzenetben. Ehhez egy szándékosan hiányos sablon deklaráció kell:

template <auto V>
struct show_value;   // nincs definíció: használata "incomplete type" hibát ad

// static_assert(app::status_text(404) == "Not Found");
// static_assert(app::status_text(404) == "OK", "status text mismatch");

Ez a technika nem hivatalos language feature, és a kimenet formátuma fordítónként változik; csak diagnosztikai segédeszközként használjuk, és a végleges kódban ne hagyjuk benne. Ha több feltételt ellenőrzünk, mindegyikhez írjunk külön üzenetet, mert a fordító az első hamis feltételnél megáll.

33.2.5 if constexpr versus normál if

Az if constexpr (C++17) és a normál if közötti különbség nem teljesítmény, hanem fordíthatóság. Egy template-en belül az if constexpr feltétele constant expression, és a nem vett ágat a fordító nem instanciálja. A normál if mindkét ágát lefordítja, akkor is, ha futásidőben soha nem fut le.

// describe.cc — C++20
#include <string>
#include <type_traits>

template <class T>
std::string describe(const T& v) {
    if constexpr (std::is_integral_v<T>) {
        return "int:" + std::to_string(v);
    } else {
        return "other";
    }
}

Ha itt normál if szerepelne, a T = std::string esetben a std::to_string(v) ág is instanciálódna, és mivel arra nincs illeszkedő overload, a program nem fordulna le — noha az ág soha nem futna. Ezt a különbséget könnyű összekeverni a #if-fel: a preprocesszor a nem vett ágat teljesen eltávolítja, a fordító nem is látja; az if constexpr a template-instanciálás szintjén zárja ki. Fontos korlát: template-en kívül az if constexpr mindkét ágát ellenőrzi a fordító, mert a feltétel nem függ template-paramétertől, így ott a haszna kicsi.

33.2.6 Nem minden runtime input fordítható le

A leggyakoribb tervezési hiba, hogy a konfigurációs fájl vagy a környezeti változó értékét próbáljuk constexpr-ré tenni. Ez fogalmilag lehetetlen: a constant expression követelménye, hogy az érték a fordítás pillanatában ismert legyen, a konfigurációs fájl viszont csak a telepítés után létezik.

A helyes minta a közös constexpr mag és két belépési pont. A mag a parse_port fenti változata; a konstans literálokhoz consteval burkoló, a dinamikus bemenethez közvetlen futásidejű hívás és std::optional-lal jelzett hibaág.

// dual_path.cc — C++20
#include <optional>
#include <string>
#include <string_view>
#include "port.h"   // app::parse_port

consteval std::uint16_t port_ct(std::string_view s) {
    return app::parse_port(s);   // hiba esetén fordítási hiba
}

std::optional<std::uint16_t> port_runtime(std::string_view s) {
    try {
        return app::parse_port(s);
    } catch (...) {
        return std::nullopt;
    }
}

Így ugyanaz a validációs logika szolgálja a fordítási idejű konstansokat és a futásidejű bemenetet, és nincs két, eltérően viselkedő ellenőrző implementáció. A fordítási költség csak ott jelenik meg, ahol a konstans út tényleg használatban van.

33.2.7 Gyakori hibák

33.2.8 Feladatok

  1. Alap. Írj constexpr táblát öt logaritmikus szintre (trace, debug, info, warn, error), és egy consteval-lal kényszerített keresőt, amely számból adja vissza a nevet. Ellenőrizd két static_assert-tel. Készíts hozzá g++ -std=c++20 -Wall -Wextra és clang++ -std=c++20 -Wall -Wextra buildparancsot.

  2. Közép. Egészítsd ki a _port literált két új érvényességi feltétellel: a 0 érték tiltott, és az 1..1023 tartomány csak akkor legyen elfogadott, ha a felhasználó kifejezetten kéri (például egy _port_priv suffix-szel). Írd le, mit tapasztalsz, amikor szándékosan hibás literált írsz be: melyik sorra mutat a hiba, és mit tartalmaz az üzenet.

  3. Nehéz. Tervezz mérést a fordítási költségre. Készíts két változatot: (a) 1000 elemű constexpr tábla inline constexpr-rel, (b) ugyanaz az adat futásidőben épített std::vector-ból. Mérd mindkettőt -ftime-report-tal (GCC) vagy -ftime-trace-szel (Clang) azonos gépen és azonos compiler-kiadással, és dokumentáld a számokat. Ezután készíts egy 1 és egy 10 TU-s változatot, és hasonlítsd össze a size a.out kimenetét. Készíts egy táblázatot az eredményekről, és indokold, mit érdemes konstansként tartani.

33.2.9 Megoldások

  1. Megoldás. A tábla inline constexpr std::array<std::pair<std::string_view,int>, 5>, a kereső consteval int level_of(std::string_view) visszaadott értékkel; a tábla és a kereső ugyanabban a fejlécben, a consteval wrapperrel. Indoklás: a consteval garantálja, hogy a hívás konstans kontextusú, a static_assert pedig dokumentálja a szándékot. Ellenőrzési feltétel: a static_assert(level_of("warn") == 3) és a static_assert(level_of("nope") == -1) forduljon le, és a szándékosan hibás static_assert mutasson a megfelelő sorra mindkét compilerrel. Ha a Clang és a GCC eltérő üzenetet ad, az nem hiba: a diagnosztika formátuma nem szabályozott.

  2. Megoldás. A parse_port mellé egy parse_port(range_policy) overload kerül, ahol a range_policy egy enum class érték; a _port a publikusat, a _port_priv a megengedőt hívja. Indoklás: a szabály a parsertől független, ezért nem a parser belsejébe, hanem a wrapperekbe kerül. Ellenőrzési feltétel: 0_port és 0_port_priv egyaránt fordítási hibát adjon, 8080_port és 08080_port forduljon le, 80_port viszont csak a _priv változattal legyen érvényes. A hibadiagnosztikánál jegyezd fel, hogy az üzenet az operátor hívásának helyére mutat, nem a throw sorára.

  3. Megoldás. A mérési terv helyes felépítése: azonos TU-szerkezet, azonos include-lista, azonos compiler-kiadás, legalább három futás, és a medián rögzítése. A két változat közötti különbséget csak akkor szabad a constexpr-nek tulajdonítani, ha a futásidejű változatban is ugyanannyi elemet dolgozunk fel. Az eredmény tipikus értelmezése: kis táblánál (10–100 elem) a fordító sokszor nem is különböztethető mérhetően, nagy táblánál viszont a konstans kiértékelés ideje a -ftime-report konstans-kiértékelési sorában jelenik meg, és a .rodata mérete nő. A 10 TU-s kísérletnél a static constexpr tábla nagyobb binárist adhat, az inline constexpr kisebbet, de ezt a linker és az optimalizálás befolyásolja, ezért mért érték nélkül nem szabad kijelenteni. Ha a mért számok nem támasztják alá a várt előnyt, a helyes döntés a futásidejű változat megtartása — a mérés célja éppen az, hogy ezt a döntést ne érzésre hozzuk.

33.2.10 Összefoglaló

A fix konfigurációs adatot érdemes fordítási időben kiszámítani és validálni, de csak akkor, ha az adat tényleg fix, és ha a mérés igazolja a nyereséget. A constexpr lehetőséget ad, a consteval és a static_assert kényszeríti ki a fordítási idejű kiértékelést; a _port-szerű user-defined literal a validálást a forrásszöveg helyére viszi. Az if constexpr a template-instanciálás szintjén zárja ki a nem vett ágat, ezért ott kell használni, ahol az ág csak bizonyos típusokra fordul le. A futásidejű bemenetre a közös constexpr mag és a futásidejű belépő a helyes minta, nem a konstans kényszerítése. A fordítási költséget a -ftime-report / -ftime-trace, a bináris méretet a size és nm -S, a cache-viselkedést a ccache/sccache statisztika mutatja; a döntést ezekre a mérésekre kell alapozni, nem a constexpr puszta jelenlétére.