40.6 Lexikális és preprocessor bővítések, kiterjesztett lebegőpontos típusok

A C++23 nyelvi változásainak jelentős része nem új absztrakció, hanem a meglévő eszközök „élesítése”: olyan helyzetek, ahol a C++20-ban a programozó kénytelen volt kerülőutat választani. A preprocessor feltételes fordítása, a literálok típusa, a karakterescape-ek írásmódja és a kis pontosságú lebegőpontos típusok mind ilyenek. A hiányzó elem nem új paradigmát, hanem pontosan azonosítható kellemetlenséget okozott: felesleges defined()-zárójeleket, static_cast<size_t>-öt a literálokon, „mohó” \x escape-eket, illetve azt, hogy a float16_t-hez implementációs neveket (_Float16, __fp16) kellett használni. Ez az alfejezet ezeket a forrásszintű újdonságokat veszi sorra, mindegyiknél a C++20-as megoldásból kiindulva.

40.6.1 A laborprofil kiterjesztése és az állítások státusza

A rész alapprofilja innentől ISO C++23, explicit -std=c++23 kapcsolóval, GCC vagy Clang mellett. A fordítási mód neve és a támogatás mértéke fordítóverzió-függő: a laborban rögzítsd a g++ --version / clang++ --version kimenetét, és először egy üres „probe” fájllal ellenőrizd, hogy a -std=c++23 egyáltalán elfogadott (egyes implementációk a -std=c++2b aliast ismerik). Az ebben az alfejezetben szereplő képességek támogatottságát nem állítom ellenőrzöttnek; mindenhol megadom a feature-test makrót és/vagy a nyelvi elemet ténylegesen kipróbáló compile probe-ot, valamint a C++20-as fallbackot.

A <version> header a szabványos feature-test makrók központi helye; a könyvtári képességnél __cpp_lib_*, a nyelvi elemnél __cpp_*, a <stdfloat> típusainál pedig a fordító által definiált __STDCPP_*_T__ makrók a mérvadók. A preprocesszor-direktívákhoz tartozó makrónevek és -értékek verziófüggők; ezeket a <version> tartalmából és a fordító dokumentációjából kell kiolvasni, ezért ahol lehet, nyelvi compile probe-ot használunk helyettük.

40.6.2 Feltételes fordítás: #elifdef, #elifndef és #warning

A C++20-ban egy többlépcsős platform- vagy feature-választó láncot így kellett írni:

#if defined(__linux__)
#  define PLATFORM "linux"
#elif defined(__APPLE__)
#  define PLATFORM "darwin"
#else
#  define PLATFORM "unknown"
#endif

A P2334 bevezette a #elifdef és #elifndef direktívákat. Szemantikailag #elifdef AZONOSITO pontosan ekvivalens a #elif defined(AZONOSITO) kifejezéssel, #elifndef pedig a #elif !defined(AZONOSITO)-val. Két feltétel fontos. Egyrészt a direktíva operandusa egyetlen azonosító, nem kifejezés, ezért a #elifdef defined(X) alak érvénytelen — ez a leggyakoribb hiba, mert a szerkezet #if-re emlékeztet. Másrészt a #elifdef csak a feltételes fordítás láncának folytatásaként állhat: #if, #ifdef, #ifndef vagy egy korábbi #elif* után; önállóan nem.

A P2437 a #warning direktívát szabványosította. Hatása: az implementációnak egy diagnosztikai üzenetet kell kibocsátania, amely tartalmazza a direktíva szövegét, de a program nem lesz ill-formed, és a fordítás alapértelmezés szerint folytatódik. Ez a lényegi különbség a #error-hoz képest, és ezért a -Werror sem feltétlenül állítja meg a buildet: a #warning nem warning, hanem szabványos diagnosztika. A C++20-as fallback #pragma message("..."), amely viszont implementációfüggő, és nem minden fordító ismeri.

Kidolgozott példa — platformválasztó lánc, #warning-gal a „nem várt” ágon:

// pp23.cpp
#include <cstdio>

#if defined(__linux__)
#  define PLATFORM "linux"
#elifdef __APPLE__
#  define PLATFORM "darwin"
#elifdef _WIN32
#  define PLATFORM "windows"
#else
#  warning "ismeretlen platform: a platform-specifikus kod talan nem fordul"
#  define PLATFORM "unknown"
#endif

int main() {
    std::printf("platform: %s\n", PLATFORM);
}
g++ -std=c++23 -Wall -Wextra -Wpedantic -o pp23 pp23.cpp

A __linux__, __APPLE__, _WIN32 makrók nem szabványosak, hanem fordító/OS specifikusak; itt csak a direktívalánc illusztrálására szolgálnak. A várt viselkedés leírás, nem mért eredmény: Linuxon a linux szöveg jelenik meg, a #warning pedig csak az #else ágon fut.

40.6.3 A z és uz literálsuffixum

A P0330 két új egész literálsuffixumot vezetett be. A z/Z suffixszel jelölt literál típusa a std::size_t előjeles párja, azaz std::make_signed_t<std::size_t> (LP64-en long); az uz/UZ suffixszel jelölt literál típusa maga a std::size_t (LP64-en unsigned long). A literál értékének ábrázolhatónak kell lennie a megfelelő típusban; ha nem, a program ill-formed, tehát itt nincs csendes túlcsordulás.

A gyakorlati nyereség a signed/unsigned keveredés és a felesleges castok eltüntetése. C++20-ban például:

template <class C>
constexpr auto last_index(C const& c) {
    return c.size() == 0 ? std::size_t{0} : c.size() - 1;  // std::size_t
}

C++23-ban ugyanez 0uz-vel olvashatóbb, és a size() eredményével való összehasonlítás nem kever előjelet:

template <class C>
constexpr auto last_index(C const& c) {
    return c.size() == 0uz ? 0uz : c.size() - 1uz;   // mindenhol std::size_t
}

A signed változat akkor hasznos, ha a ciklusváltozó negatív is lehet, vagy ha int-tel kell összevetni: for (auto i = 0z; i < static_cast<decltype(i)>(v.size()); ++i). Itt a 0z típusa long, így a v.size()-t egyszer kell konvertálni, és a -Wsign-compare csendben marad. Figyelem: a z nem azt jelenti, hogy „size_t”, hanem azt, hogy „a size_t előjeles párja” — a két suffix tehát nem ugyanannak a típusnak a két neve.

40.6.4 Nevesített karakterek és határolt escape-ek

A P2071 a \N{KARAKTERNÉV} univerzális karakternév-escape-et vezette be: az \u/\U kódszám helyett az Unicode Character Database-ben definiált név alapján azonosítja a karaktert. A P2290 ehhez kapcsolódva a \x, \u, \U escape-eknek kapcsos zárójeles, határolt alakját adta: \x{...}, \u{...}, \U{...}. Mindkettő ugyanazt a problémát oldja meg, csak más oldalról.

A \x escape hagyományosan „mohó”: a fordító a lehető leghosszabb hexadecimális számjegy-sorozatot fogyasztja, ezért a "\xff" "f" trükk kellett ahhoz, hogy egy 0xFF byte után f betű következzen. A "\x{ff}f" alak ezt explicit módon határolja, és a kapcsos alakban a számjegyek száma is korlátlan, ami a \u/\U esetében a 4, illetve 8 jegyű korlátot szünteti meg (\u{1F600}).

A \N{...} és a \x{...} közötti különbség tanulságos. A \x{e9} egyetlen kódpontot jelöl, amelynek a karakterliterál típusába kell illeszkednie; char literálban ez a 0xE9 byte. A \N{LATIN SMALL LETTER E WITH ACUTE} ugyanezt a kódpontot nevezi meg, de a stringliterál kódolása dönti el, hány byte lesz belőle: UTF-8 végrehajtási karakterkészlet esetén kettő (0xC3 0xA9). A kettő tehát nem ekvivalens:

#include <cstdio>
#include <cstring>

int main() {
    const char* hexed = "\x{e9}";                                     // 1 byte: 0xE9
    const char* named = "\N{LATIN SMALL LETTER E WITH ACUTE}";        // UTF-8: 2 byte
    std::printf("hexed=%zu named=%zu\n", std::strlen(hexed), std::strlen(named));
}
g++ -std=c++23 -Wall -Wextra -o esc esc.cpp

A névfeloldás támogatása nem kötelező minden implementációban, ezért itt különösen indokolt a compile probe: ha a \N{...}-t tartalmazó fájl nem fordul, a fallback a \u00E9 alak. A pontos egyeztetési szabályt (a név és a kódpont megfeleltetését) a szabvány [lex.charset] része rögzíti; erre itt nem térek ki részletesen.

40.6.5 UTF-8 forrásfájl kötelező támogatása

A P2295 kimondja, hogy a C++23 implementációnak a forrásfájlt UTF-8 kódolásúként is el kell tudnia olvasni. Ez fontos különbség a korábbi helyzethez képest, ahol a forráskód karakterkészlete implementációfüggő volt. Két dolgot viszont nem jelent. Egyrészt nem jelenti azt, hogy a char stringliterál automatikusan UTF-8 lesz: azt a végrehajtási karakterkészlet (execution character set) dönti el, amely GCC/Clang esetén külön kapcsolóval (-fexec-charset=UTF-8) állítható. Másrészt a u8"..." literál típusa const char8_t[] (C++20 óta), és a char8_t konverziója char-rá nem implicit.

Tipikus hiba a gyakorlatban a nem UTF-8 kódolású forrásfájl (Windows-1250, Latin-1), amelyet a fordító UTF-8-ként olvas, és „invalid UTF-8 sequence” hibát ad, vagy rossz karaktert tesz a literálba. A diagnózis: file -i forras.cpp, illetve xxd az érintett soron. A javítás nem a \x-szel való kézi kódolás, hanem a fájl konvertálása (iconv) és a build során a kódolás deklarálása. A BOM kezelése szintén implementációfüggő; a laborban érdemes BOM nélküli UTF-8-at használni.

40.6.6 Kiterjesztett lebegőpontos típusok: a <stdfloat> header

A P1467 hozta létre a <stdfloat> headert és az ott definiált típusokat: std::float16_t, std::float32_t, std::float64_t, std::float128_t és std::bfloat16_t. A std::floatN_t azt a típust jelöli, amely az ISO/IEC/IEEE 60559 binaryN formátum értékkészletét legalább lefedi (N = 16, 32, 64, 128); a std::bfloat16_t a bfloat16 értékkészletét. A típus létezése nem kötelező: a szabvány akkor írja elő, ha az implementáció rendelkezik ilyen típussal, és ezt a __STDCPP_FLOAT16_T__, __STDCPP_FLOAT32_T__, __STDCPP_FLOAT64_T__, __STDCPP_FLOAT128_T__, illetve __STDCPP_BFLOAT16_T__ makró definiálásával jelzi. A <stdfloat> header tartalmára a __cpp_lib_stdfloat feature-test makró utal; a konkrét értéket a <version>-ből kell kiolvasni.

A típusokhoz literálsuffixumok is tartoznak: f16, f32, f64, f128, bf16. A 1.0f16 típusa std::float16_t, a 1.0bf16 típusa std::bfloat16_t. A std::float32_t gyakori targeten azonos a float-tal, de ezt a szabvány nem garantálja; ABI-határon ezért nem szabad a kettőt felcserélhetőnek tekinteni.

A konverziós szabályok gyakorlati magja: a float16_t és bfloat16_t a szokásos aritmetikai konverziók során a float felé promoválódik, így a velük végzett aritmetika legalább float pontossággal történik, és az eredmény tároláskor veszít pontosságot. A float16_t ↔︎ float és float16_t ↔︎ double konverzió implicit, de szűkítő lehet; {} inicializálásnál konstans kifejezés esetén ez fordítási hiba, változó esetén -Wconversion figyelmeztet. A binary16 kb. 3 tizedesjegy pontosságú, legnagyobb véges értéke 65504; a bfloat16 kevesebb mantisszabit ad (kb. 2–3 tizedesjegy), de a float exponenstartományát tartja meg — ezért gépi tanulásban népszerű.

Kidolgozott példa feature-testtel és fallbackkal:

// stdfloat_probe.cpp
#include <cstdio>
#include <version>

#if defined(__STDCPP_FLOAT16_T__)
#  include <stdfloat>
#endif

int main() {
#if defined(__STDCPP_FLOAT16_T__)
    std::float16_t h = 1.0f16 / 3.0f16;
    std::float32_t f = 1.0f32 / 3.0f32;
    std::printf("float16=%.6f float32=%.6f sizeof=%zu/%zu\n",
                static_cast<double>(h), static_cast<double>(f),
                sizeof h, sizeof f);
#else
    std::puts("std::float16_t nem elerheto ezen a targeten (fallback: float)");
#endif
}
g++ -std=c++23 -Wall -Wextra -Wconversion -o stdfloat_probe stdfloat_probe.cpp

A várt kimenet leírás, nem mért eredmény: float16 esetén a binary16 kerekítése miatt kb. 0.333252, float32 esetén kb. 0.333333 jelenne meg. A {} inicializálás szűkítését érdemes külön kipróbálni tanítási hibaként:

std::float16_t h{1.0 / 3.0};   // hiba: double -> float16_t szukito, nem ábrázolható pontosan
std::float16_t h2 = 1.0 / 3.0; // implicit konverzió, -Wconversion jelzi

40.6.7 Párhuzamok és eltérések a C23-mal

A preprocesszor terén a C23 is átvette a #elifdef, #elifndef és #warning direktívákat, így a két nyelv között ezek hordozhatók — a pontos C23 paragrafusokat és az esetleges eltéréseket a C23-as részek tárgyalják (C23 könyvtár és preprocessor). A literálsuffixumok és escape-ek terén viszont nincs átfedés: a z/uz, az f16/bf16 és a \N{...}/\x{...} C++-specifikusak, ezért kétnyelvű fejlécben #ifdef __cplusplus védelem vagy makró szükséges. A <stdfloat> C++ header; a C _Float16/_Float32 típusai és a std::float16_t között nincs garantált megfeleltetés, ezért extern "C" határon ezeket nem szabad binárisan átadni az ABI ellenőrzése nélkül. A char8_t és a u8 literál mindkét nyelvben létezik (C++20, illetve C23), de a char8_t C++-ban erősebb típus; a C23-as változat részletei szintén a C23-as fejezetben szerepelnek.

40.6.8 Tipikus hibák és javításuk

A leggyakoribb hiba a #elifdef defined(X) írásmód: az operandus azonosító kell, nem kifejezés; a javítás #elifdef X. A második, hogy a #warning-ot hibának hiszik: nem állítja meg a fordítást, hibához #error kell. A harmadik, hogy a z suffixet size_t-nek válik: az uz a size_t, a z a párja. A negyedik, hogy a "\x{e9}"-et UTF-8 é-nek tekintik: az egyetlen byte, a helyes UTF-8 "\N{LATIN SMALL LETTER E WITH ACUTE}" vagy u8"\u00E9". Az ötödik, hogy a #ifdef __STDCPP_FLOAT16_T__ és a __cpp_lib_stdfloat közé egyenlőséget tesznek: az előbbi a konkrét típusra, az utóbbi a header tartalmára utal. Végül a hatodik: a std::float32_t-t ABI-határon float-ként kezelik, holott ez nem garantált.

40.6.9 Feladatok

  1. Preprocessor-lánc. Írj egy platform.h fejlécet, amely #if/#elifdef/#elifndef lánccal három platformot különböztet meg, és az ismeretlen ágon #warning-ot ad. Magyarázd el, miért hiba a #elifdef defined(__linux__).
  2. Indexelés. Adott egy std::vector<int> v és egy int i. Írj egy bool in_range(int, std::size_t) segédfüggvényt, amely 0z/0uz használatával elkerüli a signed/unsigned keveredést, és indokold, miért nem elég a puszta static_cast.
  3. Escape-diagnosztika. Adott const char* s = "\x{e9}";. Add meg a strlen(s) értékét és az okát, majd javítsd a kódot UTF-8 é-re. Add meg az ellenőrzési feltételt is.
  4. stdfloat-kapu. Írj egy fejlécet, amely std::float16_t-t használ, ha elérhető, különben float-ra esik vissza, és a visszaesést #warning-gal jelzi. Sorold fel a szükséges feature-test makrókat.

40.6.10 Megoldások

1. megoldás. A lánc:

// platform.h
#if defined(__linux__)
#  define PLATFORM_ID 1
#elifdef _WIN32
#  define PLATFORM_ID 2
#elifndef __unix__
#  warning "nem unix-szeru platform: a POSIX kodutak nem fordulnak"
#  define PLATFORM_ID 0
#else
#  define PLATFORM_ID 3
#endif

A #elifdef defined(__linux__) azért hiba, mert a direktíva operandusa egyetlen azonosító; a defined operátor csak #if/#elif kifejezésben értelmezhető. Ellenőrzés: a fájl fordul -std=c++23-mal, és az #else ágra kényszerítve (-U__linux__ -U__unix__) megjelenik a #warning üzenet, de a fordítás nem áll meg.

2. megoldás.

#include <cstddef>
#include <vector>

bool in_range(int i, std::size_t n) {
    return i >= 0z && static_cast<std::size_t>(i) < n;
}

Itt a 0z típusa long (LP64-en), így az i >= 0z összehasonlítás előjeles marad, és nem kell a negatív i-t unsigned-dá alakítani — ez a static_cast-tal nem oldható meg, mert a cast után a -1 nagy unsigned értékké válik. A második feltételben a cast csak a nemnegatív ágon fut, ahol értéke megőrződik. Ellenőrzés: i = -1, n = 10 esetén false; i = 3, n = 10 esetén true; -Wsign-compare nem jelez.

3. megoldás. A "\x{e9}" egyetlen char, értéke 0xE9, ezért strlen(s) == 1. UTF-8-ban az é (U+00E9) két byte: 0xC3 0xA9. Javítás:

const char* s = "\N{LATIN SMALL LETTER E WITH ACUTE}"; // vagy u8"\u00E9"

Ellenőrzési feltétel: strlen(s) == 2, és xxd-vel a byte-ok c3 a9. Ha a \N{...} nem fordul, a fallback "\u00E9", illetve u8"\u00E9".

4. megoldás.

// f16_compat.h
#include <version>

#if defined(__STDCPP_FLOAT16_T__)
#  include <stdfloat>
#  define F16 std::float16_t
#else
#  warning "std::float16_t nem elerheto: float fallback"
#  define F16 float
#endif

A szükséges makrók: __STDCPP_FLOAT16_T__ a típus létezésére, __cpp_lib_stdfloat a <stdfloat> header tartalmára. Ellenőrzés: a fejléc fordul mindkét ágon; a static_assert(sizeof(F16) >= 2) nem garantált, ezért a méretre ne építsünk szerződést.

40.6.11 Összefoglaló

A C++23 forrásszintű újdonságai jól elkülöníthető csoportokba esnek. A preprocessor #elifdef/#elifndef/#warning direktívái a feltételes fordítás zaját csökkentik, de operandszerződésük szigorú: azonosító, nem kifejezés. A z/uz literálsuffixum a size_t és előjeles párja típusát rögzíti a literál szintjén, megszüntetve a castokat. A \N{...} és a \x{...}/\u{...}/\U{...} escape-ek a karakterazonosítást teszik olvashatóvá, illetve határolttá; a char és a stringliterál kódolása továbbra is két külön kérdés. Az UTF-8 forrás támogatása kötelező, de a végrehajtási karakterkészlet nem automatikusan UTF-8. A <stdfloat> típusai feltételesek, ezért feature-test és compile probe kötelező; a float16_t/bfloat16_t aritmetikája float felé promoválódik, és a tárolás pontosságvesztéssel jár. A C23-mal való párhuzam a preprocesszorra korlátozódik; a literál- és típuskészlet eltér, ezért a kétnyelvű határon minden ilyen elemet ellenőrizni kell.