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 в местах вызова.

1
Задача
C++ SELF, 18 уровень, 1 лекция
Недоступна
Строка комментарий
Строка комментарий
1
Задача
C++ SELF, 18 уровень, 1 лекция
Недоступна
Проверка начала
Проверка начала
1
Задача
C++ SELF, 18 уровень, 1 лекция
Недоступна
Разделить запись
Разделить запись
1
Задача
C++ SELF, 18 уровень, 1 лекция
Недоступна
Команда с пробелами
Команда с пробелами
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ