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
Когда вы выбираете тип параметра, вы фактически подписываете контракт: будет ли функция владеть данными, будет ли копировать, какие источники текста принимает. И очень полезно держать в голове не «модно/немодно», а именно контракт и последствия.
Сравним три популярных варианта в одной таблице:
| Параметр | Копирует? | Принимает |
Принимает |
Хорошо для |
|---|---|---|---|---|
|
часто да | да (создаст временную строку) | да | когда нужна копия/хранение/изменение |
|
нет | да (создаст временную строку) | да | когда читаем и хотим избежать копии строки, но готовы к ограничениям |
|
нет | да (без ) |
да | когда читаем текст «здесь и сейчас» и хотим гибкость |
Ключевой момент здесь такой: 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 в местах вызова.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ