JavaRush /Курси /C++ SELF /Проєктування сигнатур: string_view чи const std::string&a...

Проєктування сигнатур: string_view чи const std::string&

C++ SELF
Рівень 18 , Лекція 4
Відкрита

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++», а робочі правила для нормального прикладного коду на рівні курсу.

Сценарій Що краще в параметрі Чому
Потрібно прочитати текст, не зберігати й не змінювати
std::string_view
Приймає і string, і літерали, і підрядки без копій
Потрібно прочитати саме std::string, бо так влаштовано API
const std::string&
Явно вимагає рядок-власник, тож сюрпризів менше
Потрібно змінити рядок
std::string&
Контракт: «можу змінювати»
Потрібно повернути новий текст, який має жити самостійно
std::string
Повертаємо власника результату
Потрібно прочитати числа або елементи, без привʼязки до контейнера
std::span<const T>
Універсальний діапазон без копій
Потрібно змінити елементи, але не розмір
std::span<T>
Можна змінювати xs[i], але не можна викликати push_back
Потрібно змінювати розмір
std::vector<T>&
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>& (або інший контейнер-власник), бо це прямо каже: «я керую вмістом, зокрема й розміром».

1
Опитування
Типи view, рівень 18, лекція 4
Недоступний
Типи view
Типи view
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ