JavaRush /Курси /C++ SELF /std::span як представ...

std::span як представлення неперервних даних

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> — це стікер на коробці: «елементи починаються тут, а їх ось стільки». Якщо коробка зникне, стікер раптом не почне зберігати числа у кишені.

Мінімальний приклад: створюємо span і працюємо з ним:

#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.

2. Звідки брати 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 там, де ви цього не хотіли.

3. Щоденні операції зі 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 — як і з будь-якою індексацією.

4. Практичний приклад: додаємо 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} у новий контейнер. Ми просто зробили «віконце» на потрібний діапазон.

5. Типові помилки під час роботи з 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 доречний.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ