1. Вступ
Іноді здається, що return — це як квиток у кіно: один квиток — одна людина. А ми хочемо «і друга провести, і попкорн прихопити». У програмуванні така ситуація трапляється постійно: ми виконали обчислення й хочемо повернути не лише результат, а й додаткові деталі — наприклад, чи все вдалося, яка сталася помилка, який залишок отримали, який індекс знайшли тощо.
Найпростіший шлях — зробити так, щоб функція повертала лише «головне», а решту записувала в параметри. Це працює, але часто погіршує читабельність: у місці виклику важко зрозуміти, що є вхідними даними, а що — вихідними, і чому аргумент раптом змінився.
Один return, але складений тип
Якщо спростити, то ми робимо ось що: замість того щоб повертати int або std::string, повертаємо, наприклад, struct із полями, або std::pair, або std::tuple.
Візуально це можна подати так:
flowchart TD
A["Функція виконує роботу"] --> B["Збирає результат із кількох частин"]
B --> C["Повертає один обʼєкт (struct / pair / tuple)"]
C --> D["Зовнішній код читає частини результату"]
Важливо: ми поки не обговорюємо «зручне розпакування» результату в кілька змінних — це буде в наступній лекції. Тут наша мета — навчитися обирати контейнер для результату й не робити контракт функції нечітким.
Щоб приклади не були абстрактними, розвиватимемо невеликий консольний застосунок для обліку витрат. Користувач вводить рядки такого вигляду:
- add 120 coffee
- add 450 groceries
- total
- exit
Поки що ми не працюємо з файлами, винятками й складними парсерами. Нам достатньо зрозуміти одне: рядок треба розібрати, а результат розбору може як бути успішним, так і ні. Саме тому й хочеться повернути більше, ніж одне значення.
2. struct: промовистий результат
Коли ви повертаєте кілька значень, майже завжди кожне з них має свій сенс і свою роль: «це команда», «це сума», «це коментар», «це успіх/помилка». struct дає змогу обʼєднати кілька змінних і дати імена частинам результату. Це суттєво зменшує ризик переплутати поля місцями, а код стає самодокументованим.
struct для результату парсингу
Зробимо функцію, яка намагається розібрати команду "add ..." і повертає результат у вигляді трьох змінних:
bool ok;
int amount;
std::string category;
У C++ їх можна обʼєднати в один тип даних. Наприклад, так:
#include <string>
struct AddCommand {
bool ok;
int amount;
std::string category;
};
У struct є приємна властивість: ви дивитеся на поля й одразу розумієте зміст. Не треба памʼятати, що «друге поле — це категорія», як буває з pair.
Тепер сама функція:
#include <string>
AddCommand parse_add_command(const std::string& line) {
if (line.rfind("add ", 0) != 0) return {false, 0, ""};
return {true, 0, "todo"}; // поки заглушка
}
Поки що тут лише заглушка — ми ще не вміємо нормально парсити число без тем, які вивчатимемо далі. Але сама модель важлива: функція повертає один обʼєкт, у якому є і ознака успіху, і дані.
Як це виглядає у зовнішньому коді
Коли ви використовуєте struct, місце виклику читається легко:
#include <iostream>
#include <string>
int main() {
std::string line = "add 120 coffee";
AddCommand cmd = parse_add_command(line);
if (!cmd.ok) {
std::cout << "Неправильна команда\n"; // Неправильна команда
return 0;
}
std::cout << cmd.amount << " " << cmd.category << "\n"; // 0 todo
}
Так, дані поки що не справжні, але зверніть увагу на стиль: cmd.ok, cmd.amount, cmd.category — усе максимально прозоро. Саме за це struct люблять у прикладному коді: він зменшує «ментальний податок» на читання.
struct як частина контракту
У застосунку для обліку витрат нам зручно мати окремий тип результату, тому що це вже бізнес-сутність. Сьогодні команда "add" повертає amount і category. Завтра ви захочете додати comment, і struct природно розшириться.
Але якщо окремий тип створювати не хочеться, а повернути два значення треба, можна скористатися простішими варіантами. Кілька таких варіантів — нижче.
3. std::pair: коли значень рівно два
std::pair<T1, T2> — це стандартний тип «два значення разом». Він дуже зручний, коли значень справді рівно два й вони настільки очевидні, що не хочеться створювати окремий struct.
Але у pair є слабке місце: доступ відбувається через .first і .second, а це не надто змістовні імена. Вони чесні — перше і друге, — але сенсу не розкривають.
Приклад: розділити рядок на команду й решту
Часто в CLI потрібно відокремити перше слово від решти рядка. Це ідеальний кандидат для pair: дві частини, і обидві — рядки.
#include <string>
#include <utility>
std::pair<std::string, std::string> split_first_word(const std::string& line) {
std::size_t pos = line.find(' ');
if (pos == std::string::npos) return {line, ""};
return {line.substr(0, pos), line.substr(pos + 1)};
}
Тут ми повернули два рядки: команду й залишок.
Використання:
#include <iostream>
#include <string>
#include <utility>
int main() {
auto p = split_first_word("add 120 coffee");
std::cout << p.first << "\n"; // add
std::cout << p.second << "\n"; // 120 coffee
}
Це цілком читабельно, тому що «перше слово» і «залишок» — зрозуміла пара. Але якби ви зробили pair<int, int> і намагалися памʼятати, де «мінімум», а де «максимум», було б уже гірше.
Коли pair зручніший, ніж struct
Іноді тип результату справді «одноразовий» і локальний. Наприклад, в одній функції ви розділили рядок і тут же його використали. Тоді створювати окремий struct може здаватися бюрократією: «я хотів просто два значення, а мені довелося створювати сутність, ніби я реєструю ТОВ».
pair у таких місцях — цілком нормальний компроміс: швидко, стандартно, без зайвих оголошень.
4. std::tuple: коли значень більше двох
std::tuple<T1, T2, T3, ...> дає змогу повернути одразу багато значень. Із технічного погляду це потужно. А з людського — тут варто бути обережними: доступ зазвичай відбувається через std::get<0>(t), std::get<1>(t) і так далі, а разом із цим дуже швидко зʼявляється «магія індексів», де переплутати сенс простіше, ніж переплутати шкарпетки після прання.
І все ж tuple інколи виправданий: коли значень справді багато й ви не хочете оголошувати struct, або коли типи дуже різні й треба швидко «склеїти» їх в один результат.
Приклад: divmod (частка, остача, успіх)
Зробимо функцію «ділення з остачею та прапорцем успіху». Ділити на нуль не можна, тому потрібен ok.
#include <tuple>
std::tuple<int, int, bool> divmod(int a, int b) {
if (b == 0) return {0, 0, false};
return {a / b, a % b, true};
}
Використання без розпакування, якого ми ще не вивчали:
#include <iostream>
#include <tuple>
int main() {
auto t = divmod(10, 3);
int q = std::get<0>(t);
int r = std::get<1>(t);
bool ok = std::get<2>(t);
std::cout << q << " " << r << " " << ok << "\n"; // 3 1 1
}
Працює, але зверніть увагу: з одного лише коду get<0> зовсім не ясно, що це — частка чи остача. Це знання живе лише у вас у голові, а не в коді. У цьому й полягає головний мінус tuple.
Коли tuple буває доречний
Якщо ви пишете маленьку внутрішню функцію і результат одразу використовується поруч, то tuple може бути допустимим. Але щойно цей результат почне використовуватися в інших місцях коду, читабельність почне падати.
На практиці багато команд користуються простим правилом: tuple — це радше інструмент для локальних технічних склейок, а для публічного контракту функції краще взяти struct.
5. Як обрати: struct vs pair vs tuple
Тепер зведемо все в одну таблицю. Вона не замінює здорового глузду, але добре допомагає ухвалити рішення без філософії рівня «а давайте подумаємо про природу порядку».
| Критерій | |
|
|
|---|---|---|---|
| Скільки значень | Будь-яка кількість | Рівно 2 | Будь-яка кількість (зазвичай 3+) |
| Читабельність у місці використання | Висока: |
Середня: |
Часто низька: |
| Імена частин результату | Є (поля) | Немає () |
Немає (індекси) |
| Ризик переплутати порядок | Низький | Середній | Високий |
| Зручно розширювати результат (додати поле) | Так | Незручно (стане tuple/struct) | Так, але ламає індекси й сенс |
| «Вага» в коді (скільки оголошень) | Потрібно оголосити тип | Нічого оголошувати не треба | Нічого оголошувати не треба |
| Найкращий сценарій | Публічний контракт і бізнес-сенс | Швидка пара «це + те» | Внутрішній технічний результат із кількох частин |
Головна думка: якщо результат має змістові частини — обирайте struct. Якщо значень рівно два і все очевидно — pair буде добрим вибором. Якщо значень багато й ви готові миритися з індексами — tuple, але обережно.
Повернення результату через параметри
Після лекцій про T& і T* ви могли подумати: «А навіщо взагалі struct, якщо можна написати bool parse(..., int& out_amount, string& out_cat)?». Так, можна. У деяких API так і роблять.
Але мінус у тому, що в місці виклику змінні out_amount і out_cat мають бути заздалегідь оголошені, а сигнатура стає довшою. До того ж зʼявляється ризик забути перевірити bool і використати out_* так, ніби там уже коректні дані.
Повернення struct психологічно дисциплінує: ви спочатку отримуєте «обʼєкт результату», а потім явно перевіряєте поля. Код стає більш лінійним і менш «магічним».
6. Приклад: пошук категорії в застосунку
Повернімося до застосунку для обліку витрат. Припустімо, ми зберігаємо категорії та суми у двох паралельних векторах. Це не ідеальна модель, але на ранньому етапі курсу вона зрозуміліша, ніж одразу ускладнювати все.
Зробимо функцію, яка шукає категорію й повідомляє: знайшли її чи ні, і, якщо знайшли, — який у неї індекс.
Рішення через struct
#include <string>
#include <vector>
struct FindResult {
bool found;
std::size_t index;
};
FindResult find_category(const std::vector<std::string>& cats, const std::string& name) {
for (std::size_t i = 0; i < cats.size(); ++i)
if (cats[i] == name) return {true, i};
return {false, 0};
}
Використання:
#include <iostream>
#include <string>
#include <vector>
int main() {
std::vector<std::string> cats{"coffee", "groceries"};
FindResult r = find_category(cats, "coffee");
if (r.found) std::cout << r.index << "\n"; // 0
}
Так, у index за found == false значення умовне (0). Ми домовилися, що дивитися на index можна лише тоді, коли found == true. Далі в курсі зʼявляться строгіші моделі, але зараз нам важливий сам принцип: «кілька значень одним обʼєктом».
Рішення через pair
Якщо дуже хочеться, можна зробити так:
#include <string>
#include <utility>
#include <vector>
std::pair<bool, std::size_t> find_category2(const std::vector<std::string>& cats,
const std::string& name) {
for (std::size_t i = 0; i < cats.size(); ++i)
if (cats[i] == name) return {true, i};
return {false, 0};
}
Використання:
#include <iostream>
#include <utility>
#include <vector>
int main() {
std::vector<std::string> cats{"coffee", "groceries"};
auto p = find_category2(cats, "tea");
std::cout << p.first << "\n"; // 0
}
Це працює, але .first — це found чи index? Зараз ви це памʼятаєте, але за тиждень можете вже не памʼятати. Тому для таких результатів struct зазвичай перемагає.
7. Типові помилки під час повернення кількох значень
Помилка № 1: повертати pair/tuple, коли частини результату легко переплутати.
Якщо ви повертаєте два числа, і одне з них — «мінімум», а інше — «максимум», то .first і .second майже не допомагають. Через деякий час ви й самі почнете читати свій код як ребус. У таких місцях struct з полями min і max раптом робить програму дружнішою.
Помилка № 2: змінювати порядок значень «у процесі».
Сьогодні ви вирішили, що tuple повертає {q, r, ok}. Через два дні ви поміняли на {ok, q, r}, бо «так зручніше всередині». Усередині — можливо, але зовні це ламає контракт. Для pair/tuple порядок — це частина API. Якщо ви відчуваєте, що його постійно хочеться переграти, це сигнал, що вам потрібен struct з іменами.
Помилка № 3: використовувати tuple як заміну нормальної моделі даних.
tuple — не «універсальний контейнер сенсу», а просто упаковка кількох значень. Якщо ви почали передавати по коду tuple<int, string, bool, double> і ще й у трьох місцях по-різному трактувати індекси, — вітаю, ви винайшли мініхаос. Краще один раз назвати сутність і виразити сенс через поля.
Помилка № 4: намагатися повернути посилання на локальні змінні всередині результату.
Іноді новачок робить щось на кшталт «поверну tuple<const string&, bool>» і кладе туди посилання на локальний рядок. Після виходу з функції локальний рядок знищується, а посилання стає небезпечним. На нашому рівні простіше триматися такого правила: повертаємо значення, а не посилання, якщо не впевнені на 200 %.
Помилка № 5: забувати про сенс поля ok/found і читати інші поля без перевірки.
Якщо ваш результат має вигляд {ok, value}, то value має сенс лише за ok == true. Якщо ви пишете код, який спочатку читає value, а вже потім перевіряє ok, ви самі влаштовуєте собі сюрпризи. Спочатку перевірка, потім використання — це не занудство, а страховка від дивних багів.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ