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 ..." и сообщает результат в виде 3-х переменных:
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 << "Bad command\n"; // Bad command
return 0;
}
std::cout << cmd.amount << " " << cmd.category << "\n"; // 0 todo
}
Да, данные пока не настоящие, но посмотрите на стиль: cmd.ok, cmd.amount, cmd.category — всё максимально прозрачно. Именно за это struct любят в прикладном коде: он снижает «ментальный налог» на чтение.
struct как часть контракта
В вашем приложении «учёт расходов» нам удобно иметь отдельный тип результата, потому что это уже бизнес-сущность. Сегодня команда "add" возвращает amount и category. Завтра вы захотите добавить comment, и struct расширяется естественно.
Но если вы не хотите создавать отдельный тип, а вам ну очень нужно вернуть 2 значения из функции, то можно воспользоваться лайфхаками. Список лайфхаков ниже.
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, вы сами себе устраиваете сюрпризы. Сначала проверка, потом использование — это не занудство, а страховка от странных багов.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ