JavaRush /Курсы /C++ SELF /string_view‑грабли: у...

string_view‑грабли: углубление правил времени жизни

C++ SELF
47 уровень , 0 лекция
Открыта

1. std::string_view одновременно полезный и опасный

Когда вы впервые видите std::string_view, он выглядит как «лёгкая строка без копий»: передал в функцию — и всё быстро, красиво, современно. И это правда, пока вы держите в голове один важный нюанс: string_view не владеет текстом. Он похож на «окошко» в чужие данные. Окошко — штука удобная: посмотреть можно, перенести домой — нельзя.

Практическая ценность string_view обычно в двух вещах. Во-первых, мы можем принимать текст в функцию без копирования: хоть std::string, хоть строковый литерал, хоть кусок строки. Во-вторых, можно делать подстроки тоже без копирования: «укажи на диапазон символов».

Но из этих же преимуществ вырастает и риск: если владелец текста исчез или переместил свои данные, view продолжает хранить старый адрес и длину — и вы читаете уже «не текст», а случайность.

Чтобы зафиксировать роли, полезно держать в голове такую таблицу:

Сущность Владеет памятью? Хранит “где текст”? Срок жизни данных
std::string
да да пока жив объект
std::string
строковый литерал "hi" «да» (статически) да до конца программы
std::string_view
нет да (адрес+длина) не управляет временем жизни

Что реально внутри std::string_view

Очень легко ошибиться, если думать, что string_view — это «почти string». На самом деле у него другая природа: это просто пара «указатель на символы + количество символов». Никаких гарантий про нуль-терминатор, никаких гарантий «данные будут жить», никаких гарантий «данные не изменятся». Поэтому string_view часто ведёт себя как аккуратный «срез» массива символов.

Вот маленький пример, который полезно один раз увидеть:


#include <iostream>
#include <string_view>

int main() {
    std::string_view sv = "hello";

    std::cout << sv.size() << '\n'; // 5
    std::cout << sv << '\n';        // hello
}

Почему это работает безопасно? Потому что "hello" — строковый литерал. Он живёт очень долго (практически «всегда»), поэтому view на него не становится висячим.

Полезная мысль: std::string_view может указывать на текст, который вообще не нуль-терминирован. Например, на середину строки. Это нормально. Но это означает, что любая логика «передам data() куда-то, где ждут C‑строку» может внезапно сломаться. И обычно ломается именно в тот день, когда вы уже уверены, что «всё протестировано».

2. Закон дня: view не должен пережить владельца

Центральная мысль всей лекции звучит почти как правило техники безопасности: view не должен жить дольше владельца данных. Если вы запомнили только одно предложение — пусть это будет оно.

Удобная аналогия: std::string — это книга, std::string_view — это закладка с номером страницы и строкой. Закладка не хранит текст. Она хранит «где читать». Если книгу выбросили, закладка осталась, но читать теперь нечего. А если книгу перепечатали и страницы «переехали», закладка указывает в никуда, хотя выглядит совершенно солидно.

Ещё одна важная деталь: время жизни view (переменной std::string_view) и время жизни данных, на которые он смотрит — это разные вещи. View может жить долго, а данные — уже нет. И как раз это и есть ситуация «dangling string_view».

3. Частые грабли со временем жизни std::string_view

Грабля №1: string_view из временной std::string

Самая частая и самая обидная ошибка: создать временную строку и сразу сделать из неё string_view. Код компилируется идеально. На ревью выглядит «современно». А потом начинаются мистические падения «только на сервере и только по пятницам».

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

int main() {
    std::string_view sv = std::string("hi");
    std::cout << sv << '\n'; // UB: sv смотрит на уже уничтоженную строку
}

Почему так? Потому что std::string("hi") — временный объект. Он живёт до конца полного выражения, то есть примерно до ;. После ; временная строка уничтожается, а sv остаётся с адресом в «прошлое».

Скрытые временные строки: substr у std::string

Есть вариант ещё хитрее. Вы можете даже не создавать std::string("...") вручную. Достаточно вызвать метод, который возвращает std::string, а не string_view. Например, std::string::substr.

#include <string>
#include <string_view>

int main() {
    std::string s = "command: add milk";

    std::string_view v = s.substr(0, 7); // UB: substr вернул временную std::string
    (void)v;
}

Здесь проблема не в s — он жив. Проблема в том, что s.substr(...) создал новую строку (временную), и view построился уже на ней.

Как сделать правильно? Если вы хотите именно view без копий, то делайте view из исходной строки, а уже потом берите substr у view:

#include <string>
#include <string_view>

int main() {
    std::string s = "command: add milk";

    std::string_view v = std::string_view{s}.substr(0, 7); // OK
    (void)v;
}

Теперь substr делает срез внутри view, а view смотрит на память s, которая живёт.

Нюанс из практики: почему здесь легко ошибиться

Даже в комитете C++ обсуждали детали конструкторов string_view для range‑сценариев и строгость преобразований — то есть тема «как легко случайно построить view не оттуда» реально болезненная. Например, поднимался вопрос, что range‑конструктор string_view должен быть explicit.

Грабля №2: функция возвращает string_view, а внутри создаёт строку

После первой грабли логично попасть во вторую: «ладно, не буду делать временный в main, сделаю функцию, которая вернёт view». И вот тут почти гарантированно появляется dangling, потому что строка внутри функции — локальная, а значит умирает при выходе из функции.

#include <string>
#include <string_view>

std::string_view bad_prefix() {
    std::string p = "cmd:";
    return p; // UB: p будет уничтожена при выходе из функции
}

Это прямой аналог «вернул ссылку на локальную переменную», только в мире строк.

Возвращать string_view можно, но только если вы возвращаете view на данные, которые гарантированно живут дольше. Например, на строковый литерал:

#include <string_view>

std::string_view ok_prefix() {
    return "cmd:"; // OK: литерал живёт до конца программы
}

Или на строку, которую вам передали извне (но тут появляется следующий риск: вызывающий может передать временную строку).

Грабля №3: возвращаем view на параметр, но передают временный объект

Эта грабля выглядит особенно интеллигентно. Вы пишете функцию «без копий», принимаете std::string_view, возвращаете std::string_view — красота. Проблема в том, что ваш контракт начинает зависеть от того, что именно передали.

#include <string_view>

std::string_view first_word(std::string_view line) {
    std::size_t pos = line.find(' ');
    return (pos == std::string_view::npos) ? line : line.substr(0, pos);
}

Теперь опасный вызов:

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

int main() {
    std::string_view w = first_word(std::string("add milk"));
    std::cout << w << '\n'; // UB: временная строка умерла после ';'
}

Сама функция first_word «честная»: она работает с view и возвращает view на тот же буфер. Но контракт функции получается тонким: «возвращаемое значение валидно, пока жив источник текста».

Как сделать надёжнее для начинающего кода? Очень часто ответ простой: возвращать std::string по значению, если результат предполагается хранить.

#include <string>
#include <string_view>

std::string first_word_copy(std::string_view line) {
    std::size_t pos = line.find(' ');
    std::string_view w = (pos == std::string_view::npos) ? line : line.substr(0, pos);
    return std::string(w); // копируем ровно нужный кусок
}

Да, копия. Зато контракт прозрачный: результат живёт сам по себе.

Грабля №4: хранить std::string_view как поле структуры

Когда вы начинаете моделировать данные, появляется соблазн: «а давайте хранить string_view вместо string, чтобы было быстрее». Это один из самых дорогих «микро‑выигрышей» в вашей жизни, потому что он часто превращается в минное поле времени жизни.

Очень плохой вариант модели выглядит так:

#include <string_view>

struct Task {
    int id = 0;
    std::string_view title; // опасно: кто владелец текста?
    bool done = false;
};

Почему опасно? Потому что Task теперь хранит «чужую закладку». Где книга? Кто гарантирует, что книга не исчезнет? Чаще всего — никто.

Правильный «скучный» вариант — владеть строкой:

#include <string>

struct Task {
    int id = 0;
    std::string title; // владеем
    bool done = false;
};

А string_view использовать там, где он реально хорош: на входе функций, в парсинге, в сравнении, в поиске, но не как долговременное хранилище.

Грабля №5: view на std::string, а потом строку меняют

Даже если владелец текста живёт достаточно долго, остаётся вторая причина, почему view может «сломаться»: владелец может изменить буфер. Для std::string это означает, что при модификации строка может перевыделить память, переехать в другое место — и старый адрес больше не указывает на актуальные данные.

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

int main() {
    std::string s = "abc";
    std::string_view v = s;

    s += "defghijklmnopqrstuvwxyz"; // может перевыделить буфер
    std::cout << v << '\n';         // UB, если буфер переехал
}

Коварство в том, что иногда буфер не переедет (например, из-за small string optimization или из-за уже достаточной capacity()). Поэтому ошибка может «не проявляться» на маленьких строках и проявиться на больших, на другом компиляторе или просто в другой фазе луны.

Практический вывод для начинающего кода: если вы взяли string_view на строку, не модифицируйте эту строку, пока view нужен. А если вам нужно модифицировать строку — берите view заново после модификаций, как «одноразовую ссылку на текущую версию».

4. Безопасный стиль для TaskBoard: принимаем view, храним string

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

Пусть у нас есть добавление задачи. Сигнатура удобно выглядит так: мы принимаем std::string_view title, но кладём в модель копию (владение).

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

struct Task {
    int id = 0;
    std::string title;
    bool done = false;
};

void add_task(std::vector<Task>& tasks, int id, std::string_view title) {
    tasks.push_back(Task{id, std::string(title), false});
}

Обратите внимание на идею: view живёт только в рамках вызова add_task, а внутри Task мы сразу делаем владеющую строку. Это ровно тот момент, когда копирование оправдано: мы превращаем внешний «заимствованный» текст в наше «собственное» состояние.

Похожий стиль полезен и для поиска. Например, проверить, начинается ли заголовок с "#":

#include <string_view>

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

Эта функция идеальна для string_view, потому что она ничего не сохраняет и не возвращает «заимствование наружу». Она просто читает и отвечает.

Мини‑набор парсинга на string_view

Чтобы увидеть string_view «в своей стихии», рассмотрим маленький кусочек парсинга команд для TaskBoard. Пользователь вводит строки вроде "add Buy milk" или "done 3". Мы читаем строку в std::string line, а затем парсим её, избегая лишних копий на промежуточных шагах.

Начнём с простой «обрезки пробелов слева». Она возвращает string_view, и это нормально, потому что возвращаемый view всё ещё смотрит на те же исходные данные.

#include <string_view>

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

Теперь «разделить один раз» по пробелу: команда и остаток строки.

#include <string_view>
#include <utility>

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

Эти функции безопасны при одном условии: входной view должен быть валидным. А в нашем стиле это обеспечивается просто: мы читаем строку в std::string line, и передаём std::string_view{line} в парсер, и парсер работает только в рамках текущей итерации обработки команды.

Теперь сделаем результат парсинга, который владеет данными. Это ключевой момент: парсер может внутри использовать view, но наружу отдаёт владеющие строки.

#include <string>

struct ParsedAdd {
    std::string title;
};

И функция парсинга команды add:

#include <optional>
#include <string>
#include <string_view>

std::optional<ParsedAdd> parse_add(std::string_view line) {
    auto [cmd, rest] = split_once(line);
    if (cmd != "add" || rest.empty()) return std::nullopt;
    return ParsedAdd{std::string(rest)};
}

Здесь происходит правильная «математика времени жизни»: rest — это view на line, но мы сразу копируем его в std::string. Поэтому ParsedAdd можно безопасно хранить и использовать где угодно.

И вот где начинается взрослая дисциплина: если вы возвращаете наружу объект, который будет жить дальше, этот объект должен владеть тем, что ему нужно. View хорош как промежуточный инструмент внутри функции, но плохо подходит как часть результата, если вы не готовы тащить в контракт «а источник точно жив?».

5. Шпаргалка: где string_view уместен, а где опасен

Когда вы пишете код, мозг очень любит «упрощать»: раз тип подходит, значит всё ок. Но с string_view так нельзя — нужен быстрый чек по времени жизни. Хорошая новость: он реально быстрый, если задавать себе правильные вопросы.

Место в коде
std::string_view
обычно…
Почему
параметр функции «прочитать текст» уместен живёт только во время вызова
локальная переменная внутри функции уместен вы контролируете порядок действий
возвращаемое значение функции рискован вызывающий может хранить дольше, чем жив источник
поле
struct/class
почти всегда рискован поле живёт долго, а владелец текста может не жить
view на строку, которую вы потом меняете рискован буфер может переехать

А теперь тот же чек в виде маленькой блок‑схемы:

flowchart TD
    A["Хочу использовать string_view"] --> B{"Где он будет жить?"}
    B -->|Только внутри вызова/функции| C["Обычно безопасно"]
    B -->|Буду хранить в поле / глобально / в контейнере| D{"Есть железная гарантия жизни владельца?"}
    D -->|Да, владелец живёт дольше и не меняет буфер| E["Можно, но контракт должен быть явным"]
    D -->|Нет / не уверен| F["Храни std::string (владей данными)"]

Если вы ловите себя на мысли «ну, вроде владелец будет жить…» — это почти всегда сигнал: лучше владеть строкой. std::string дешевле, чем неделя отладки.

6. Типичные ошибки при работе с std::string_view

Ошибка №1: std::string_view sv = std::string("..."); и уверенность, что «ну я же сразу использую».
Это одна из самых частых ловушек, потому что она выглядит как «оптимизация»: никаких копий, всё современно. На практике временная std::string умирает в конце полного выражения, а sv остаётся. Правильная привычка: если источник временный — либо сделайте владеющую std::string, либо не сохраняйте view за пределы выражения.

Ошибка №2: std::string_view v = s.substr(...) у std::string.
Интуитивно кажется, что «substr — это подстрока, значит view». Но у std::string substr возвращает новый std::string, то есть создаёт временный объект, и view начинает смотреть на него. Гораздо безопаснее брать substr у std::string_view{s} — тогда вы действительно получаете «срез без копии».

Ошибка №3: возвращать std::string_view из функции, где строка создаётся внутри.
Это точный аналог «возврата ссылки на локальную переменную», только в текстовом виде. Если строка создана внутри, она уничтожится при выходе, а view станет висячим. Лечится честно: возвращайте std::string по значению, или возвращайте view только на данные, чьё время жизни гарантировано дольше (например, литералы).

Ошибка №4: хранить std::string_view в модели данных (например, struct Task { std::string_view title; }).
Такой дизайн заставляет всю систему зависеть от внешнего владельца текста. В реальном приложении это почти всегда приводит к ситуациям «оно вчера работало». Более надёжный стиль: хранить std::string в модели, а string_view использовать в параметрах и при разборе входной строки.

Ошибка №5: держать view на std::string, а потом модифицировать эту строку.
Даже если строка всё ещё «жива», её буфер может перевыделиться при +=, append(), insert() и других операциях. Старый view не обновится и продолжит указывать на прежний адрес. В безопасном стиле либо не изменяют строку, пока view используется, либо создают view заново после модификаций, либо сразу копируют нужный фрагмент в std::string.

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