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.
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.
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.
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:
literal operator suffixes not preceded by '_' are reserved).using-gal behozzuk; a bevett minta a
…::literals alnévtér. Két névtér ugyanazt a
_port nevet önállóan, ütközés nélkül deklarálhatja.// 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.
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.
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.
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.
constexpr függvény automatikusan
fordítási időben fut. Csak konstans kontextusban fut, egyébként normál
hívás.consteval függvényt futásidejű adaton hívni: ez
fordítási hiba, nem csendes visszaesés.constexpr táblát gyakran include-olt fejlécbe
tenni, ezzel minden TU-t és minden cache-bejegyzést érinteni.static_assert üzenetébe nem tenni bele a tényleges
értéket, így a hibaüzenet hasznavehetetlen.if constexpr helyett normál if-et
használni olyan ággal, amely csak a típusok egy részhalmazára fordul
le.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.
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.
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.
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.
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.
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.
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.