JavaRush /Курсы /C++ SELF /Правило времени жизни: view

Правило времени жизни: view

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

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 — это «взгляд».

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