JavaRush /Курсы /C++ SELF /std::span как view на...

std::span как view на непрерывные данные

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

1. Основы std::span: память, view и const-контракт

Если вы пишете учебные программы, сначала всё выглядит просто: вот у меня есть std::vector<int>, я передаю его в функцию, и всё хорошо. Но затем в коде внезапно появляется обычный C-массив, потом std::array, а потом вы хотите обработать не «всё», а, например, только первые 10 элементов. И начинается классика: либо вы пишете три версии одной и той же функции, либо переходите на «указатель + размер» и начинаете жить в режиме “пожалуйста, не перепутай параметры местами”.

std::span — это аккуратная стандартная упаковка идеи «указатель на начало + длина», только в виде нормального типа с методами и понятным контрактом. Он существует именно как вокабулярный тип стандартной библиотеки: то есть тип, который вы используете в интерфейсах, чтобы другие люди (и вы через неделю) сразу понимали смысл. Сам факт, что span подробно обсуждается и правится в стандартных документах, хорошо показывает его «официальный» статус в языке.

Что такое непрерывные данные: простая модель памяти

Перед тем как брать std::span, нужно почувствовать, на что он вообще может смотреть. «Непрерывные данные» означают, что элементы лежат в памяти подряд, без дырок: один за другим, как места в кинотеатре (только в кинотеатре места иногда ломаются, а в памяти — если вы сами не устроите UB).

Такими контейнерами являются C-массив T arr[N], std::array<T, N>, std::vector<T> (его элементы лежат подряд, пока вектор не «переехал» при росте), и строка std::string (символы тоже лежат подряд, и у них есть data() и size()). А вот, например, std::list — не непрерывный: там каждый элемент живёт отдельно, как коты, которые не согласны лежать рядом.

Чтобы закрепить ощущение, вот условная схема того, что значит «подряд»:

flowchart LR
    A["arr[0]"] --> B["arr[1]"] --> C["arr[2]"] --> D["arr[3]"]
    subgraph Memory["Память: подряд, без дырок"]
      A
      B
      C
      D
    end

И именно на такой «плотный ряд» std::span и рассчитан.

std::span<T>: «указатель + размер», только по-человечески

Теперь можно знакомиться с героем лекции. std::span<T> — это невладеющее представление (view) на последовательность элементов T, которые лежат непрерывно. Внутри это действительно очень близко к паре (T*, size), но с нормальным интерфейсом: size(), empty(), operator[], range-for и так далее.

Важно не перепутать философию. std::vector<int> — это коробка с числами и своим складом (памятью). std::span<int> — это стикер на коробке: «смотри на элементы вот отсюда и вот столько». Если коробка исчезла, стикер не начинает внезапно хранить числа в кармане.

Минимальный пример «создали спан и пробежались»:

#include <iostream>
#include <span>

int main() {
    int a[] = {10, 20, 30};

    std::span<int> s(a); // view на весь массив
    std::cout << s.size() << '\n';   // 3
    std::cout << s[1] << '\n';       // 20
}

Обратите внимание на стиль: span часто передают по значению, потому что он маленький (как два числа: адрес и длина). Передавать const std::span<int>& обычно не нужно: вы не экономите копирование «чего-то большого», там нечему быть большим.

std::span<const T> vs std::span<T>: «читаю» и «меняю элементы»

Когда новичок впервые видит span, он часто делает одну и ту же вещь: везде пишет std::span<int>, даже если функция только читает. Это примерно как брать бензопилу, чтобы порезать хлеб: иногда сработает, но окружающим тревожно.

Если функция не должна менять элементы, то правильный выбор — std::span<const T>. Это сразу «зашивает» в тип параметра обещание: «я читаю, но не изменяю».

Сравните две функции (и обратите внимание, что код почти одинаковый — разница именно в контракте):

#include <span>

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

void negate_all(std::span<int> xs) {
    for (int& x : xs) x = -x;
}

Смысл прост: std::span<int> позволяет менять сами элементы, но по-прежнему не позволяет менять размер (нельзя “добавить элемент в span”, потому что добавлять можно только владельцу, например vector).

3. Откуда брать std::span

Сейчас будет приятная часть: span действительно делает код «универсальнее», но без магии и без шаблонов (пока что). Вы просто берёте непрерывные данные — и делаете на них view.

Из C-массива T arr[N]

После заголовка важно понять: C-массив — это честный непрерывный блок. std::span умеет из него строиться очень удобно:

#include <iostream>
#include <span>

void print_first(std::span<const int> xs) {
    if (xs.empty()) return;
    std::cout << xs[0] << '\n';
}

int main() {
    int a[] = {5, 6, 7};
    print_first(a); // span создаётся неявно
}

Здесь происходит полезная вещь: print_first не знает, что такое «массив» как отдельный тип хранения. Она знает только: «мне дали последовательность int».

Из std::array<T, N>

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

#include <array>
#include <iostream>
#include <span>

int main() {
    std::array<int, 4> a{1, 2, 3, 4};
    std::span<const int> s(a);

    std::cout << s.size() << '\n'; // 4
}

Из std::vector<T>

Вектор тоже подходит идеально, но с оговоркой: память у вектора может перевыделяться при росте. Сегодня мы это просто фиксируем как факт; подробно «когда ломается view» будем разбирать в следующей лекции дня. Пока используем span как короткоживущий объект «рядом с вызовом функции».

#include <iostream>
#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> v{10, 20, 30};
    std::cout << sum(v) << '\n'; // 60
}

С точки зрения читаемости это один из самых приятных моментов: функция принимает не «вектор», а «диапазон элементов». И внезапно вы можете скормить ей и массив, и std::array, и std::vector.

std::span для std::string: да, строка непрерывная, но это не string_view

Переходим к тонкому месту: “span на строку”. Символы std::string лежат подряд, поэтому можно сделать std::span<const char> и обработать строку как массив символов. Это иногда полезно, когда вы хотите именно «байтовое» или «символьное» представление без особенностей строковых методов.

Но важно не путать роли. std::string_view — это view именно строки (и там удобно find, substr, remove_prefix). std::span<const char> — это view на символы как элементы массива. Он не «понимает» подстроки как строковые операции, но зато универсален как диапазон char.

Пример: посчитаем, сколько в тексте цифр (не очень умно, зато показательно):

#include <iostream>
#include <span>
#include <string>

int count_digits(std::span<const char> chars) {
    int c = 0;
    for (char ch : chars) {
        if (ch >= '0' && ch <= '9') ++c;
    }
    return c;
}

int main() {
    std::string s = "Room 101";
    std::cout << count_digits(std::span<const char>(s.data(), s.size())) << '\n'; // 3
}

Почему нельзя просто count_digits(s)? Потому что std::string не обязан неявно превращаться в span. Мы создаём span явно через data() и size(), и это даже хорошо: так вы меньше шансов «случайно» получить view туда, куда не хотели.

4. Ежедневные операции со span

Сейчас полезно приземлиться: в 90% случаев от span вам нужны очень простые вещи. Вы проверяете empty(), узнаёте size(), пробегаете range-for, иногда берёте элемент по индексу. По сути это тот же набор, что вы уже делали для std::vector и std::string, только теперь он работает для «чужих данных».

Небольшой пример «распечатать диапазон красиво»:

#include <iostream>
#include <span>

void print(std::span<const int> xs) {
    std::cout << '[';
    for (std::size_t i = 0; i < xs.size(); ++i) {
        std::cout << xs[i] << (i + 1 == xs.size() ? "" : ", ");
    }
    std::cout << "]\n";
}

int main() {
    int a[] = {3, 1, 4};
    print(a); // [3, 1, 4]
}

Обратите внимание: индексация у span такая же «честная», как у массива или vector через operator[]: границы он не проверяет. Поэтому если индекс приходит извне, проверка — ваша ответственность.

subspan(): «окошко» на часть данных без копирования

Теперь самое вкусное: std::span умеет делать «под-диапазоны» без копирования элементов. Это похоже по духу на std::string_view::substr(): вы не создаёте новый массив, вы создаёте новый взгляд на кусочек старого массива.

Это полезно в прикладных задачах постоянно. Например, вы хотите обработать «все элементы, кроме первого», или «первые N», или «середину». И вместо копирования в новый std::vector вы делаете subspan().

Пример: берём средние два элемента:

#include <iostream>
#include <span>

int main() {
    int a[] = {10, 20, 30, 40};

    std::span<int> all(a);
    std::span<int> mid = all.subspan(1, 2); // {20, 30}

    std::cout << mid[0] << ' ' << mid[1] << '\n'; // 20 30
}

Важно: subspan() не меняет владельца и не двигает данные. Он просто говорит: «начинай смотреть с позиции offset, и смотри count элементов». Если указали неверные границы — получите ошибку логики (а иногда и UB), как и с любой индексацией.

5. Практический пример: добавляем span в “MiniStats”

Чтобы std::span не остался «умной теорией», давайте встроим его в маленький консольный кусочек приложения, которое мы постепенно развиваем по курсу. Пусть у нас будет модуль “MiniStats”: он умеет считать сумму и среднее по последовательности чисел, причём источник данных не важен: массив, std::array или std::vector.

Считаем сумму и среднее по span<const int>

Сделаем две функции, максимально простые и «читаемые глазами». Мы пока не лезем в сложные модели данных: просто работаем с числами и выводом.

#include <iostream>
#include <span>

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

double average(std::span<const int> xs) {
    if (xs.empty()) return 0.0;
    return static_cast<double>(sum(xs)) / xs.size();
}

Здесь приятно то, что average умеет принимать любые непрерывные числа: ему не нужен vector. И он честно обрабатывает пустой диапазон.

Вызываем эти функции и для массива, и для vector

Теперь покажем «вот зачем это всё было»:

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

int sum(std::span<const int> xs);
double average(std::span<const int> xs);

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

    std::cout << sum(a) << '\n';        // 6
    std::cout << average(v) << '\n';    // 25
}

Фокус в том, что у нас одни и те же функции работают с разными источниками данных. И их сигнатуры не «врут»: sum и average не владеют числами и не меняют их — они просто читают.

Обрабатываем только кусочек данных через subspan()

Очень типичный сценарий в реальных программах: «посчитай статистику не по всем числам, а по хвосту/префиксу». Покажем это:

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

int sum(std::span<const int> xs);

int main() {
    std::vector<int> v{5, 100, 100, 100};

    std::span<const int> all(v);
    std::span<const int> tail = all.subspan(1); // всё, кроме первого

    std::cout << sum(tail) << '\n'; // 300
}

Обратите внимание: мы не копировали {100, 100, 100} в новый контейнер. Мы просто сделали «окошко» на нужный диапазон.

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

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

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

Ошибка №3: строить span и хранить его “где-то подольше”, не думая о владельце.
span не продлевает жизнь данных. Если владелец исчез (например, это был локальный std::vector внутри функции), span становится бесполезной бумажкой с адресом в никуда. Точно так же неприятно, когда владелец “переезжает” (особенно это актуально для std::vector при росте). Сегодня достаточно запомнить правило: span должен жить недолго и рядом с местом использования.

Ошибка №4: забыть, что operator[] не проверяет границы.
У span индексация такая же «без страховки», как у массивов и у vector через []. Если индекс вычисляется или приходит из ввода, сначала проверяйте i < xs.size(). Иначе вы получите либо мусор, либо падение, либо (самое “весёлое”) «иногда работает».

Ошибка №5: делать span на std::string и ожидать “строковых” операций.
std::span<const char> — это диапазон char, не строка. Он не знает про find, starts_with и “подстроки” как текст. Если задача про текст — чаще удобнее std::string_view. Если задача про «массив байт/символов» — тогда span уместен.

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