На цьому уроці ми розглянемо клас std::string_view, який є нововведенням стандарту С++17.
Проблеми рядків
На уроці про рядки C-style ми говорили про небезпеки, які виникають при їх використанні. Звичайно, рядки C-style працюють швидко, але при цьому їх використання не є таким вже й простим і безпечним в порівнянні з std::string.
Правда, варто відзначити, що і у std::string є свої недоліки, особливо при використанні константних рядків.
Розглянемо наступний приклад:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
#include <iostream> #include <string> int main() { char text[]{ "hello" }; std::string str{ text }; std::string more{ str }; std::cout << text << ' ' << str << ' ' << more << '\n'; return 0; } |
Результат виконання програми:
hello hello hello
Усередині функції main() виконується копіювання рядка hello
3 рази, в результаті чого ми отримуємо 4 копії вихідного рядка:
перша копія — це безпосередньо сам рядковий літерал hello
, який створюється на етапі компіляції і зберігається у бінарному вигляді;
ще одна копія створюється при ініціалізації масиву типу char;
далі йдуть об’єкти str
і more
класа std::string, кожен з яких, в свою чергу, створює ще по одній копії рядка.
Через те, що клас std::string спроектований так, щоб його об’єкти могли бути модифіковані, кожному об’єкту класу std::string доводиться зберігати свою власну копію рядка. Завдяки цьому, початковий рядок може бути змінений без впливу на інші об’єкти std::string.
Це також справедливо і для константних рядків (const std::string
), незважаючи на те, що подібні об’єкти не можуть бути змінені.
Клас std::string_view
В якості наступного прикладу візьмемо вікно у вашому будинку і автомобіль, що стоїть на вулиці неподалік. Ви можете подивитися через вікно і побачити машину, але при цьому ви не можете доторкнутися до неї або пересунути її. Ваше вікно лише забезпечує вигляд на автомобіль, який є окремим незалежним об’єктом.
У стандарті C++17 додали ще один спосіб використання рядків — за допомогою класу std::string_view, який знаходиться в заголовку string_view.
На відміну від об’єктів класу std::string, які зберігають свою власну копію рядка, клас std::string_view забезпечує представлення (англ. “view”) для заданого рядка, який може бути визначений де-небудь в іншому місці.
Спробуємо переписати код з попереднього прикладу, замінивши кожне входження std::string
на std::string_view
:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
#include <iostream> #include <string_view> int main() { std::string_view text{ "hello" }; // представлення для рядка "hello", яке зберігається у бінарному вигляді std::string_view str{ text }; // представлення цього ж рядка - "hello" std::string_view more{ str }; // представлення цього ж рядка - "hello" std::cout << text << ' ' << str << ' ' << more << '\n'; return 0; } |
В результаті ми отримаємо такий самий результат, як і в попередньому прикладі, але при цьому у нас не будуть створені зайві копії рядка hello
. Коли ми копіюємо об’єкт класу std::string_view, то новий об’єкт std::string_view буде “дивитися” на той же рядок, на який “дивився” початковий об’єкт. Крім того, клас std::string_view не тільки швидкий, але і володіє багатьма функціями, які ми розглядали при роботі з класом std::string:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
#include <iostream> #include <string_view> int main() { std::string_view str{ "Trains are fast!" }; std::cout << str.length() << '\n'; // 16 std::cout << str.substr(0, str.find(' ')) << '\n'; // Trains std::cout << (str == "Trains are fast!") << '\n'; // 1 // Начиная с C++20 std::cout << str.starts_with("Boats") << '\n'; // 0 std::cout << str.ends_with("fast!") << '\n'; // 1 std::cout << str << '\n'; // Trains are fast! return 0; } |
Оскільки об’єкт класу std::string_view не створює копії рядка, то, змінивши початковий рядок, ми, тим самим, вплинемо і на його представлення у зв’язаному з ним об’єктом std::string_view:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
#include <iostream> #include <string_view> int main() { char arr[]{ "Gold" }; std::string_view str{ arr }; std::cout << str << '\n'; // Gold // Змінюємо 'd' на 'f' в arr arr[3] = 'f'; std::cout << str << '\n'; // Golf return 0; } |
Змінюючи arr
, можна бачити, як змінюється і str
. Це відбувається через те, що початковий рядок є спільним для цих змінних. Варто відзначити, що при використанні об’єктів класу std::string_view краще уникати модифікування вихідного рядка, поки існують пов’язані з ним об’єкти класу std::string_view, інакше це може призвести до плутанини і помилок.
Порада: Використовуйте std::string_view замість рядків C-style. Для рядків, які ви не плануєте в подальшому модифікувати, краще використовувати клас std::string_view замість std::string.
Функції, які модифікують представлення
Повернемося до нашої аналогії з вікном, тільки тепер розглянемо вікно з фіранками. Ми можемо закрити частину вікна лівою чи правою фіранкою, тим самим зменшивши те, що можна побачити крізь вікно. Зауважте, що ми не змінюємо об’єкти, що знаходяться зовні вікна, змінюється лише сектор спостереження з вікна.
Аналогічно і з класом std::string_view: в ньому містяться функції, що дозволяють нам управляти представленням рядка. Завдяки цьому ми можемо змінювати представлення рядка без зміни початкового рядка.
Для цього використовуються наступні функції:
remove_prefix()
— видаляє символи з лівої частини представлення;
remove_suffix()
— видаляє символи з правої частини представлення.
Наприклад:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
#include <iostream> #include <string_view> int main() { std::string_view str{ "Peach" }; std::cout << str << '\n'; // Ігноруємо перший символ str.remove_prefix(1); std::cout << str << '\n'; // Ігноруємо останні 2 символи str.remove_suffix(2); std::cout << str << '\n'; return 0; } |
Результат виконання програми:
Peach
each
ea
На відміну від справжніх фіранок, за допомогою яких ми можем закрити частину (або повністю) вікна, об’єкти класу std::string_view не можна “відкрити знову”. Змінивши один раз область видимості, ви вже не зможете повернутися до первинних значень (варто відзначити, що є рішення, які дозволяють вирішити дану проблему, але розглядати ми їх не будемо).
std::string_view і звичайні рядки
На відміну від рядків C-style, об’єкти класів std::string і std::string_view не використовують нульовий символ (нуль-термінатор) в якості мітки для позначення кінця рядка. Дані об’єкти знають, де закінчується рядок, тому що відстежують його довжину:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
#include <iostream> #include <iterator> // для функції std::size() #include <string_view> int main() { // Немає нуль-термінатора char vowels[]{ 'a', 'e', 'i', 'o', 'u' }; // Масив vowels не є нуль-термінованим. Ми повинні передати його довжину вручну. // Оскільки vowels є масивом, то ми можемо використовувати функцію std::size(), щоб отримати його довжину std::string_view str{ vowels, std::size(vowels) }; std::cout << str << '\n'; // це безпечно, так як std::cout знає, як виводити std::string_view return 0; } |
Результат виконання програми:
Проблеми володінням і доступу
Оскільки std::string_view є всього лише представленням рядка, його час життя не залежить від часу життя рядка, якого він представляє. Якщо рядок, який відображується, вийде за межі області видимості, то std::string_view більше не зможе його відображати і при спробі доступу до нього ми отримаємо невизначені результати:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
#include <iostream> #include <string> #include <string_view> std::string_view askForName() { std::cout << "What's your name?\n"; // Використовуємо std::string, оскільки std::cin буде змінювати рядок std::string str{}; std::cin >> str; // Ми переключаємося на std::string_view тільки в демонстраційних цілях. // Якщо ви вже маєте std::string, то немає необхідності переключатися на std::string_view std::string_view view{ str }; std::cout << "Hello " << view << '\n'; return view; } // str знищується і, таким чином, знищується і рядок, створений str int main() { std::string_view view{ askForName() }; // view намагається звернутися до рядка, якого вже не існує std::cout << "Your name is " << view << '\n'; // невизначені результати return 0; } |
Результат виконання програми:
What's your name?
nascardriver
Hello nascardriver
Your name is �P@�P@
Коли ми оголосили змінну str
і за допомогою std::cin присвоїли їй певне значення, то ця змінна створила всередині себе рядок, розмістивши його в динамічній області пам’яті. Після того, як змінна str
вийшла за межі області видимості в кінці функції askForName(), внутрішній рядок припинив своє існування. При цьому об’єкт класу std::string_view не знає, що рядка більше не існує, і дозволяє нам до нього звернутися. Спроба доступу до такого рядка через його представлення в функції main() призводить до невизначеної поведінки, в результаті чого ми отримуємо кракозябри.
Така ж ситуація може відбутися і тоді, коли ми створюємо об’єкт std::string_view з об’єкта std::string, а потім модифікуємо початковий об’єкт std::string. Модифікація об’єкта std::string може призвести до створення в іншому місці нового внутрішнього рядка і подальшого знищення старого рядка. При цьому std::string_view продовжить “дивитися” в те місце, де був старий рядок, але його вже там не буде.
Попередження: Слідкуйте за тим, щоб початковий рядок, на який посилається об’єкт std::string_view, не виходив за межі області видимості і не змінювався до тих пір, поки використовується об’єкт std::string_view, який на нього посилається.
Конвертація std::string_view в std::string
Об’єкти класу std::string_view НЕ конвертуються неявно в об’єкти класу std::string, але конвертуються при явній конвертації:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
#include <iostream> #include <string> #include <string_view> void print(std::string s) { std::cout << s << '\n'; } int main() { std::string_view sv{ "balloon" }; sv.remove_suffix(3); // print(sv); // помилка компіляції: неявна конвертація заборонена std::string str{ sv }; // явна конвертація print(str); // ок print(static_cast<std::string>(sv)); // ок return 0; } |
Результат виконання програми:
Конвертація std::string_view в рядок C-style
Деякі старі функції (такі як strlen()
) працюють тільки з рядками C-style. Для того щоб перетворити об’єкт класу std::string_view в рядок C-style, ми спочатку повинні конвертувати його в об’єкт класу std::string:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
#include <cstring> #include <iostream> #include <string> #include <string_view> int main() { std::string_view sv{ "balloon" }; sv.remove_suffix(3); // Створення об'єкту std::string з об'єкта std::string_view std::string str{ sv }; // Отримуємо рядок C-style з нуль-термінатором auto szNullTerminated{ str.c_str() }; // Передаємо рядок з нуль-термінатором в функцію, яку ми хочемо використовувати std::cout << str << " has " << std::strlen(szNullTerminated) << " letter(s)\n"; return 0; } |
Результат виконання програми:
ball has 4 letter(s)
Однак варто враховувати те, що створення об’єкта класу std::string щоразу, коли ми хочемо перетворити об’єкт std::string_view в рядок C-style, є витратною операцією, тому ми повинні по можливості уникати подібних ситуацій.
Функція data()
Доступ до початкового рядка об’єкту std::string_view можна отримати за допомогою функції data(), яка повертає рядок C-style. При цьому забезпечується швидкий доступ до даного рядка (як до рядка C-style). Але це слід використовувати тільки тоді, коли об’єкт std::string_view не був змінений (наприклад, за допомогою функцій remove_prefix()
чи remove_suffix()
) і пов’язаний з ним рядок має нуль-термінатор (так як це рядок C-style).
У наступному прикладі функція std::strlen() нічого не знає про std::string_view, тому ми передаємо їй функцію str.data()
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
#include <cstring> // для функції std::strlen() #include <iostream> #include <string_view> int main() { std::string_view str{ "balloon" }; std::cout << str << '\n'; // Для простоти ми скористаємося функцією std::strlen(). Замість неї можна було б використати будь-яку іншу функцію, яка працює з рядком з нуль-термінатором в кінці. // Тут ми можемо використовувати функцію data(), так як ми не змінювали представлення і рядок має нуль-термінатор std::cout << std::strlen(str.data()) << '\n'; return 0; } |
Результат виконання програми:
balloon
7
Коли ми намагаємося звернутися до об’єкта класу std::string_view, який був змінений, функція data() може повернути зовсім не той результат, який ми очікували від неї отримати. У наступному прикладі показано, що відбувається, коли ми звертаємося до функції data() після зміни представлення рядка:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
#include <cstring> #include <iostream> #include <string_view> int main() { std::string_view str{ "balloon" }; // Видаляємо символ "b" str.remove_prefix(1); // Видаляємо частину "oon" str.remove_suffix(3); // Пам'ятайте, що попередні 2 команди не змінюють початковий рядок, вони лише працюють з його представленням std::cout << str << " has " << std::strlen(str.data()) << " letter(s)\n"; std::cout << "str.data() is " << str.data() << '\n'; std::cout << "str is " << str << '\n'; return 0; } |
Результат виконання програми:
all has 6 letter(s)
str.data() is alloon
str is all
Очевидно, що даний результат — це не те, що ми планували побачити, і він є наслідком спроби функції data() отримати доступ до даних представлення std::string_view, яке було змінено. Інформація про довжину рядка втрачається при зверненні до нього через функцію data(). std::strlen і std::cout продовжують зчитувати символи з початкового рядка до тих пір, поки не зустрінуть нуль-термінатор, який знаходиться в кінці рядка baloon
.
Попередження: Використовуйте std::string_view::data() тільки в тому випадку, коли представлення std::string_view не було змінено і рядок, який відображається, містить нуль-термінатор. Використання функції std::string_view::data() з рядком без нуль-термінатора може призвести до виникнення помилок.
Нюанси std::string_view
Будучи відносно недавнім нововведенням, клас std::string_view реалізований не так вже й ідеально, як хотілося б:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
std::string s{ "hello" }; std::string_view v{ "world" }; // Не працює std::cout << (s + v) << '\n'; std::cout << (v + s) << '\n'; // Потенціально небезпечно або не те, що ми хочемо отримати, // оскільки ми намагаємося використати об'єкт std::string_view в якості рядка C-style std::cout << (s + v.data()) << '\n'; std::cout << (v.data() + s) << '\n'; // Допустимо, так як нам потрібно створити новий об'єкт std::string, але некрасиво і нераціонально std::cout << (s + std::string{ v }) << '\n'; std::cout << (std::string{ v } + s) << '\n'; std::cout << (s + static_cast<std::string>(v)) << '\n'; std::cout << (static_cast<std::string>(v) + s) << '\n'; |
Немає ніяких причин для того, щоб рядки №5-6 не працювали, але тим не менше вони не працюють. Ймовірно, повна підтримка даного функціоналу буде реалізована в наступних версіях стандарту C++.