JavaRush /Курсы /C++ SELF /Проектирование сигнатур: string_view vs const std::string...

Проектирование сигнатур: string_view vs const std::string&

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

1. Сигнатура функции как контракт

Когда вы пишете функцию, легко думать, что главное — это её тело: там же “вся работа”. Но на практике половина качества кода живёт в сигнатуре: какие параметры, какие типы, что возвращаем. Сигнатура — это контракт между вашим кодом и чужим кодом (даже если “чужой” — это вы через две недели, уставший и слегка раздражённый). В этой лекции мы будем относиться к параметрам как к обещаниям: “я только читаю”, “я буду хранить”, “я могу менять элементы”, “я могу менять размер” — и научимся выражать эти обещания типами.

Представьте, что функция — это кофейня. Параметр по значению — вы принесли кофе с собой (копия). Параметр по const& — вы дали бариста попробовать ваш кофе, но чашку он не забирает. View-тип (std::string_view/std::span) — вы показали бариста фотографию чашки и сказали: “вот отсюда пробуй”, но если вы уйдёте из кофейни, фотография останется, а кофе — нет. Немного странная метафора, зато запоминается.

Нам понадобятся три “вопроса”, которые стоит задавать про любую функцию:

Вопрос Что выясняем Что влияет на выбор
Функция только читает или изменяет? Нужен const или нет std::span<const T> vs std::span<T>, std::string_view vs std::string&
Функции нужно владеть данными/результатом? Надо ли делать копию и хранить std::string/std::vector (owning) vs view-типы
Функция работает с контейнером или с диапазоном? Нужна ли привязка к vector std::vector<T>& vs std::span<const T>

2. Строки: const std::string& и std::string_view

Когда хорош const std::string&

const std::string& часто выглядит как “стандартный ответ” на вопрос “как передать строку без копирования”. И да, это реально хорошая базовая стратегия, потому что она читаема и предсказуема: функция принимает именно std::string, то есть ожидает нормальный владеющий объект строки. Ссылка const& означает: “я не буду менять строку”, и при этом мы избегаем копирования.

Но важно понимать: const std::string& — это не “самое современное”, а “самое честное” в смысле ожиданий. Если вы пишете функцию, которая логически работает именно со строкой-владельцем (например, вы точно знаете, что входные данные живут долго, и вам нужна богатая строковая функциональность), const std::string& может быть даже лучше, чем std::string_view, потому что меньше сюрпризов с временем жизни.

Хороший пример — функция, которая печатает строку и не хочет принимать “кусочки” или литералы без явного превращения в std::string:

#include <iostream>
#include <string>

void print_line(const std::string& s) {
    std::cout << s << '\n';
}

int main() {
    std::string name = "Alice";
    print_line(name); // Alice
}

Плюс const std::string& в том, что вызывающий код обычно уже имеет std::string. Минус — если вызывающий код имеет std::string_view или строковый литерал, то либо нужно перегрузить функцию, либо произойдёт создание временного std::string (то есть копирование/выделение памяти), и это может быть неожиданно дорогим, если вызовов много.

Тонкий момент: строковый литерал может быть передан в const std::string&, потому что C++ разрешает создать временный std::string и привязать к нему const&. Это удобно, но не бесплатно:

#include <iostream>
#include <string>

void debug(const std::string& s) {
    std::cout << s << '\n';
}

int main() {
    debug("hello"); // hello (но создаётся временный std::string)
}

И ещё один важный моральный урок: если внутри функции вам нужно сохранить входную строку “на потом”, то const std::string& сам по себе ничего не гарантирует. Если вы сохраните ссылку на входной параметр, а вызывающий код передал временную строку — вы получите висячую ссылку. Поэтому “сохраняю ссылку на параметр” — почти всегда подозрительно, независимо от того, std::string_view это или const std::string&.

Когда лучше std::string_view

std::string_view — это представление (view) на последовательность символов. В практическом смысле это очень маленький объект (примерно “указатель + длина”), который не владеет текстом. Он хорош там, где вы хотите: прочитать текст, разобрать, сравнить, проверить префикс, найти разделитель — и сразу забыть. std::string_view — часть стандартной библиотеки и активно используется для интерфейсов “принять текст без копий” в современном C++.

Главная победа std::string_view: функция начинает принимать текст из разных источников без лишних копий — std::string, литералы, части строк (через substr() у string_view), иногда буферы.

Классический паттерн: std::string_view как параметр — по значению. Почему не по const&? Потому что он маленький и копируется дёшево, а по значению ещё и проще для чтения: “функция берёт view и использует сейчас”.

#include <string_view>

bool is_command(std::string_view s, std::string_view cmd) {
    return s == cmd;
}

Теперь давайте привяжем это к нашему приложению. Представим, что мы делаем мини-утилиту командной строки: читаем строку команды и выполняем простые действия. Пусть команды будут такие: sum 1 2 3, avg 10 20, upper hello.

Начнём с функции, которая отделяет “первое слово” от “остальной части” без копирования:

#include <string_view>
#include <utility>

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

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

Ещё один момент, который часто забывают: string_view::data() не обязана указывать на null-terminated строку. То есть printf("%s", sv.data()) может закончиться неожиданным чтением “мусора” дальше границ view. В нашем курсе мы используем iostream, и это проще: std::cout << sv; печатает ровно нужное количество символов.

3. Диапазоны: std::span<const T>

Когда мы пишем функции для чисел, очень часто логика не зависит от того, откуда числа взялись: это может быть std::vector<int>, std::array<int, N>, обычный C-массив int a[], или вообще кусок vector, который мы хотим обработать. Если принимать const std::vector<int>&, мы жёстко привязываем функцию к vector — и теряем гибкость.

std::span<const T> решает это: он выражает “я читаю непрерывный диапазон элементов типа T”. Это тот же принцип view: span не владеет памятью, он только знает, где начало и сколько элементов. И, как и string_view, span чаще всего передают по значению, потому что он маленький.

Сделаем в нашем приложении две функции: сумма и максимум. Обратите внимание: код короткий и понятный, а сигнатура говорит “я только читаю”.

#include <span>

int sum(std::span<const int> xs) {
    int total = 0;
    for (int x : xs) total += x;
    return total;
}
#include <span>

int max_value(std::span<const int> xs) {
    int best = xs[0];
    for (int x : xs) if (x > best) best = x;
    return best;
}

Тут есть очевидная оговорка: max_value требует, чтобы xs был не пустой. В реальном коде мы бы добавили проверку и определились, что возвращать при пустом диапазоне, но отдельная стратегия ошибок — это отдельная тема. Сегодня важнее увидеть, как std::span<const T> выражает контракт “только чтение”.

Теперь красиво: мы можем вызвать sum() и для vector, и для массива:

#include <iostream>
#include <span>
#include <vector>

int main() {
    std::vector<int> v{1, 2, 3};
    int a[] = {10, 20};

    std::cout << sum(std::span<const int>(v.data(), v.size())) << '\n'; // 6
    std::cout << sum(std::span<const int>(a)) << '\n';                 // 30
}

Да, в первом вызове чуть многовато букв. В “боевом” стиле часто пишут просто std::span{v} или std::span(v), но это зависит от доступных конструкторов и deduction guides в вашей библиотеке. Идея остаётся той же: span — универсальный вход для “непрерывных данных”.

4. Практичные правила выбора параметров

Когда смотришь на все эти типы, легко впасть в крайности. Одни начинают писать string_view вообще везде (потому что “modern”), другие боятся его как огня (“там же указатели, страшно”). Наша цель — золотая середина: вы выбираете тип параметра так, чтобы контракт был понятен и риск был минимален.

Давайте зафиксируем набор “если… то…” в виде таблицы. Это не “закон C++”, а рабочие правила для нормального прикладного кода на уровне курса.

Сценарий Что лучше в параметре Почему
Нужно прочитать текст, не хранить, не менять
std::string_view
Принимает и string, и литералы, и подстроки, без копий
Нужно прочитать именно std::string, потому что API так устроено
const std::string&
Явно требует владельца-строку, меньше сюрпризов
Нужно изменить строку
std::string&
Контракт “могу менять”
Нужно вернуть новый текст, который должен жить сам
std::string
Возвращаем владельца результата
Нужно прочитать числа/элементы, без привязки к контейнеру
std::span<const T>
Универсальный “диапазон” без копий
Нужно изменить элементы, но не размер
std::span<T>
Можно менять xs[i], но нельзя push_back
Нужно менять размер
std::vector<T>&
span не выражает контракт изменения размера

Обратите внимание на важный психологический момент: тип параметра — это подсказка читателю. Если вы принимаете std::span<const int>, читатель уже понимает: “ага, это алгоритм, ему всё равно, что за контейнер”. Если вы принимаете std::vector<int>&, читатель уже настораживается: “тут могут менять размер/содержимое, надо аккуратнее”.

5. Когда view-типы не надо использовать

Очень хочется сделать красиво: “у меня везде string_view, везде span, я современный как C++23”. Но у view-типов есть границы, и они связаны не с синтаксисом, а со временем жизни данных и ясностью контракта. В этом разделе мы соберём ситуации, где view превращается из помощника в источник сюрпризов, и сделаем это без героизма: лучше чуть проще, но безопаснее.

Первый большой запрет: не возвращайте view на данные, которые созданы внутри функции. Это классическая ловушка: функция создала std::string, сделала string_view и вернула. Строка умерла, view остался. Компилятор не обязан вас спасать, а баг будет “плавающий” и неприятный.

#include <string>
#include <string_view>

std::string_view bad() {
    std::string tmp = "hello";
    return std::string_view(tmp); // tmp умрёт при выходе
}

Второй запрет: не храните view “где-то надолго”, если вы не контролируете владельца. Для простого курса это можно сформулировать грубо, но честно: view должен быть короткоживущим. Создали — использовали — выбросили. Хранить std::string_view как “поле модели” без гарантий — почти всегда ошибка, потому что модель начинает зависеть от чужого времени жизни.

Третий запрет связан с std::span: не используйте span, если функция по смыслу меняет размер контейнера. Иногда новичок думает: “ну я же могу принять std::span<int>, а потом как-то добавить элементы…”. Нельзя: span не владеет памятью и не умеет расширяться. Если вы меняете размер — это ответственность владельца (vector).

#include <span>
#include <vector>

void append_zero(std::vector<int>& v) {
    v.push_back(0); // это про размер, поэтому vector&
}

Четвёртый запрет: не создавайте view слишком рано, а потом не делайте “опасные” операции с владельцем. Например, вы взяли span на vector, а потом сделали push_back. Вектор мог перевыделить память, span стал смотреть “в прошлую жизнь”. Аналогично со string_view и операциями, которые могут менять буфер строки.

#include <span>
#include <vector>

int main() {
    std::vector<int> v{1, 2, 3};
    std::span<int> sp(v.data(), v.size());

    v.push_back(4); // может “переехать”
    // sp после этого использовать рискованно
}

Пятый случай более “дизайнерский”: не используйте view, если он ухудшает читаемость интерфейса. Иногда функция логически принимает “имя пользователя” как сущность, и вы хотите, чтобы вызывающий код явно передавал std::string. Если вы поставите string_view, вы расширите входы, но можете усложнить жизнь: появятся тонкие вопросы “а можно ли передавать сюда результат временного выражения?” и “а вы внутри не сохранили view?”. Если команда начинающих — простота иногда важнее микро-оптимизации.

6. Практический мини-рефакторинг

Чтобы закрепить всё не “в теории”, а руками, соберём минимальный каркас нашего приложения-командника. Мы читаем строку, выделяем команду, парсим числа, считаем сумму/среднее. Ключевой момент: строку-владельца (std::string line) держим в main, а в функции передаём string_view, чтобы не копировать и удобно резать на части.

Сначала — обработчик команды. Он принимает view на строку команды и решает, что делать:

#include <iostream>
#include <string_view>

void handle_command(std::string_view line) {
    auto [cmd, rest] = split_first_word(line);

    if (cmd == "sum") std::cout << "SUM\n";     // SUM
    else if (cmd == "avg") std::cout << "AVG\n"; // AVG
    else std::cout << "Unknown\n";              // Unknown
}

Здесь важно: handle_command не хранит line нигде, он просто анализирует. Поэтому string_view подходит идеально.

Теперь добавим разбор чисел через std::stringstream. Да, это потребует временный std::string, потому что stringstream работает со строкой-владельцем, но это нормально: мы делаем копию только для “остатка” команды, а не для всей архитектуры.

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

std::vector<int> parse_ints(std::string_view s) {
    std::stringstream ss(std::string{s});
    std::vector<int> xs;
    for (int x; ss >> x; ) xs.push_back(x);
    return xs;
}

Теперь мы можем сделать вычисления через span<const int>, чтобы логика не зависела от того, где хранятся числа:

#include <iostream>
#include <span>
#include <string_view>
#include <vector>

void run_sum(std::string_view rest) {
    std::vector<int> xs = parse_ints(rest);
    std::cout << sum(std::span<const int>(xs.data(), xs.size())) << '\n'; // например: 6
}

И наконец — main, который читает строки. Здесь std::string живёт как владелец, и мы создаём view “на время обработки”:

#include <iostream>
#include <string>

int main() {
    std::string line;
    while (std::getline(std::cin, line)) {
        handle_command(line); // line владеет, handle_command только смотрит
    }
}

Обратите внимание, как “контрактность” сигнатур делает код самодокументируемым. handle_command(string_view) намекает: “не храню, только читаю”. parse_ints возвращает vector<int>, потому что это уже данные, которыми надо владеть. sum(span<const int>) говорит: “я алгоритм, мне всё равно, что за контейнер”.

7. Типичные ошибки при работе с view-типами

Ошибки с view-типами часто выглядят как “ну оно же компилируется”. И именно поэтому они опаснее: компилятор не всегда может доказать, что вы создали висячий view, а ваш тест на двух строках “вроде работает”. В этом разделе соберём самые частые грабли и проговорим их человеческим языком, чтобы вы узнавали их по запаху ещё до того, как откроете отладчик.

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

Ошибка №2: “сохраню view в поле или глобальную переменную, чтобы не копировать”.
Такое желание обычно появляется после первого знакомства с string_view: кажется, что это бесплатная оптимизация. На практике это превращает ваш код в минное поле времени жизни: любая смена владельца, любое перевыделение буфера — и сохранённый view становится некорректным. Если нужно хранить — храните владельца (std::string, std::vector), а view создавайте только для краткого чтения.

Ошибка №3: создать span/string_view, а потом поменять владельца так, что он “переедет”.
Для vector это часто push_back, resize, иногда erase. Для string — конкатенация +=, append, replace и любые операции, меняющие размер. Правильный порядок действий такой: сначала “подготовить владельца” (в идеале — закончить изменения), потом создать view и сразу использовать.

Ошибка №4: принять std::span<T> там, где вы ничего не меняете.
Это более мягкая, но очень важная ошибка дизайна. Если функция только читает, она должна принимать std::span<const T>. Тогда вы не сможете случайно изменить элементы, и вызывающий код сможет передавать в неё константные данные. Такой const — это не “занудство”, а защита от случайных ошибок.

Ошибка №5: использовать std::span, когда функция меняет размер контейнера.
Иногда пытаются “унифицировать” интерфейсы и везде ставят span. Но span не отражает контракт изменения размера, и внутри вы всё равно упрётесь в необходимость push_back/erase. В таких случаях честнее и понятнее принять std::vector<T>& (или другой контейнер-владелец), потому что это прямо говорит: “я управляю содержимым, включая размер”.

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