Az std::string_view osztály

Czirkos Zoltán · 2022.06.21.

Hány sztring osztályunk van egy projekten belül? Mennyi bosszúságot okoz ez? Hogy segíthetünk ezen?

C++ tanulmányaink során elég nehéz megúszni egy sztring osztály elkészítését. Vagy többét. A sztring igazi állatorvosi ló, amelyen mindenféle memóriakezelési probléma bemutatható. A helyzet az, hogy valós projektek is tele vannak egyedi fejlesztésű sztring osztályokkal.

Sztringek, sztringek mindenhol

A problémát az okozza, hogy maga a nyelv beépített típusként nem ismeri a sztringet. Létezik a karaktertömb – beépített típusként –, de ez nem adható át értékként. Létezik a szabványosított sztring osztály, az std::string – ez meg valójában csak egy osztály, amely nem igazán része a nyelvnek. Ez kényelmetlenségeket okoz, és kezdők számára érthetetlen helyzeteket teremt. Hogy magyarázzuk meg ezeket a furcsa kimeneteket?

#include <iostream>
#include <string>

int main() {
    std::string h = "hello";
    
    std::cout << h + "!";           // hello!
    std::cout << "!" + h;           // !hello
    std::cout << "hello" + "!";     // compile error  (???!?!)
    
    std::cout << h + '!';           // hello!
    std::cout << '!' + h;           // !hello
    std::cout << '!' + '!';         // 66  (??!?!)
}

Ez csak a szabványos sztring volt. Hab a tortán az összes többi keretrendszer saját sztring osztálya. Bármilyen alkalmazás- vagy grafikus keretrendszert használunk, nem ússzuk meg, hogy előbb-utóbb előkerüljenek ezek is: QString, wxString és a többiek.

1. Paraméterként átadott sztring?

Maradjunk most a szabványos karaktertömbnél és sztring osztálynál. A kétféle reprezentáció nemcsak kényelmetlen, hanem a hatékonyságot is rontja. Tegyük fel, hogy szeretnénk átadni egy függvénynek paraméterként egy sztringet, például egy megnyitandó fájl nevét.

Hogyan tesszük ezt? Első ötletünk a megszokott char const * lehet: nullával lezárt karaktertömbre mutató pointer. Ez azoknak kellemetlen, akiknek a hívás helyén egy std::string objektumban van az információ:

void fajlt_beolvas(char const *fajlnev) {
    // ...
}

int main() {
    std::string fn;
    std::cin >> fn;
    fajlt_beolvas(fn.c_str());  // :(
}

A problémát az okozza, hogy az std::string osztálynak nincsen char const * konverziója. Ez jogos, általában véve elég rossz ötlet beépített típusra implicit konverziót definiálni, mert szinte bármilyen műveletnél megtalálná a beépített operátorokat a fordító. Ehelyett a sztring osztálynak .c_str() tagfüggvénye van, amely az objektum által kezelt memóriaterületre mutató pointert ad. Viszont kényelmetlen mindig kiírni a metódushívást a paraméterátadás helyén.

Próbálkozzunk meg akkor inkább egy std::string átvételével!

void fajlt_beolvas(std::string const & fajlnev) {
    // ...
}

int main() {
    fajlt_beolvas("adat.txt");  // std::string("adat.txt");
}

Ilyenkor kisebb hatékonytalanságba futunk bele. Ha a hívónak nem sztringje van, hanem C-s karaktertömbje, akkor egy konverziót kell végrehajtani: meghívni az std::string(char const *) paraméterű konstruktorát. Bár ez megtörténik automatikusan, de ez a konstruktor létrehozza a sztring másolatát dinamikus memóriaterületen – ami pedig teljesen felesleges volt.

2. Az std::string_view osztály

A megoldást egy olyan osztály adja, amelyik:

  • Automatikusan, konverzióval létrehozható – a nyers pointerrel ellentétben.
  • Nem végez memóriakezelést – ahogy a nyers pointernél is ez teljesül.

Vegyük észre, hogy valójában az std::string memóriakezelésére itt nincsen szükségünk. A függvény első változata, a char const * paraméterű, már rámutatott erre: amíg a függvény belsejében vagyunk, és nem térünk vissza a hívóhoz, addig a hívó nem is fogja kivenni a pointer alól a karaktertömböt. Ezért nincsen szükség másolatra se. Egy olyan objektum kell csak, amelyik hivatkozik egy karaktertömböt a memóriában, de nem ő maga kezeli annak élettartamát.

A keresett osztály az std::string_view, amelyik a C++17 óta létezik. A legegyszerűbb használatához a következőket kell tudni:

  • Eltárol egy hivatkozást egy sztringhez, de nem másolja le azt. Tudja, hogy hol van egy karaktertömb eleje és vége a memóriában, de nem ő foglalja, és nem is ő szabadítja fel azt. Úgy képzelhetjük el, mint egy szöveg referenciáját – erre utal a view szó a nevében.
  • Van std::string_view(char const *) konstruktora, tehát képes „becsomagolni” egy nullával lezárt karaktertömböt.
  • Van std::string::operator std::string_view() konverzió is, tehát egy meglévő sztring objektumból is létrehozható.
  • Egyébiránt sztringként viselkedik, pl. == operátorral egyenlőségre vizsgálható, [] operátorral indexelhető stb.

Ez kényelmesen és hatékonyan használható mindkét esetben:

#include <string_view>

void fajlt_beolvas(std::string_view fajlnev) {
    // ...
}

int main() {
    fajlt_beolvas("adat.txt");  // std::string_view("adat.txt"),
                                // de nem készül dinamikus másolat!
    
    std::string fn;
    std::cin >> fn;
    fajlt_beolvas(fn);          // fn.operator std::string_view(),
                                // és automatikus a konverzió!
}

A használatakor arra kell ügyelni, hogy a string_view objektum ne élje túl a karaktertömböt, azaz a hivatkozott sztring objektumot. Leginkább emiatt függvényparaméternek alkalmas ez a típus.

3. Sztringnek látszó tárgy

Az std::string_view összehasonlító operátorokkal, iterátorokkal és egyéb tagfüggvényekkel is rendelkezik. Ezért célravezetőbb lehet sok helyen ezt használni sima char const * helyett.

#include <string_view>
#include <iostream>

int main() {
    char s1[100], s2[100];
    std::cin >> s1 >> s2;
    
    if (std::string_view(s1) == std::string_view(s2)) { // operator==
        std::cout << "egyforma" << std::endl;
    }
    
    int cnt = 0;
    for (char c : std::string_view(s1))                 // iterator
        if (c == 'x')
            ++cnt;
    std::cout << cnt << std::endl;
}

Ezek a példák persze kicsit sántítanak, mert char const * konstruktorparaméter esetén a string_view kénytelen a konstruktorban meghatározni a sztring hosszát – és az összehasonlításhoz, illetve a megszámláláshoz amúgy is végig kellett volna menni a tömbön.

4. Részsztringek kezelése

Vannak olyan sztringműveletek, amelyek részsztringeket állítanak elő. Például:

  • std::string::substr(pos, len) – részsztring előállítása, mint alapművelet.
  • Egy sztring elejéről és végéről a szóközök levágása – ezt trim()-nek szoktuk nevezni.
  • Sztring darabolása – split().

Ha ezekkel std::string objektum(oka)t állítunk elő, akkor az lassú lehet, hiszen minden egyes részsztring előállítása ilyenkor dinamikus memóriakezelést, tömbmásolást jelent. Viszont ha tudjuk, hogy a darabolt/vágott sztring megmarad a memóriában, string-ek helyett string_view objektumokat is létrehozhatunk, amelyek csak hivatkoznak a vágott részre.

Pl. a trim() művelethez:

#include <iostream>
#include <string>
#include <string_view>

std::string_view trim(std::string_view s) {
    /* szóközök az elején */
    size_t begin = 0;
    while (begin < s.length() && s[begin] == ' ')
        ++begin;
    
    /* szóközök a végén */
    size_t end = s.length();
    while (end > begin && s[end - 1] == ' ')
        --end;
    
    return s.substr(begin, end - begin);    // string_view::substr
}

int main() {
    std::cout << "Írj be egy sort!\n";
    std::string eredeti;
    std::getline(std::cin, eredeti);
    
    std::string_view vagott = trim(eredeti);
    std::cout << '[' << vagott << ']';
}

Hasonlóképp működhetne a split() is, amelyik std::vector<std::string_view> objektumot állítana elő.