32.2 charconv, format és C++23 print

Ez az alfejezet két olyan könyvtári eszközcsaládot hasonlít össze, amelyek a mindennapi DevOps-kódban ugyanazt a feladatot látják el — szöveget alakítanak számmá és számot szöveggé —, de eltérő szerződéssel: a <charconv> locale-független, allokációmentes, hibakód alapú konverziót ad, a <format> pedig típusos, fordításidőben ellenőrzött formázást. A kettő nem versenytárs, hanem réteg: a konfiguráció és a metrikák gépi konverziója charconv, a naplósorok és a felhasználói kimenet format (majd C++23-ban print). A korábbi részekből támaszkodunk az iostream állapot- és pufferelési modelljére (iostream, stream state és pufferelés (32.1)), a C printf-család megbízhatósági korlátaira (Formázás és megbízható szövegfeldolgozás (11.2)) és a constrained felület tervezésére (Concepts, requires és constrained overload (27.2)).

32.2.1 A charconv szerződése: ptr és ec

A std::from_chars és std::to_chars már a C++17-ben megjelent (<charconv>), de a lebegőpontos túlterhelések implementációtámogatása évekig hiányos volt. A szerződésük szándékosan „nyers”: nincs kivétel, nincs allokáció, nincs locale, nem kell nullterminátor, és a bemenetet fél-nyílt tartományként [first, last) kapják.

A definíció lényege az eredménytípus:

struct from_chars_result {
    const char* ptr;
    std::errc   ec;
};
struct to_chars_result {
    char*     ptr;
    std::errc ec;
};

A from_chars(first, last, value, base) esetén a kitöltési szabályok pontosan megkülönböztetendők:

Ebből következik a gyakorlati alapszabály, amely e rész első tanulási célja: a sikeresnek tűnő ec nem elegendő, a teljes fogyasztást is ellenőrizni kell, azaz ptr == last. A "80a" bemenet ugyanis sikeresen parsolódik 80-ra, ptr az 'a'-ra mutat, és a naplóban csendben rossz konfiguráció lesz belőle. Ugyanígy fontos, hogy az egész túlterhelések nem fogadnak el vezető + jelet, nem hagyják figyelmen kívül a vezető szóközt, és előjeles karaktert sem fogadnak el előjel nélküli cél esetén — a "-1" bemenet unsigned célra invalid_argument, nem pedig alulcsordulás, ellentétben a strtoul viselkedésével.

A to_chars(first, last, value) ellenkező irányban dolgozik: nem ír nullterminátort, siker esetén ptr az utolsó kiírt karakter után mutat, és ha a rendelkezésre álló puffer nem elég nagy, ec == std::errc::value_too_large mellett ptr == last. A visszaadott pointer és a puffer eleje közötti különbség adja a hosszt, ezért a tipikus minta így néz ki:

#include <charconv>
#include <cstdint>
#include <string_view>
#include <system_error>

std::string_view render_u16(std::uint16_t v, char (&buf)[8]) {
    auto [ptr, ec] = std::to_chars(buf, buf + sizeof buf, v);
    if (ec != std::errc{}) return {};          // nem fér el: nem lehet
    return {buf, static_cast<std::size_t>(ptr - buf)};
}

32.2.1.1 Lebegőpontos támogatás ellenőrzése

A lebegőpontos to_chars precízió megadása nélkül a legrövidebb, round-trip pontos ábrázolást adja: a visszaolvasott érték bitre azonos lesz az eredetivel. Ez az, amiért naplózáshoz és szerializáláshoz vonzó: nem kell "%.17g"-hez hasonló, feleslegesen hosszú alakot használni, és nem kell attól tartani, hogy a visszaolvasás más értéket ad. A chars_format::fixed, scientific, general (alapértelmezés) és hex mód a megjelenítést szabályozza, a from_chars pedig a „C” locale strtod mintáját követi, azzal a szigorítással, hogy vezető szóközt és + jelet nem fogad el, és a tizedespont mindig ..

Mivel a __cpp_lib_to_chars feature-test makró a gyakorlatban nem feltétlenül különbözteti meg az egész- és a lebegőpontos résztámogatást, a támogatást nem makróra, hanem fordítási és futásidejű próbára érdemes alapozni:

// probe_charconv.cpp — fordítás: g++ -std=c++17 -Wall -Wextra -Wpedantic probe_charconv.cpp -o probe
#include <charconv>
#include <cstdio>
#include <cstring>

int main() {
    char buf[64];
    double in = 0.1;
    auto w = std::to_chars(buf, buf + sizeof buf, in);
    if (w.ec != std::errc{}) { std::puts("to_chars(float): nem tamogatott"); return 1; }

    double out = 0.0;
    auto r = std::from_chars(buf, w.ptr, out);
    if (r.ec != std::errc{} || r.ptr != w.ptr) {
        std::puts("from_chars(float): hiba"); return 2;
    }
    if (std::memcmp(&in, &out, sizeof in) != 0) {
        std::puts("from_chars(float): nem round-trip"); return 3;
    }
    std::puts("ok");
}

A próba eredménye konkrét implementáció és verzió kérdése; a laborprofilban rögzítsd a fordítót és a standard library kiadását, és a próbát tedd a build ellenőrző lépései közé. Ha a lebegőpontos irány hiányzik, a konfigurációban maradj egész típusoknál, vagy izolált adapterben használj strtod-t a from_chars helyett — a locale-t ott explicit „C”-re kell állítani.

32.2.2 std::format: típusos formázás C++20-ban

A std::format(fmt, args...) std::string-et ad vissza, a formátumstring egy Python-szerű mini-nyelvet használ: {}, {0}, {:>8}, {:08x}, {:.2f}. A fmt paraméter típusa std::format_string<Args...>, ami azt jelenti, hogy a könyvtár a formátumstringet fordításidőben ellenőrzi, amennyiben az konstans kifejezés: az argumentumszám, a típuskompatibilitás és a specifikáció helyessége is diagnosztizálható. Ez a printf-vel szembeni legfontosabb nyereség: a %d-vel double-t kiíró hiba nem futásidejű UB, hanem fordítási hiba.

A formázás alapértelmezésben locale-független: ha nem adsz át std::locale objektumot, a könyvtár a classic („C”) locale-t használja, tehát a tizedespont . marad, függetlenül a folyamat LC_NUMERIC beállításától. Ez a naplók és gépi fájlok szempontjából pont az, amit szeretnénk; a lokalizált, emberi felülethez a std::format(loc, fmt, args...) alak kell.

A futásidejű határ ugyanakkor éles: ha a formátumstring nem konstans (például konfigurációból vagy hálózatról jön), a std::format nem használható, mert a format_string nem áll elő futásidőben. Ilyenkor a std::vformat(fmt, args) a belépési pont, ahol fmt std::string_view, args pedig std::format_args:

#include <format>
#include <string>
#include <string_view>

std::string render(std::string_view fmt, int v) {
    return std::vformat(fmt, std::make_format_args(v));
}

Két fontos következmény. Egyrészt három külön élettartamot kell követni. Az argumentumok (a változók, amelyekre hivatkozunk); a std::make_format_args által visszaadott store, amely ezekre hivatkozó argumentumleírókat tárol; és a std::format_args nézet, amely a store-ra mutat. A nézet addig használható, amíg a store és az argumentumok is élnek. Egy nevesített store-ból képzett nézet tehát nyugodtan átadható egy későbbi vformat-hívásnak ugyanabban a blokkban; a hiba az, ha a nézetet egy temporary store-ból mentjük el, mert az a teljes kifejezés végén megszűnik:

// format_store.cpp — C++20: a format_args nézet, a store birtokolja az argumentumokat
#include <cstdio>
#include <format>
#include <string>
#include <string_view>

std::string render_later(std::string_view fmt, std::format_args args) {
    return std::vformat(fmt, args);       // a nézetet csak a hívás idejére használjuk
}

int main() {
    int code = 404;
    std::string path = "/index";
    // Nevesített store: addig él, amíg a blokk; a format_args nézet rá mutat.
    auto store = std::make_format_args(code, path);
    std::format_args args = store;
    std::string a = render_later("{} {}", args);   // helyes: store, code, path él
    std::printf("%s\n", a.c_str());

    // HIBA (nem futtatandó): a store temporary, a teljes kifejezés végén
    // megszűnik, az elmentett nézet lógó:
    // std::format_args bad = std::make_format_args(code, path);
    // std::vformat("{} {}", bad);
}

Buildjegyzőkönyv (lab-gcc profil). g++ -std=c++20 és -std=c++23 alatt is figyelmeztetés nélkül fordul, a kimenet 404 /index.

Másrészt a std::vformat futásidőben validál, és érvénytelen formátumstring esetén std::format_error kivételt dob; ez memóriabiztonság szempontjából jobb, mint a printf UB-je, de nem jelenti azt, hogy a nem megbízható bemenetet szabad formátumstringként használhatnánk: a naplóinjektálás és az erőforrás-kimerítés továbbra is valós támadási felület. Megbízható minta: a formátumstring mindig a kódban konstans, a külső adat pedig argumentum.

32.2.2.1 formatter testreszabása

Saját típus formázásához a std::formatter<T, CharT> specializációját kell megadni. A legegyszerűbb, és egyben a legtöbbet adó fogás a std::string_view formatteréből való öröklés: így a szélesség- és igazítás-specifikáció ingyen működik.

#include <format>
#include <string>
#include <string_view>

enum class Level { info, warn, error };

std::string_view to_string(Level l) {
    switch (l) {
    case Level::info:  return "INFO";
    case Level::warn:  return "WARN";
    case Level::error: return "ERROR";
    }
    return "?";
}

template <>
struct std::formatter<Level, char> : std::formatter<std::string_view, char> {
    auto format(Level l, std::format_context& ctx) const {
        return std::formatter<std::string_view, char>::format(to_string(l), ctx);
    }
};

A specializációnak minden fordítási egységben láthatónak kell lennie, ahol használják, ezért headerbe való. Ha saját specifikációt akarsz (például {:s} rövid névhez), a parse(std::format_parse_context&) tagfüggvényt kell megírni, amely a specifikáció végpontjára mutató iterátort ad vissza, és érvénytelen karakter esetén std::format_error-t dob; a format tag pedig a ctx.out() iterátorral ír.

32.2.3 std::print és std::println: C++23, támogatás és fallback

A C++23 <print> fejléce a std::format motorjára épülő, közvetlen kiírást ad: std::print("port={}", 8080) és std::println(...), utóbbi záró újsorral, valamint FILE*-ot első argumentumként elfogadó változatok. Előnye, hogy a formázott szöveghez nem kell std::string-et építeni a hívó kódjában. Két dolgot ne felejts: a pufferelést nem végzi el helyetted (ha a kimenet azonnali megjelenése a szerződés, gondoskodj a flush-ról), és ne keverd ugyanazon a csatornán a printf-féle és a print-féle írást, mert a sorrendiség nehezen követhetővé válik.

A támogatás verziófüggő és library-kérdés, ezért a kódot feature-test makróval és fallbackkal érdemes írni:

// log_line.cpp — C++17/20/23: képességfüggő kiírás, tiszta fallbackkel
#include <version>      // a feature-test makrók forrása: MINDEN teszt előtt
#include <cstdio>
#include <string>
#include <string_view>

#if defined(__cpp_lib_print) && __cpp_lib_print >= 202207L
#  include <print>
#  define HAS_PRINT 1
#endif
#if defined(__cpp_lib_format) && __cpp_lib_format >= 201907L
#  include <format>
#  define HAS_FORMAT 1
#endif

// Logsor kiírása: C++23 <print>, ha van; különben format + fwrite.
void log_line(std::string_view msg) {
#if defined(HAS_PRINT)
    std::println("{}", msg);                    // C++23
#elif defined(HAS_FORMAT)
    std::string s = std::format("{}\n", msg);   // C++20
    std::fwrite(s.data(), 1, s.size(), stdout);
#else
    std::fwrite(msg.data(), 1, msg.size(), stdout);
    std::fputc('\n', stdout);
#endif
}

int main() {
#if defined(HAS_PRINT)
    log_line("branch=print");
#elif defined(HAS_FORMAT)
    log_line("branch=format");
#else
    log_line("branch=fallback");
#endif
}

A feature-test makrókat a <version> fejléc definiálja (illetve az adott képesség saját fejléce). Az #if tesztek előtt ezért a <version>-t kell include-olni; különben a makró a teszt pontján még nem létezik, és a kód csendben a fallback-ágat választja, akkor is, ha a könyvtár támogatná a képességet. Az aktív ághoz tartozó fejlécet (<format>, <print>) a teszt után, feltételesen vonjuk be, nem véletlen tranzitív include-ra bízzuk. A küszöbök: __cpp_lib_format >= 201907L (C++20 std::format), __cpp_lib_print >= 202207L (C++23 std::print).

g++ -std=c++17 -Wall -Wextra -Wpedantic log_line.cpp -o log_line && ./log_line
g++ -std=c++20 -Wall -Wextra -Wpedantic log_line.cpp -o log_line && ./log_line
g++ -std=c++23 -Wall -Wextra -Wpedantic log_line.cpp -o log_line && ./log_line

Buildjegyzőkönyv (lab-gcc profil). A GCC 13.3 libstdc++-a __cpp_lib_format-ot 202110L értékkel definiálja, __cpp_lib_print-et nem. A kimenet -std=c++17 mellett branch=fallback, -std=c++20 és -std=c++23 mellett branch=format; mindhárom figyelmeztetés nélkül fordult. A <version> include nélküli eredeti változat -std=c++20 mellett is branch=fallback-et adott. A print ágat ebben a profilban nem lehetett ellenőrizni.

A makrók konkrét értéke és a mögöttük álló tényleges támogatás nem azonos; a fordítást és a viselkedést a laborprofilban rögzített compiler/stdlib pároson ellenőrizd. A __cpp_lib_print hiánya nem hiba, csak a fallback ágra terel.

32.2.4 Allokáció, locale és formátumstring-megbízhatóság

A két eszköz költségmodellje eltér. A from_chars/to_chars nem allokál, nem dob, és futásideje kiszámítható; forró naplózási útvonalon, ahol másodpercenként sok tízezer sor keletkezik, ez a különbség mérhető. A std::format std::string-et allokál, a std::format_to egy kimeneti iterátorba ír (például egy előre lefoglalt std::string-be std::back_inserter-rel), a std::formatted_size pedig előre megmondja a szükséges hosszt, amivel egy allokációra szorítható a művelet. Korlátozott méretű pufferbe a std::format_to_n ír; eredménye {out, size}, ahol a size a teljes, levágatlan hossz, tehát a csonkolás detektálható.

#include <format>
#include <string>

std::string line;
line.reserve(128);
std::format_to(std::back_inserter(line), "level={} port={}", "WARN", 8080);

A locale kérdése három külön döntés: a charconv definíció szerint locale-független; a std::format locale nélkül a classic locale-t használja; a std::print/println kimenetének kódolása (a terminál és az ordinary literal encoding viszonya) további, a szabvány és az implementáció dokumentációja szerint ellenőrizendő réteg. A <format> C++23-as chrono- és range-formázói, valamint a Unicode-kézbesítés részletei itt nem téma; ezekre csak a szükséges határjelzésként hivatkozunk.

Végül a megbízhatóság: a fordításidőben ellenőrzött formátumstring a hibák nagy részét kiszűri, de a dinamikus (vformat) út csak futásidőben derül ki. Naplózó komponensben ezért a jó szerződés az, hogy a formátumstring a forrásban konstans literál, a változó adat argumentum, a felhasználói bemenet pedig soha nem kerül a formátum pozíciójába.

32.2.5 Kidolgozott példa: konfiguráció és naplósor

A példa egy port=8080 alakú konfigurációs értéket parsol szigorúan, majd naplósort formáz. A build: g++ -std=c++20 -O2 -Wall -Wextra -Wpedantic main.cpp -o main, illetve clang++ -std=c++20 -O2 -Wall -Wextra -Wpedantic main.cpp -o main. A kimenet elvárt, nem mért eredmény; a saját laborkörnyezetedben futtatva ellenőrizd.

#include <charconv>
#include <cstdint>
#include <format>
#include <optional>
#include <string>
#include <string_view>
#include <system_error>

std::optional<std::uint16_t> parse_u16(std::string_view s) {
    if (s.empty()) return std::nullopt;
    const char* first = s.data();
    const char* last  = first + s.size();
    std::uint16_t v{};
    auto [ptr, ec] = std::from_chars(first, last, v);
    if (ec != std::errc{} || ptr != last) return std::nullopt;  // teljes fogyasztás
    return v;
}

int main() {
    const std::string_view values[] = {"80", "80a", "+80", "-1", "65536", "0"};
    for (std::string_view raw : values) {
        auto v = parse_u16(raw);
        std::string shown = v ? std::format("{}", *v) : std::string{"hiba"};
        std::format_to(std::back_inserter(shown), "");
        std::printf("%-6.*s -> %s\n", static_cast<int>(raw.size()), raw.data(), shown.c_str());
    }
}

A várható sorok: 80 -> 80, 80a -> hiba, +80 -> hiba, -1 -> hiba, 65536 -> hiba, 0 -> 0. A 80a a teljes fogyasztás ellenőrzése nélkül átmenne; a 65536 a result_out_of_range ág; a +80 és a -1 az invalid_argument ág.

32.2.6 Tipikus hibák

32.2.7 Összefoglaló

A <charconv> a gépi konverzió eszköze: [first, last) tartomány, ptr/ec eredmény, teljes fogyasztás-ellenőrzés, nincs allokáció és nincs locale. A lebegőpontos irány round-trip pontos, de implementációtámogatását próbával kell igazolni. A <format> típusos, konstans formátumstring esetén fordításidőben ellenőrzött, locale nélkül a classic locale-t használó réteg; a futásidejű határ a std::vformat, a format_args élettartam-korlátjával. A <print>/<println> C++23-as kényelmi réteg, fallbackkal és a támogatás ellenőrzésével. A döntés rövid szabálya: konfiguráció és metrika charconv, napló format, kimenet print, ha a laborprofil engedi.

32.2.8 Feladatok

  1. Magyarázó. Írd le, miért ad a from_chars("12x") sikeres ec-t, és pontosan melyik két feltétel együttes teljesülése jelent „teljes” parse-t. Egészítsd ki egy olyan esettel, amikor a value tartalma hiba után is érdekes lehet.

  2. Kódolási. Készíts parse_i32(std::string_view) függvényt, amely elfogad egy opcionális vezető + jelet, majd std::from_chars-szal parsol, és csak teljes fogyasztás esetén ad értéket. Mellékelj formatter<Level> specializációt, amely a {:>8} szélesség-specifikációt is támogatja.

  3. Diagnosztikai. Az alábbi két részlet közül melyik hibás és miért? Add meg a javítást és az ellenőrzési feltételt.

    auto args = std::make_format_args(port, level);
    std::string s = std::vformat(fmt, args);   // (A)
    std::uint16_t v{};
    auto [p, ec] = std::from_chars(begin, end, v);
    if (ec == std::errc{}) use(v);              // (B)

32.2.9 Megoldások

1. A from_chars a bemenet előtagját parsolja, nem a teljes tartományt: a "12x" esetén a 12 érvényes minta, ezért ec == std::errc{} és ptr az 'x'-re mutat. Teljes parse akkor van, ha ec == std::errc{} és ptr == last. A value hiba esetén a szabvány szerint változatlan marad, de ha nem inicializáltad, az olvasása önmagában nem definiált viselkedés — ezért a mintában std::uint16_t v{}; szerepel. Ellenőrzési feltétel: a "80a", " 80", "+80", "-1", "65536", "" bemenetek mind elutasítva, a "0" és a "65535" elfogadva.

2. A + jelet a from_chars nem fogadja el, ezért kézzel kell levágni, majd a maradékra hívni a konverziót; ügyelni kell arra, hogy a csupasz "+" ne menjen át.

#include <charconv>
#include <cstdint>
#include <optional>
#include <string_view>
#include <system_error>

std::optional<std::int32_t> parse_i32(std::string_view s) {
    if (s.empty()) return std::nullopt;
    if (s.front() == '+') {
        s.remove_prefix(1);
        if (s.empty()) return std::nullopt;
    }
    std::int32_t v{};
    auto [ptr, ec] = std::from_chars(s.data(), s.data() + s.size(), v);
    if (ec != std::errc{} || ptr != s.data() + s.size()) return std::nullopt;
    return v;
}

A formatter a std::string_view formatteréből örökölve kapja a szélességet; a format const tag kell, hogy a format_context-tel működjön. Ellenőrzési feltétel: std::format("{:>8}", Level::warn) eredménye " WARN" (8 karakter), std::format("{}", Level::error) eredménye "ERROR"; a parse_u16 esetében a "-1" és a "+" bemenet nullopt.

3. Az (A) az élettartam-hiba: a make_format_args a port és level referenciáit csomagolja, a format_args csak a teljes kifejezésig érvényes, így a következő sorban történő felhasználás dangling. Javítás: a vformat hívás legyen ugyanabban a kifejezésben, vagy konstans formátumstring esetén használj std::format-ot. A (B) a teljes fogyasztás hiánya: a "80a" bemenet sikeresen átmegy, és rossz értéket használ. Javítás: if (ec == std::errc{} && p == end) use(v);. Ellenőrzési feltétel: a (B) javított változata a "80a"-t elutasítja, a "80"-t elfogadja; az (A) javított változata nem tartalmaz a vformat híváson kívül tárolt format_args objektumot.