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 доречний.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ