JavaRush /Курси /C++ SELF /std::string_view як п...

std::string_view як параметр

C++ SELF
Рівень 18 , Лекція 1
Відкрита

1. Вступ

Коли ви лише починаєте писати функції, дуже природно зробити щось на кшталт: «нехай функція приймає std::string — зручно ж!». І так, це зручно… доти, доки ви випадково не почнете копіювати рядки всюди, де цього не планували. А рядки, як і студенти на ранковій парі, інколи бувають доволі «важкими»: довгими й не надто схильними до зайвих переміщень.

Сьогоднішня мета цілком практична: навчитися писати такі функції, які «вміють читати текст звідки завгодно» — зі std::string, із рядкового літерала "hello", із фрагмента рядка — і водночас не копіюють символи без потреби. Для цього в нас є герой дня — std::string_view.

Невелике зауваження «зі світу стандартів»: у робочих матеріалах і обговореннях комітету C++ регулярно постають питання щодо узгодженості інтерфейсів std::string і std::string_view. Тема справді практична й жива, а не вигадана викладачами лише для тренування.

Проблема прихованих копій під час передавання std::string за значенням

Подивімося на наївний варіант:

#include <string>

std::size_t count_letters(std::string s) { // <- копія рядка!
    return s.size();
}

Функція робить рівно одну річ: повертає довжину. Але вона отримує рядок за значенням, а отже, фактично просить: «дай мені мою особисту копію тексту». Для std::string це часто означає виділення памʼяті й копіювання символів. Компілятор інколи допоможе оптимізаціями, але покладатися на «авось» — погана стратегія і в програмуванні, і в житті.

Звісно, можна сказати: «Добре, тоді давайте const std::string&». Це вже набагато краще: копії не буде. Але в цього підходу є обмеження: він приймає лише std::string. А якщо в нас рядковий літерал? А якщо фрагмент рядка, виділений під час розбору? Тут і починається зайвий клопіт.

Саме тут і зʼявляється std::string_view: ми ніби кажемо функції: «ось тобі вид на текст, читай, але не володій ним».

Який тип параметра вибрати: std::string, const std::string& чи std::string_view

Коли ви вибираєте тип параметра, то фактично підписуєте контракт: чи володітиме функція даними, чи копіюватиме їх, які джерела тексту вона прийматиме. І тут дуже корисно тримати в голові не категорії «модно/немодно», а саме контракт і його наслідки.

Порівняймо три популярні варіанти в одній таблиці:

Параметр Копіює? Приймає
"literal"
Приймає
std::string
Добре для
std::string s
часто так так (створить тимчасовий рядок) так коли потрібні копія, зберігання чи зміна
const std::string& s
ні так (створить тимчасовий рядок) так коли лише читаємо й хочемо уникнути копії рядка, але готові до обмежень
std::string_view sv
ні так (без
std::string
)
так коли читаємо текст «тут і зараз» і хочемо більшої гнучкості

Ключова думка тут така: const std::string& справді уникає копіювання, якщо на вході вже std::string, але рядковий літерал "hi" не є std::string. Тому під час виклику f("hi"), якщо f приймає const std::string&, компілятору доводиться створювати тимчасовий std::string. А std::string_view зазвичай може дивитися на літерал напряму — без проміжного рядка.

І саме тут зʼявляється приємне відчуття: «О, так справді простіше». Ви пишете одну функцію, а користуватися нею можна з різними джерелами тексту.

Чому std::string_view зазвичай передають за значенням

Із std::string_view легко спрацьовує давня звичка: «раз це рядок, мабуть, треба const&». Але string_view — не «рядок із даними». Це маленький обʼєкт-опис: умовно кажучи, «адреса + довжина». Тому передавання за значенням тут зазвичай цілком нормальне й навіть бажане: ви копіюєте два числа, а не весь текст.

Виглядає це так:

#include <string_view>

bool is_empty(std::string_view s) {
    return s.empty();
}

Ми передали s за значенням, але самі дані не копіювали: s лише дивиться на чужу памʼять.

Щоб краще уявити, що відбувається, можна намалювати схему:

flowchart LR
    A["std::string — власник
буфер: 'h e l l o'"] --> B["std::string_view
data()+size()"] B --> C["функція читає
символи без копії"]

Водночас важливо памʼятати: раз string_view не володіє памʼяттю, він і не зобовʼязаний «тримати її живою». Він просто вказує на неї. Сьогодні ми використовуємо його саме як параметр — тобто короткоживучий перегляд, створений на час виклику функції. Це найтиповіший і найбезпечніший сценарій.

2. Базові операції std::string_view для читання й розбору

Зараз хочеться одразу почати «розбирати все підряд», але корисніше спершу опанувати невеликий набір операцій, який покриває 80 % задач. std::string_view спеціально зроблено схожим на std::string у плані читання: у нього є size(), empty(), operator[], find(), substr(). Тобто у вас не повинно виникати відчуття «я знову вчу новий рядок» — радше це «рядок у режимі лише читання».

Мінімальний набір, без якого швидко стає боляче

Почнімо з короткого прикладу: перевіримо перший символ. І не забуваймо про empty(), бо інакше буде класика жанру: «усе працювало, доки не прийшов порожній рядок».

#include <string_view>

bool starts_with_hash(std::string_view s) {
    if (s.empty()) return false;
    return s[0] == '#';
}

Зверніть увагу: s[0] не перевіряє межі. Це зроблено свідомо: так само працює і std::string. Тому перевірка empty() — не прикраса, а цілком реальна страховка.

Тепер — приклад трохи корисніший для CLI-застосунків: перевірка префікса команди.

#include <string_view>

bool has_prefix(std::string_view s, std::string_view prefix) {
    if (s.size() < prefix.size()) return false;
    return s.substr(0, prefix.size()) == prefix;
}

Ця функція може приймати і std::string, і "cmd:", і фрагмент рядка — без копіювання тексту. Саме заради такої гнучкості ми сюди й прийшли.

find() і npos: як не перетворити substr() на лотерею

Коли ви починаєте ділити рядок на частини, наприклад key=value, перше бажання — знайти роздільник і зробити substr. Але у find() є важлива особливість: якщо символ не знайдено, повертається спеціальне значення npos. І якщо ви забудете це перевірити, ваш код стане схожим на гру «вгадай, чи впаде застосунок на цих вхідних даних».

Напишімо безпечний split_once, який ділить рядок за першим роздільником:

#include <string_view>
#include <utility>

std::pair<std::string_view, std::string_view> split_once(std::string_view s, char delim) {
    std::size_t pos = s.find(delim);
    if (pos == std::string_view::npos) return {s, std::string_view{}};
    return {s.substr(0, pos), s.substr(pos + 1)};
}

Тут другий фрагмент може бути порожнім: це нормально й корисно. Наприклад, key= дасть порожнє значення праворуч.

Мініперевірка в main:

#include <iostream>
#include <string_view>
#include <utility>

int main() {
    auto [left, right] = split_once("key=value", '=');
    std::cout << left << " | " << right << '\n'; // key | value
}

Кілька слів про еволюцію стандарту: у робочих обговореннях комітету можна зустріти навіть окремі пункти, повʼязані з тим, як string і string_view мають краще поєднуватися. Тобто «рядки без копій» — це не рідкісний трюк, а цілком помітний напрям розвитку зручності стандартної бібліотеки.

remove_prefix() і remove_suffix(): зміщуємо межі без нового рядка

Дуже поширене завдання під час читання команд або даних — прибрати пробіли по краях. Із std::string ви могли б робити substr() і створювати нові рядки або акуратно працювати з індексами. А std::string_view дає змогу зробити це елегантніше: просто змістити межі перегляду. Символи не змінюються, памʼять не виділяється — ви лише кажете: «дивися не на весь текст, а на його середину».

Напишімо trim_spaces, який прибирає пробіли по краях:

#include <string_view>

std::string_view trim_spaces(std::string_view s) {
    while (!s.empty() && s.front() == ' ') s.remove_prefix(1);
    while (!s.empty() && s.back()  == ' ') s.remove_suffix(1);
    return s;
}

Перевірмо:

#include <iostream>
#include <string_view>

int main() {
    std::cout << '[' << trim_spaces("   hi   ") << "]\n"; // [hi]
}

Тут важливо зрозуміти: trim_spaces нічого не «створює». Вона повертає новий string_view, який дивиться всередину початкового тексту, просто з іншими межами.

Ось чому string_view такий вдалий для параметрів: ви приймаєте вхід як перегляд, швидко нормалізуєте його, просто змістивши межі, а далі розбираєте вже охайний фрагмент — і все це без копій.

3. Приклад: розбір команди без копій, зберігання з копією

Щоб приклади не були «у вакуумі», продовжимо розвивати наш навчальний консольний застосунок. Уявімо, що в нас є простий «список завдань»: ми читаємо рядок із командою, наприклад "add Купити молоко", і додаємо завдання в std::vector<std::string>. Поки що без складних моделей і статусів — лише текст, щоб не забігати наперед.

Зараз наша мета дуже конкретна: розбирати рядок команди без зайвих копій, але під час додавання завдання копіювати дані в std::string, тому що зберігати string_view надовго не можна: він же не володіє памʼяттю.

parse_command: команда й аргумент як string_view

Зробімо функцію, яка з рядка виду "add Купити молоко" дістане команду й аргумент:

#include <string_view>
#include <utility>

std::pair<std::string_view, std::string_view> parse_command(std::string_view line) {
    line = trim_spaces(line);
    auto [cmd, rest] = split_once(line, ' ');
    return {cmd, trim_spaces(rest)};
}

Зверніть увагу: і trim_spaces, і split_once працюють на string_view, тобто не створюють нових рядків.

Тепер шматочок main: читаємо рядок, розбираємо його, реагуємо.

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

int main() {
    std::vector<std::string> tasks;
    std::string line;

    std::getline(std::cin, line);
    auto [cmd, arg] = parse_command(line);

    if (cmd == "add" && !arg.empty()) tasks.push_back(std::string(arg));
    std::cout << tasks.size() << '\n'; // 1 (якщо ввели "add something")
}

Тут і криється головна практична думка: std::string_view ідеально підходить для сценарію «прочитати й розібрати», але коли ви хочете зберегти результат незалежно від початкового рядка, його треба чесно перетворити на std::string. У коді це видно буквально: tasks.push_back(std::string(arg)).

Якщо розгорнути це в невеликий цикл, вийде вже щось схоже на «живу» CLI-утиліту. Розібʼємо все на невеликі фрагменти, щоб код не перетворювався на простирадло.

#include <iostream>
#include <string>

bool read_line(std::string& line) {
    std::cout << "> ";
    return static_cast<bool>(std::getline(std::cin, line));
}

А ось обробка пари команд:

#include <iostream>
#include <string>
#include <vector>

void handle(std::vector<std::string>& tasks, std::string_view cmd, std::string_view arg) {
    if (cmd == "add" && !arg.empty()) tasks.push_back(std::string(arg));
    if (cmd == "list") for (const auto& t : tasks) std::cout << "- " << t << '\n';
}

Зауважте, як добре поєднуються контракти: handle отримує cmd і arg як string_view — щоб швидко прочитати, — але tasks зберігає std::string, тобто володіє даними. Це якраз та сама «правильна архітектурна лінь»: ми економимо копії там, де вони не потрібні, і робимо копію там, де без неї не обійтися.

Невеликий жартівливий переклад людською мовою: string_view — це «зазирнути в холодильник», а std::string — «купити продукти й покласти їх до себе». Із холодильником сусіда теж можна ознайомитися, але краще не зберігати в себе записку «у сусіда на другій полиці лежить ковбаса» як єдиний план харчування.

4. Типові помилки під час використання std::string_view у параметрах

Помилка № 1: приймати std::string_view, а потім зберігати його кудись «на потім».
Найпідступніша пастка в тому, що string_view виглядає як рядок, і рука сама тягнеться зберегти його у std::vector або зробити полем обʼєкта. Але string_view не володіє памʼяттю: якщо початковий рядок зміниться або зникне, ваш збережений view почне дивитися в нікуди. Безпечна звичка така: string_view використовуємо як параметр і локальну змінну, а для зберігання копіюємо в std::string.

Помилка № 2: забути перевірити npos після find() й одразу робити substr().
Код на кшталт auto pos = s.find('='); return s.substr(pos + 1); виглядає компактно, але ламається, якщо у введенні немає '='. Правильний ритуал трохи нудніший, зате не перетворює введення користувача на небезпечну лотерею: спочатку перевірка pos == std::string_view::npos, а вже потім substr().

Помилка № 3: звертатися до s[0] або s[s.size()-1] без перевірки empty().
Навіть якщо «за логікою» порожній рядок не має прийти, він обовʼязково прийде — бо користувач натисне Enter, бо файл закінчиться, бо тест перевірювальної системи вирішить пожартувати. Захисний стиль дуже простий: перед front()/back()/operator[] спочатку if (s.empty())

Помилка № 4: намагатися «змінити рядок» через std::string_view.
string_view — не редактор, а «віконце». Методи remove_prefix/remove_suffix змінюють лише межі перегляду, але не самі символи. Якщо вам потрібно справді модифікувати текст — робити заміни, вставки, накопичувати результат, — тоді вже потрібен власник: найчастіше це std::string.

Помилка № 5: думати, що const std::string& завжди дорівнює string_view за ефективністю.
Для передавання вже наявного std::string обидва варіанти добрі. Але якщо ви хочете приймати літерали й фрагменти рядків без проміжних обʼєктів, std::string_view зазвичай зручніший: ви розширюєте набір можливих входів функції й менше провокуєте приховані тимчасові std::string у місцях виклику.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ