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++”, а рабочие правила для нормального прикладного кода на уровне курса.
| Сценарий | Что лучше в параметре | Почему |
|---|---|---|
| Нужно прочитать текст, не хранить, не менять | |
Принимает и string, и литералы, и подстроки, без копий |
| Нужно прочитать именно std::string, потому что API так устроено | |
Явно требует владельца-строку, меньше сюрпризов |
| Нужно изменить строку | |
Контракт “могу менять” |
| Нужно вернуть новый текст, который должен жить сам | |
Возвращаем владельца результата |
| Нужно прочитать числа/элементы, без привязки к контейнеру | |
Универсальный “диапазон” без копий |
| Нужно изменить элементы, но не размер | |
Можно менять xs[i], но нельзя push_back |
| Нужно менять размер | |
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>& (или другой контейнер-владелец), потому что это прямо говорит: “я управляю содержимым, включая размер”.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ