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)).
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:
ec == std::errc::invalid_argument és
ptr == first;ec == std::errc::result_out_of_range, és a
value változatlan marad;ec == std::errc{} és ptr az
utolsó illeszkedő karakter utáni pozícióra mutat.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)};
}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.
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.
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.
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_lineBuildjegyző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.
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.
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.
ec-t vizsgálsz. A részleges parse
csendben rossz konfigurációt ad. Javítás:
ec == std::errc{} && ptr == last.to_chars eredményét C-sztringként
kezeled. Nincs nullterminátor; a hossz
ptr - buf.value_too_large hibát, és a puffert a típus maximális
hosszára méretezd (16 bites előjel nélkülihez 5 számjegy elég, de hagyj
tartalékot).std::format futásidejű stringgel.
Fordítási hiba; a szándék std::vformat, a
format_args élettartamának tiszteletben tartásával.format_args eltárolása. A teljes
kifejezésen túl dangling; a vformat hívást ugyanabban a
kifejezésben tartsd.format_error- kockázat; a séma legyen
konstans.charconv és a
locale nélküli format nem követi a
LC_NUMERIC-et; ne a locale-tól várd a tizedespont
kezelését.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.
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.
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.
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)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.