28.2 std::string, std::string_view és szövegownership

A szöveg az eddigi tárolóknál megszokott kérdéseket — folytonos tárolás, méret, kapacitás, invalidáció — egy új dimenzióval egészíti ki: a szöveg gyakran nem önálló objektum, hanem egy nagyobb buffer egy szelete, miközben a C API-k többsége nullterminált const char*-t vár. A modern C++ ezt a kettősséget két, élesen eltérő szerződésű típusra bontja: a std::string tulajdonol és életciklust visel, a std::string_view nem tulajdonol, csak hivatkozik egy máshol élő bájttartományra. Az alábbiakban nem tagfüggvény-katalógust adunk, hanem azt a szerződést, amely alapján eldönthető, melyik típus kerüljön egy függvényszignatúrába, egy tagváltozóba vagy egy visszatérési értékbe. A std::array, std::vector és folytonos tárolás (28.1) tárolószerződéseire és az Owner, view és erőforrásszerződés C++ API-ban (21.5) owner/view felosztására építünk; a kód C++20-at és explicit -std=c++20 kapcsolót feltételez, GCC vagy Clang fordítóval.

28.2.1 A tulajdonló szöveg: std::basic_string

A std::string valójában std::basic_string<char>; az általános alak

template<class CharT,
         class Traits = std::char_traits<CharT>,
         class Allocator = std::allocator<CharT>>
class basic_string;

A Traits adja a karakterek összehasonlítását, hosszszámítását és keresését; a std::char_traits<char> nem locale-érzékeny, egyszerű bájtösszehasonlítást végez. Ez fontos: a std::string szemantikája bájtokban értendő, nem „karakterekben” valamely kódolás szerint. A basic_string tulajdonolja a puffert, gondoskodik annak felszabadításáról, és gondoskodik arról, hogy az elemek folytonosan helyezkedjenek el.

28.2.1.1 Méret, kapacitás és folytonos hozzáférés

A basic_string invariánsa: az elemek a [data(), data() + size()) félig nyílt tartományban folytonosan helyezkednek el, és a data() + size() címen egy CharT() érték (azaz '\0') található. C++11 óta a data() és a c_str() ugyanazt a pointert adja vissza, és mindkettő nullterminált; a korábbi szabványokban ez a data()-ra nem volt igaz. A size() a tényleges hossz, a capacity() a lefoglalt tároló hossza, mindig size() <= capacity(). A kapacitásműveletek szabálya szabványverziónként eltér:

A lab-gcc profilban egy 100 kapacitású, kiürített stringen a reserve(0) után a kapacitás -std=c++17 és -std=c++20 alatt is 100 maradt, a shrink_to_fit() után 15 lett (az SSO-puffer mérete). Ebből is következik, hogy a capacity() konkrét értékére építeni hordozhatatlan.

A c_str() szerződése kizárólag annyi, hogy a pointer egy nullterminált tömb kezdetére mutat. Ha a string beágyazott '\0'-t tartalmaz, a c_str() által mutatott tömb a C függvények számára az első nullánál „véget ér”, holott a size() nagyobb lehet. Ez nem hiba a std::string részéről, hanem a C API korlátja.

28.2.1.2 Small string optimization: implementációfüggő részlet

A gyakorlatban minden elterjedt implementáció small string optimizationt (SSO) alkalmaz: egy kis puffert magában az objektumban tárol, így a rövid stringekhez nem kell heap-allokáció. A küszöb és az objektum mérete azonban implementációfüggő, és nem része a szabványnak. A libstdc++ (GCC) tipikus felépítésében a std::string 32 bájtos, és 15 karakter fér el inline; a libc++ (Clang) és a MSVC könyvtára eltérő elrendezést és küszöböt használhat. Ezek az adatok a konkrét implementáció dokumentációjában ellenőrizendők, és nem használhatók hordozható kódban arra, hogy „ez a string biztosan nem allokál”. A gyakorlati következmény mégis valós: a rövid stringek másolása és mozgatása olcsóbb lehet, mint a hosszúaké, és a mozgatás SSO esetén másolást jelent, ami a mutatókra nézve meglepő lehet.

28.2.2 A nem tulajdonló nézet: std::string_view

A std::string_view egy nem tulajdonló, olcsón másolható leírás egy létező CharT-tartományról. Fogalmilag két adatból áll: egy const CharT* pointerből és egy méretből; a pontos elrendezés implementációfüggő, de a triviális másolhatóság és a kis méret gyakorlatilag garantált viselkedés. A string_view nem foglal memóriát, nem futtat destruktort, és nem hosszabbítja meg semminek az élettartamát. Ez a szerződés egyben a felelősség: a view addig érvényes, amíg a mögötte álló tartomány él, és amíg azt nem módosítják érvénytelenítő módon.

Két konstrukciós út különbözik:

std::string-ből a view implicit konverzióval keletkezik, és a substr O(1)O(1) művelet, mert csak a pointert és a hosszt állítja be újra.

28.2.2.1 Miért nem garantált a nullterminátor

A string_view egyetlen garantált tartománya [data(), data() + size()). Arra nincs garancia, hogy data()[size()] olvasható, hogy ott '\0' áll, vagy hogy a mögötte lévő memória egyáltalán a hívóhoz tartozik. Egy std::string-ből készült view esetében a nullterminátor gyakran tényleg ott van, de ez a string tulajdonsága, nem a view-é. Egy literálból készült view-nál szintén ott van, egy nagyobb buffer szeleténél viszont a szelet vége után a buffer következő bájtja áll — ami bármi lehet. Ezért printf("%s", sv.data()) vagy bármely const char*-t váró C függvény hívása sv.data()-val csak akkor helyes, ha külön bizonyítottuk a nulltermináltságot.

Ugyanez a string_view-ra: az alapértelmezett konstrukció data() == nullptr és size() == 0. A nullptr átadása %s-nek undefined behavior, tehát a „üres view biztonságos” reflex hibás; a C API határán mindig ellenőrizni kell a pointert és a hosszt, vagy explicit nullterminált std::string-et kell készíteni.

28.2.3 Érvénytelenség: mikor lesz a view dangling

A basic_string a nem const tagfüggvények túlnyomó részével érvénytelenítheti a rá mutató pointereket, referenciákat, iterátorokat és view-kat. A szabvány erre a típusra külön, dokumentált listát ad (a C++20 szövegben a [string.require] szakaszban); ennek pontos tartalmát a választott szabványverzióban kell ellenőrizni. A gyakorlati ökölszabály:

Művelet A std::string tartalma A rá mutató view
push_back, append, insert, reserve, nagyobb resize módosul, újraallokálhat érvénytelen lehet (allokáció esetén biztosan az)
erase, clear, shrink_to_fit, kisebb resize módosul nem garantáltan érvényes
operator[], at, data, begin/end (const) nem módosul érvényes marad

Külön kezelendő a temporary élettartama. A std::string_view v = std::string("a") + "b"; sorban a jobb oldalon egy std::string temporary keletkezik, amely a teljes kifejezés végén megszűnik; a v már a következő utasításban dangling. Ugyanez a hiba a return std::string("x").substr(0, 1); alakban is: a temporary a return után nem él. Hasonlóan veszélyes a std::string tagot tartalmazó osztály c_str()-jéből készített view, ha az osztály később módosítja a stringet. És mivel a mozgatás SSO esetén másolás lehet, a mozgatott stringre korábban készített view-ra sem lehet hordozhatóan támaszkodni.

28.2.4 Kidolgozott példa: kulcs–érték sorok feldolgozása

A példa egy tulajdonos stringből indul, a feldolgozó függvény string_view-kat ad vissza, és a hívó felelőssége, hogy az owner túlélje a view-k használatát. A kiírás a C formázó helyett fwrite-ot használ, mert a hossz alapján minden bájtot ki kell írni, a beágyazott '\0'-t is.

// tokenize.cpp — C++20, GCC/Clang, -std=c++20
#include <cstdio>
#include <string>
#include <string_view>
#include <vector>

using namespace std::string_literals;

// Minden bájtot kiír, a beágyazott '\0'-t is; a hosszt a view adja.
void put(std::string_view s) { std::fwrite(s.data(), 1, s.size(), stdout); }

// Előfeltétel: a 'text' által mutatott bájtok a visszaadott view-k
// teljes élettartama alatt élnek és nem módosulnak.
std::vector<std::string_view> split(std::string_view text, char sep) {
    std::vector<std::string_view> out;
    std::size_t begin = 0;
    while (true) {
        std::size_t end = text.find(sep, begin);
        if (end == std::string_view::npos) end = text.size();
        out.push_back(text.substr(begin, end - begin));
        if (end == text.size()) break;
        begin = end + 1;
    }
    return out;
}

int main() {
    std::string owner = "alpha=1;beta=2;raw=a\0b"s;   // s-literál: a \0 is benne marad
    auto fields = split(owner, ';');   // a view-k az owner pufferére mutatnak
    for (std::string_view f : fields) {
        std::size_t eq = f.find('=');
        if (eq == std::string_view::npos) continue;
        std::string_view key = f.substr(0, eq);
        std::string_view val = f.substr(eq + 1);
        // Hosszal adjuk át a C API-nak: nem feltételezzük a nullterminátort.
        put(key); put(" -> "); put(val); put("\n");
    }
}
g++ -std=c++20 -Wall -Wextra -Wpedantic -O2 tokenize.cpp -o tokenize
# vagy: clang++ -std=c++20 -Wall -Wextra -Wpedantic -O2 tokenize.cpp -o tokenize

Buildjegyzőkönyv (lab-gcc profil). A program figyelmeztetés nélkül fordult; a kimenet bájtonként (od -c) alpha -> 1, beta -> 2, majd raw -> a\0b — a beágyazott NUL és az utána álló b is kiíródik.

Az owner (owner) élettartama hosszabb, mint a fields-é, és a ciklus alatt nem módosul, ezért a view-k érvényesek. A printf("%.*s", n, p) itt nem lenne elég: a precízió csak felső korlát, a %s konverzió az első '\0'-nál akkor is megáll, ha a megadott hossz nagyobb; ráadásul a hossz int, így INT_MAX-nál hosszabb view nem adható át. A %.*s tehát csak akkor helyes, ha a szerződés kimondja, hogy a view nem tartalmaz NUL-t, és a mérete int-be fér:

// precision_s_nul.cpp — C++20: %.*s a NUL-nál megáll, fwrite nem
#include <cstdio>
#include <string_view>

int main() {
    std::string_view sv("a\0b", 3);
    std::printf("%.*s", static_cast<int>(sv.size()), sv.data());   // csak "a"
    std::fputc('|', stdout);
    std::fwrite(sv.data(), 1, sv.size(), stdout);                  // 61 00 62
}

Buildjegyzőkönyv (lab-gcc profil). A precision_s_nul kimenete bájtonként 61 7c 61 00 62: a %.*s csak az a-t írta ki (61), a | (7c) után az fwrite a teljes 61 00 62 sorozatot. Ha a ciklus törzsében owner += "x"; állna, a fields elemei érvénytelenné válhatnának, és a további olvasás undefined behavior lenne.

28.2.5 Beágyazott NUL, UTF-8 és a char8_t kitekintés

A std::string bájtsorozat, ezért beágyazott nullát is képes tárolni: std::string s("a\0b", 3); hossza 3. Ugyanez literalból (std::string s = "a\0b";) hossza 1, mert a const char* konstruktor Traits::length-et használ. A string_view ugyanígy viselkedik: a pointer+hossz konstruktor megőrzi a beágyazott nullát, a pointer-only alak nem. A c_str()-t és a data()-t C API-nak átadva mindig a size() a mérvadó, nem a nullterminátor.

UTF-8 esetén a size() bájtokban számol, nem karakterekben (code pointokban): egy ékezetes magyar betű két bájt, egy emoji akár négy. A substr bájtindexeken dolgozik, ezért egy view szeletelése könnyen egy UTF-8 szekvencia közepére eshet, ami érvénytelen szöveget ad — ezt a szintet a view nem ellenőrzi. A C++20 a char8_t és a std::u8string bevezetésével külön típust adott az UTF-8 szövegnek; a std::string és a std::u8string között nincs implicit konverzió, a u8"..." literál típusa C++20-ban const char8_t[]. Ennek részletei a Szövegkódolás, locale és UTF-8 C-ben (7.5) C-s UTF-8 áttekintésére épülve a későbbi szövegkezelési részben (Könyv, „C++ szöveg, fájlok, idő és könyvtári I/O”) kerülnek sorra; itt csak annyi a szerződés, hogy a std::string nem garantál érvényes UTF-8-at, és a view sem.

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

28.2.7 Összefoglaló

A std::string tulajdonos: folytonos, nullterminált puffert tart, size()/capacity() szerződéssel, implementációfüggő SSO-val. A std::string_view pointer és hossz párja: olcsó, nem tulajdonol, nem garantál nullterminátort, és érvényessége az owner élettartamához és változatlanságához kötött. A döntés szabálya egyszerű: ha a szöveget tárolni, módosítani vagy tulajdonolni kell, std::string; ha csak egy hívás idejére olvasni kell egy létező tartományt, std::string_view. C API határán a hosszt kell átadni, nem a pointert önmagában. A bájtalapú szemantika miatt a size() nem karakterszám, és UTF-8 szeletelésnél a view nem véd meg a szekvencia közepére vágástól.

28.2.8 Feladatok

  1. Magyarázó feladat. Az alábbi három függvény közül melyik ad érvényes view-t, és milyen feltétellel? Indokolj.
std::string_view f1(const std::string& s) { return s; }
std::string_view f2() { std::string s = "x"; return s; }
std::string_view f3(const std::string& s) { return std::string_view(s).substr(0, 2); }
  1. Kódolási feladat. Írj két függvényt: std::string_view trim(std::string_view) és std::string trim_owned(std::string_view). Mindkettő vágja le az elejéről és végéről a szóközt. Magyarázd el, melyik milyen élettartam-szerződést kíván a hívótól.

  2. Diagnosztikai feladat. Az alábbi osztály egy konfigurációs értéket tárol, de a konstruktorát temporary stringgel hívják. Találd meg a hibát, javítsd, és add meg az ellenőrzési feltételt.

class Label {
public:
    explicit Label(std::string_view s) : s_(s) {}
    std::string_view get() const { return s_; }
private:
    std::string_view s_;
};
// hívás: Label l(std::string("temp"));
  1. Diagnosztikai feladat. Mit ír ki az alábbi program, és miért? Írj olyan változatot, amely a teljes tartalmat kiírja.
std::string s("a\0b", 3);
std::printf("[%s]\n", s.c_str());

28.2.9 Megoldások

1. megoldás. f1 érvényes view-t ad, de csak addig, amíg a hívó s objektuma él és nem módosul érvénytelenítő módon; a visszatérési érték tehát a hívó felelőssége. f2 hibás: a lokális s a visszatéréskor megszűnik, a view dangling, használata undefined behavior. f3 érvényes: a std::string_view(s) temporary csak egy pointer–hossz pár, amely az s pufferére mutat; a temporary megszűnése nem érinti az s-et, így a visszaadott view ugyanaddig érvényes, amíg s él. Ellenőrzési feltétel: a hívás helyén igazolható, hogy az argumentum élettartama túléli a view használatát; ezt kommentben vagy szerződésben rögzíteni kell.

2. megoldás. A view-változat nem allokál és nem másol, de a visszaadott szelet az eredeti bufferre mutat, ezért a hívónak kell biztosítania, hogy az eredeti szöveg tovább él. A tulajdonos változat új stringet készít, így önállóan érvényes marad, cserébe allokálhat és másol.

std::string_view trim(std::string_view s) {
    std::size_t b = 0, e = s.size();
    while (b < e && (s[b] == ' ' || s[b] == '\t' || s[b] == '\n')) ++b;
    while (e > b && (s[e-1] == ' ' || s[e-1] == '\t' || s[e-1] == '\n')) --e;
    return s.substr(b, e - b);   // O(1), az eredeti bufferre mutat
}

std::string trim_owned(std::string_view s) {
    std::string_view t = trim(s);
    return std::string(t);        // másol, önálló tulajdonost ad
}

Ellenőrzési feltétel: a trim eredményét csak addig szabad használni, amíg az argumentum mögötti buffer él és változatlan; a trim_owned eredménye függetlenül érvényes. Mindkét függvény helyes beágyazott '\0'-t tartalmazó bemenetre is, mert indexel és nem strlen-t használ.

3. megoldás. A hiba a temporary élettartama: a std::string("temp") a Label konstruktorát kiszolgáló teljes kifejezés végén megszűnik, a s_ view pedig a megszűnt pufferre mutat; a get() hívása dangling referenciát ad. A view nem hosszabbítja meg a temporary élettartamát. Javítás: a tag legyen tulajdonos, és a konstruktor másoljon.

class Label {
public:
    explicit Label(std::string s) : s_(std::move(s)) {}
    const std::string& get() const { return s_; }
private:
    std::string s_;
};

Alternatív, ha a másolás elkerülése a cél: a konstruktor csak const std::string&-et fogadjon el, és a szerződés rögzítse, hogy a hivatkozott stringnek túl kell élnie a Label-t — ez azonban törékeny, ezért alapértelmezésben a tulajdonos tag a helyes választás. Ellenőrzési feltétel: a get() által visszaadott referenciát használó kód forduljon le és fusson sanitizerrel (pl. AddressSanitizer) anélkül, hogy a Label élettartamán kívül hozzáférne; a javított változatban a temporarys hívás is biztonságos.

4. megoldás. A program [a]-t ír ki. Az s három bájtot tárol ('a', '\0', 'b'), a size() értéke 3, de a c_str() által mutatott tömb a C printf számára az első nullánál véget ér, mert a %s nullterminált stringet vár. Ez nem undefined behavior, hanem a C API és a std::string eltérő hosszfogalmának következménye. A teljes tartalom kiírásához hosszt kell átadni:

std::printf("[%.*s]\n", static_cast<int>(s.size()), s.data());
// vagy: std::fwrite(s.data(), 1, s.size(), stdout);

Ellenőrzési feltétel: a kimenet [a + NUL + b] bájtsorozat legyen, azaz a printf híváson kívül a hossz 3; a fwrite-os változat akkor is helyes, ha a tartalom tetszőleges bájtokat tartalmaz. A %.*s és a %s közötti különbség tudatosítása a feladat lényege: a string_view ugyanezen okból soha nem adható át %s-nek hossz nélkül.