JavaRush /Курсы /C++ SELF /Вернуть несколько значений: struct, std::pair и std::tupl...

Вернуть несколько значений: struct, std::pair и std::tuple

C++ SELF
15 уровень , 3 лекция
Открыта

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

Теперь сведём всё в одну таблицу. Она не заменяет голову, но хорошо помогает принять решение без философии уровня «а давайте подумаем о природе порядка».

Критерий
struct
std::pair
std::tuple
Сколько значений Любое Ровно 2 Любое (обычно 3+)
Читаемость на месте использования Высокая:
res.amount
Средняя:
p.first
Часто низкая:
get<0>
Имена частей результата Есть (поля) Нет (
first/second
)
Нет (индексы)
Риск перепутать порядок Низкий Средний Высокий
Удобно расширять результат (добавить поле) Да Неудобно (станет 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, вы сами себе устраиваете сюрпризы. Сначала проверка, потом использование — это не занудство, а страховка от странных багов.

1
Задача
C++ SELF, 15 уровень, 3 лекция
Недоступна
Два ориентирa
Два ориентирa
1
Задача
C++ SELF, 15 уровень, 3 лекция
Недоступна
Первое слово и хвост
Первое слово и хвост
1
Задача
C++ SELF, 15 уровень, 3 лекция
Недоступна
Команда пополнения
Команда пополнения
1
Задача
C++ SELF, 15 уровень, 3 лекция
Недоступна
Деление без аварии
Деление без аварии
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ