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() не зобовʼязана вказувати на рядок, завершений нульовим символом. Тобто 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 всюди підряд, бо «це сучасно», інші бояться його як вогню: «там же вказівники, страшно». Наша мета — золота середина: обирати тип параметра так, щоб контракт був зрозумілим, а ризики — мінімальними.
Зафіксуймо набір «якщо… то…» у вигляді таблиці. Це не «закон 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>& (або інший контейнер-власник), бо це прямо каже: «я керую вмістом, зокрема й розміром».
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ