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.
std::basic_stringA 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.
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:
reserve(n), C++20: ha
n > capacity(), a kapacitás legalább n
lesz; ha n <= capacity(), a hívásnak nincs
hatása — a C++20 reserve soha nem csökkenti a
kapacitást (P0966). A reserve(0) tehát nem zsugorít.reserve(n), C++17 és korábban: a
capacity()-nél kisebb n nem kötelező
zsugorítási kérés volt, amelyet az implementáció figyelmen kívül
hagyhatott.shrink_to_fit(): nem kötelező kérés a
kapacitás size()-ra csökkentésére, minden verzióban.reserve() argumentum nélkül: C++20-ban
elavult (deprecated); a jelentése a shrink_to_fit() kérés.
Helyette a shrink_to_fit()-et írjuk.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.
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.
std::string_viewA 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_view sv(ptr, len) — a pointer és a hossz
közvetlenül adott, beágyazott '\0' is belekerülhet.std::string_view sv(ptr) vagy
std::string_view sv = "szöveg" — a
Traits::length hosszszámítást használja, ami az első
'\0'-nál megáll; ez strlen-nel ekvivalens
költség, azaz
.std::string-ből a view implicit konverzióval keletkezik,
és a substr
művelet, mert csak a pointert és a hosszt állítja be újra.
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.
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.
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 tokenizeBuildjegyző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.
char8_t kitekintésA 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.
std::string_view f() { std::string s = "x"; return s; } — a
s a visszatéréskor megszűnik, a view dangling. Javítás:
adjunk vissza std::string-et (értékszemantika, RVO), vagy a
view csak paraméterből származzon.std::string_view tagot tárol, a konstruktorában kapott
temporaryre nem támaszkodhat. Javítás: std::string tag,
vagy dokumentált, hogy a tag csak egy másik, hosszabb életű ownerre
mutathat.printf("%s", sv.data()) akkor hibás, ha a view nem
stringből vagy literálból származik, illetve ha
data() == nullptr. Javítás:
fwrite/std::ostream::write a hosszal, vagy
ideiglenes std::string a C API határán; %.*s
csak NUL-mentes, int-be férő view-ra.std::string s = "a\0b"; után s.size() == 1.
Javítás: pointer+hossz konstruktor.sizeof-ra építés. A „rövid
string nem allokál” állítás implementációfüggő; teljesítménykritikus
kódban mérni kell, nem feltételezni.auto sv = std::string_view(v); v += "x"; use(sv); — a
növelés újraallokálhat. Javítás: a view-t a módosítás után újra képezni,
vagy a módosítás előtt lezárni a view használatát.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.
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); }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.
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"));std::string s("a\0b", 3);
std::printf("[%s]\n", s.c_str());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.