1. Введение
Когда вы только начинаете писать программы, кажется, что данные «живут всегда», пока вы о них помните. Увы, C++ не читает ваши мысли. Он живёт по правилам областей видимости: вошли в блок { ... } — переменные появились, вышли — исчезли. Для обычных типов это обычно очевидно. Но view-типы (особенно std::string_view) умеют выглядеть «как строка», хотя внутри у них лишь ссылка на чужую память. Именно поэтому тема времени жизни в лекции про view-типы — не философия, а техника безопасности.
Представьте, что view — это адрес на карте, а владелец — это реальный дом. Адрес можно переписать в блокнот, но если дом снесли, адрес сам по себе никого не спасёт. И самое коварное: вы всё ещё можете прийти по адресу, постоять, и… иногда даже увидеть «кусок стены» (случайные данные в памяти). А иногда — провалиться в яму (краш).
Главное правило: view не продлевает жизнь данных
Правило, которое сегодня нужно выучить как таблицу умножения:
std::string_view и std::span не владеют данными. Они не продлевают и не защищают время жизни. Поэтому view должен использоваться только пока жив владелец данных.
Это правило распадается на две практические части.
Первая часть — про время жизни владельца: если владелец уничтожен, view становится бессмысленным. Вторая часть — про стабильность памяти владельца: иногда владелец остаётся жив, но переезжает в другое место (например, std::vector увеличился и перевыделил память). Тогда view всё ещё хранит «старый адрес», и это уже тоже беда. В этой лекции мы фокусируемся на первой части — «владелец умер».
Для наглядности полезна схема:
flowchart LR
A[Owner: std::string / std::vector] -->|хранит данные| D[(Данные в памяти)]
B[View: string_view / span] -->|смотрит на| D
A -->|вышли из scope -> уничтожен| X[Данные исчезли/стали недоступны]
B -->|остался жить| Y[View указывает 'в никуда']
View — как ярлык на рабочем столе: если программу удалили, ярлык не превращается в программу. Он превращается в грустную иконку «файл не найден».
2. Где заканчивается жизнь объекта: минимальная модель
Чтобы применять правило на практике, нужно понимать, где именно C++ «закрывает дверь и выключает свет».
Самая простая и достаточная для нас модель такая: локальные переменные живут до конца блока. Блоком может быть тело if, for, while, или просто { ... }. Когда выполнение выходит из блока, локальные переменные внутри блока уничтожаются.
Вторая важная вещь: локальные переменные внутри функции уничтожаются при выходе из функции. Это звучит очевидно, но именно здесь чаще всего рождаются «плохие view».
Посмотрите на пример — он компилируется, но логически опасен:
#include <string>
#include <string_view>
std::string_view bad() {
std::string s = "hello";
return std::string_view{s}; // s умрёт при выходе из функции
}
Снаружи bad() возвращает «почти строку». А внутри возвращается «окошко», которое смотрело на s. s уже уничтожена — окно смотрит в стену.
Дальше мы будем постоянно задавать себе один вопрос: «кто владелец данных и где он живёт?». Если ответа нет — это тревожный звонок.
3. Сценарий: возвращаем std::string_view на локальную строку
Очень хочется написать «удобную» функцию, которая возвращает часть строки как string_view, ведь это быстро и без копий. Но если исходная строка создаётся внутри функции, то возвращать view нельзя: вместе с выходом из функции исходная строка умрёт.
Плохой пример
Сейчас мы намеренно напишем код, который выглядит как оптимизация: «я не копирую строку, я возвращаю view». На деле это оптимизация в стиле «ускорил падение программы». Такой код может даже иногда работать на маленьких тестах, потому что память ещё «не затёрлась». Но это именно тот случай, когда «иногда» — не успех, а ловушка.
#include <string>
#include <string_view>
std::string_view get_prefix_bad() {
std::string s = "cmd:run";
return std::string_view{s}.substr(0, 3); // "cmd"
}
Проблема не в substr(). Проблема в том, что s — локальная переменная.
Как сделать правильно
Дальше делаем правильно: либо пусть владелец создаётся снаружи и передаётся внутрь, либо функция должна возвращать владеющий результат (std::string). Выбор зависит от контракта: нужно ли результату жить независимо, или мы хотим просто «посмотреть» на часть уже существующей строки.
Вариант A: владелец приходит параметром, возвращаем view
#include <string_view>
std::string_view get_prefix_ok(std::string_view s) {
if (s.size() < 3) return s;
return s.substr(0, 3);
}
Здесь функция честно говорит: «я не владею строкой, я только смотрю». А значит, вызывающий код обязан передать строку, которая живёт достаточно долго.
Вариант B: нужен независимый результат — возвращаем std::string
#include <string>
std::string get_prefix_copy(std::string_view s) {
std::size_t n = (s.size() < 3) ? s.size() : 3;
return std::string{s.substr(0, n)}; // копия: результат живёт сам
}
Да, тут есть копирование. Но оно честное и безопасное: результат не зависит от жизни исходной строки.
4. Сценарии: временные объекты и хранение view «на потом»
View на временный std::string
Даже если вы не создаёте std::string как локальную переменную, можно случайно получить временный объект. Временные объекты — это такие «одноразовые стаканчики»: они существуют ровно до конца выражения (упрощённо), а потом исчезают. std::string_view может легко «прилипнуть» к временному std::string, и тогда у вас появляется view на уже уничтоженную строку. Это особенно коварно, потому что синтаксис выглядит красиво и современно.
Плохой пример: создаём временную строку через +, а затем берём view.
#include <string>
#include <string_view>
std::string_view bad_temp() {
std::string_view v = std::string("hi") + "!!!"; // временная строка
return v; // v уже смотрит на умершие данные
}
Почему? Потому что std::string("hi") + "!!!" создаёт временный std::string. После завершения строки кода этот временный объект уничтожается. v остаётся.
Правильная идея: если вы создаёте новую строку (конкатенацией, append, форматированием), значит вы создали нового владельца. Пусть этот владелец живёт в переменной:
#include <string>
#include <string_view>
int main() {
std::string owner = std::string("hi") + "!!!";
std::string_view v = owner;
// v безопасен, пока жив owner
}
«Давайте сохраним string_view, вдруг пригодится»
На этом месте у новичков часто включается режим «бережливость»: «я не хочу копировать строку, давайте будем хранить string_view в структуре, в векторе, в глобальной переменной». Идея звучит экономно, но на практике превращается в кредит под 300% годовых: экономия сегодня, баги завтра. Хранить view можно, но только если вы железно контролируете, что владелец живёт дольше. В учебных проектах чаще всего такого контроля нет.
Рассмотрим типичный шаблон ошибки: читаем строки построчно, режем их на куски string_view, и сохраняем эти куски в контейнер.
#include <iostream>
#include <string>
#include <string_view>
#include <vector>
int main() {
std::vector<std::string_view> tokens;
std::string line;
while (std::getline(std::cin, line)) {
tokens.push_back(std::string_view{line}); // token смотрит на line
}
// После цикла line хранит только последнюю строку,
// а старые tokens смотрят в прошлое (и это прошлое уже перезаписано).
}
Заметьте: line жив, но его содержимое менялось. Все std::string_view, которые указывали на старые значения line, теперь указывают на «что-то». Иногда это будет последняя строка, иногда мусор, иногда кажется «работает».
Что делать? Если вам нужно хранить токены надолго — храните владельцев, то есть std::string. Например, копировать токены в std::vector<std::string>. Да, это копирование. Но оно соответствует задаче: «хранить».
5. std::span и время жизни: те же правила
Со std::span ситуация концептуально такая же, просто «строка» заменяется на «массив элементов». std::span<T> внутри обычно хранит указатель на первый элемент и длину. Он удобнее, чем пара (T*, size), но по времени жизни он столь же строг: если массив исчез — span исчезает вместе с надеждой на корректность. А ещё span особенно любят использовать в функциях для чисел, поэтому ошибка может проявиться «не сразу», а через странные результаты вычислений.
Плохой пример: возвращаем span на локальный массив.
#include <span>
std::span<const int> bad_span() {
int a[3] = {1, 2, 3};
return std::span<const int>(a); // a умрёт при выходе из функции
}
Правильный подход похож на строковый: либо владелец приходит снаружи, либо вы возвращаете владеющий контейнер (std::vector<int> или std::array<int, N>), а не view.
Например, если данные создаются внутри, можно вернуть std::vector<int>:
#include <vector>
std::vector<int> make_data() {
return {1, 2, 3};
}
А span сделать уже у вызывающего кода:
#include <span>
#include <vector>
int sum(std::span<const int> xs) {
int total = 0;
for (int x : xs) total += x;
return total;
}
int main() {
std::vector<int> data = {1, 2, 3};
std::span<const int> view(data.data(), data.size());
int s = sum(view);
}
И снова: view живёт ровно столько, сколько жив data.
6. Пример в приложении: view живёт до конца обработки команды
Чтобы правило времени жизни не осталось «теорией с плаката», привяжем его к нашей учебной программе. Представим маленькое консольное приложение, которое читает команду строкой и выполняет простые действия (например, "add", "list", "help"). После темы про парсинг хочется разбирать команду эффективно, без лишних копий. И вот здесь string_view идеально подходит — но только если мы не будем хранить его дольше одной итерации цикла.
Разбор команды без хранения view
Сделаем простую функцию: отделим команду от аргумента по пробелу. Функция возвращает два string_view, но это нормально, потому что вызывающий код владеет строкой и использует результат сразу.
#include <string_view>
#include <utility>
std::pair<std::string_view, std::string_view> split_cmd(std::string_view line) {
std::size_t pos = line.find(' ');
if (pos == std::string_view::npos) return {line, std::string_view{}};
return {line.substr(0, pos), line.substr(pos + 1)};
}
Ключевой контракт: split_cmd не хранит view, не кладёт его в глобальные переменные и не возвращает ссылку на локальные данные. Она просто «режет» то, что ей дали.
Безопасный цикл чтения команд
Теперь в main читаем строку-владельца, строим view, разбираем, выполняем и забываем.
#include <iostream>
#include <string>
#include <string_view>
int main() {
std::string line;
while (std::getline(std::cin, line)) {
std::string_view v = line; // view живёт внутри итерации
auto [cmd, arg] = split_cmd(v);
if (cmd == "help") {
std::cout << "Commands: help, echo\n";
} else if (cmd == "echo") {
std::cout << arg << '\n';
}
}
}
Здесь всё честно: line — владелец, v, cmd, arg — view. Итерация закончилась — view больше не используется. Мы не пытаемся «сохранить arg навсегда».
Если вам хочется где-то «запомнить аргумент» (например, сохранить заметку в список), это уже другой контракт: значит, нужно копировать arg в std::string и хранить именно её.
7. Быстрая самопроверка: можно ли здесь view
Когда вы пишете код с string_view/span, мозг первое время будет сомневаться: «а это безопасно?». Это нормально. Чтобы не гадать на кофейной гуще, полезно держать маленькую таблицу решений. Она не заменяет понимание, но помогает в типичных случаях. Если ваш случай не попадает ни в одну строку — значит, надо остановиться и выяснить, кто владелец и каков его срок жизни.
| Ситуация | Можно ли использовать view? | Почему |
|---|---|---|
| Функция принимает std::string_view и сразу читает | Да | View живёт внутри вызова, данные принадлежат вызывающему |
| Функция возвращает std::string_view на строку, переданную параметром | Иногда да | Только если вызывающий гарантирует жизнь строки после возврата |
| Функция возвращает std::string_view на локальный std::string | Нет | Локальная строка уничтожится при выходе из функции |
| Храним std::string_view в vector «на будущее» | Обычно нет | Слишком легко потерять владельца/перезаписать данные |
| Функция принимает std::span<const int> и считает сумму | Да | Нет копий, жизнь данных контролирует вызывающий |
| Возвращаем std::span<int> на локальный массив | Нет | Массив уничтожится при выходе из функции |
8. Типичные ошибки
Ошибка №1: возврат string_view/span, который смотрит на локальные данные функции.
Это самый частый и самый «классический» провал. Код компилируется, иногда даже выдаёт «правильные» значения на маленьких тестах, а потом начинает вести себя странно. Лекарство простое: либо возвращайте владеющий тип (std::string, std::vector), либо принимайте владельца параметром и возвращайте view как часть его данных.
Ошибка №2: хранение view дольше, чем живёт исходная строка/массив.
Новички часто сохраняют std::string_view в поле структуры или в контейнер, не сохранив владельца рядом. Через пару шагов программы владелец меняется или уничтожается, а view остаётся. Исправление — хранить владельца (например, std::string) или хранить идентификатор/позиции, а view строить «на лету» при использовании.
Ошибка №3: view на данные, которые «тихо меняются».
Даже если владелец формально жив, его содержимое может изменяться (например, вы используете одну переменную std::string line в цикле и перезаписываете её через getline). View, сохранённый на прошлую итерацию, теперь указывает на новые данные. Лекарство: view должен быть короткоживущим и использоваться внутри одного «такта» обработки, либо вы должны делать копию данных для долговременного хранения.
Ошибка №4: «раз не копирует, значит безопаснее».
Это психологическая ловушка. string_view/span дают скорость и удобство интерфейса, но безопасность времени жизни они не повышают — наоборот, требования к аккуратности становятся выше. Хорошая привычка: каждый раз, создавая view, мысленно проговорите «кто владелец и когда он умрёт».
Ошибка №5: попытка сделать view «универсальным хранителем результата».
Иногда хочется вернуть из функции string_view как «результат поиска» или «обрезанную строку» и потом хранить этот результат где-то. Если результат действительно нужно хранить независимо — это работа владеющих типов. View — это не «результат», view — это «взгляд».
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ