JavaRush /Курсы /C++ SELF /std::optional<T>: «значение есть/нет» вместо магиче...

std::optional<T>: «значение есть/нет» вместо магических -1

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

1. Проблема «магических значений»

Когда вы пишете первые программы, очень хочется, чтобы всё было «просто»: нашли элемент — вернули индекс, не нашли — вернули -1. Это выглядит как маленькая хитрость, которая экономит пару строк кода. Но у этой хитрости есть побочный эффект: вы начинаете жить в мире скрытых договорённостей — и однажды сами же о них забудете.

Представьте, что мы делаем наш учебный мини‑проект: консольный список задач (условный TaskList). У нас есть std::vector<Task> tasks;, и мы хотим найти задачу по id. Первое, что часто пишут — вот так:

#include <vector>
#include <cstddef>

int find_index_by_id_old(const std::vector<int>& ids, int id) {
    for (std::size_t i = 0; i < ids.size(); ++i) {
        if (ids[i] == id) return static_cast<int>(i);
    }
    return -1;
}

Код рабочий, но он вынуждает нас «переехать» на int, хотя индексы у vector — это обычно std::size_t. И теперь везде по коду будет болтаться правило: «если вернулось -1, значит не найдено». А если вы забудете проверить -1 и попытаетесь использовать его как индекс, получится эффект «минус один внезапно стал огромным числом» (спасибо неявным преобразованиям к size_t).

Чтобы стало совсем жизненно, вот типичный баг-скетч:

#include <iostream>
#include <vector>

int main() {
    std::vector<int> ids{10, 20, 30};
    int idx = -1; // допустим, “не нашли”
    std::cout << ids[idx] << '\n'; // UB / авария / “работает на моём компе”
}

То есть проблема не в том, что -1 «плохой». Проблема в том, что -1 — это значение того же типа, что и «нормальный результат», и компилятор не может помочь вам отличить «нашли индекс» от «не нашли».

2. Идея std::optional<T>: тип, который честно говорит «может не быть»

Когда вы в реальной жизни спрашиваете друга: «У тебя есть сдача?», ответ бывает двух видов: «да» (и вот сдача) или «нет». И это разные состояния, а не «сдача = -1 евро». std::optional<T> — это примерно такая же честность, только в коде.

std::optional<T> — это контейнер для 0 или 1 значения типа T.

Он умеет находиться в двух состояниях:

  • внутри лежит значение типа T (говорим: «optional не пустой»);
  • значения нет (говорим: «optional пустой»), и это обозначается std::nullopt.

Важная мысль: отсутствие результата становится частью типа, а не тайной договорённостью.

Минимальный пример «есть/нет»:

#include <iostream>
#include <optional>

int main() {
    std::optional<int> a = 10;
    std::optional<int> b = std::nullopt;

    std::cout << static_cast<bool>(a) << '\n'; // 1
    std::cout << static_cast<bool>(b) << '\n'; // 0
}

Если сказать по‑простому: optional<int> — это «возможно int».

Небольшой факт «из мира стандартов»: std::optional<T> нельзя сделать для массива T[] — то есть optional<int[10]> запрещён (это считается некорректным типом). В наших учебных задачах это почти никогда не мешает, но полезно знать, что у optional есть ограничения на то, какой тип можно туда класть.

3. Возвращаем optional из функции: «нашёл → вернул, не нашёл → nullopt»

Перед тем как воткнуть optional в наш проект, важно поймать правильную интонацию: optional чаще всего появляется там, где вы раньше возвращали «особое значение», потому что «иногда результата нет».

В поиске по vector это прям классика. Давайте сделаем нормально: индекс либо существует, либо нет.

#include <cstddef>
#include <optional>
#include <vector>

std::optional<std::size_t> find_index_by_id(const std::vector<int>& ids, int id) {
    for (std::size_t i = 0; i < ids.size(); ++i) {
        if (ids[i] == id) return i;
    }
    return std::nullopt;
}

Обратите внимание на две детали.

Во-первых, мы вернули именно std::optional<std::size_t>. Теперь индекс остался «родного типа», и нам не нужно втискивать -1 в int.

Во-вторых, в ветке «не нашли» мы возвращаем std::nullopt. Это не число, не «плохой индекс», не 0, но по‑особенному — это отдельное состояние: «значения нет».

Если вы любите читать код как текст, то эта функция буквально читается так:
«Верни возможный индекс; если нашёл — верни его; иначе — верни отсутствие».

4. Как пользоваться optional: сначала проверка, потом доступ

Теперь самое важное: optional помогает, но не делает магию автоматически. Он не превращает программу в неуязвимую крепость. Он просто вынуждает вас явно обработать вариант «результата нет».

Самый безопасный стиль для новичка звучит так: сначала спроси «есть?», потом возьми.

Выглядит это обычно вот так:

#include <iostream>
#include <optional>
#include <vector>

std::optional<std::size_t> find_index_by_id(const std::vector<int>& ids, int id);

int main() {
    std::vector<int> ids{10, 20, 30};

    auto idx = find_index_by_id(ids, 20);
    if (idx) {
        std::cout << "found at " << *idx << '\n'; // found at 1
    } else {
        std::cout << "not found\n";
    }
}

Здесь *idx означает «достань значение из optional». Но делать так можно только если idx не пустой.

Есть два удобных способа проверки:

  • if (idx) — самый короткий и популярный;
  • if (idx.has_value()) — то же самое, но читается более «словами».

Для тренировки можно написать так:

if (idx.has_value()) {
    std::cout << *idx << '\n';
}

Почему это важно? Потому что optional дисциплинирует: если вы забыли проверить, ваш код становится подозрительным даже для человека, который читает его через месяц. С -1 всё хуже: там подозрительность спрятана, и вы её не замечаете.

5. Встраиваем optional в TaskList: поиск задачи по id

Теперь давайте вернёмся к нашему проекту (список задач). Пусть у нас есть модель:

#include <string>

struct Task {
    int id = 0;
    std::string title;
    bool done = false;
};

Мы храним задачи так:

#include <vector>

std::vector<Task> tasks;

И хотим уметь отметить задачу выполненной по id. Для этого сначала надо найти индекс задачи.

Сделаем функцию:

#include <cstddef>
#include <optional>
#include <vector>

std::optional<std::size_t> find_task_index_by_id(const std::vector<Task>& tasks, int id) {
    for (std::size_t i = 0; i < tasks.size(); ++i) {
        if (tasks[i].id == id) return i;
    }
    return std::nullopt;
}

Теперь применение в «бизнес‑логике» становится очень читаемым — и главное, безопасным:

#include <iostream>
#include <vector>

void mark_done(std::vector<Task>& tasks, int id) {
    auto idx = find_task_index_by_id(tasks, id);
    if (!idx) {
        std::cout << "No task with id=" << id << '\n';
        return;
    }
    tasks[*idx].done = true;
}

Обратите внимание, как красиво здесь работает ранний return: если не нашли — сообщили и вышли, не делая лишних вложенных if.

Если сравнить это с подходом «верну -1», то тут у нас исчез целый класс ошибок: мы не сможем случайно использовать -1 как индекс, потому что у нас больше нет -1 вообще. Есть только «есть индекс / нет индекса».

6. optional vs bool + out‑параметр: что лучше для новичка и почему

До optional многие пишут функции так: «возвращаю bool (успех/неуспех), а результат кладу в параметр по ссылке». Это рабочий стиль, и он встречается в старом C++ очень часто. Но для новичка он часто выглядит как фокус с напёрстками: «где результат? почему он не в return

Пример старого стиля:

#include <cstddef>
#include <vector>

bool find_task_index_by_id_old(const std::vector<Task>& tasks, int id, std::size_t& outIndex) {
    for (std::size_t i = 0; i < tasks.size(); ++i) {
        if (tasks[i].id == id) { outIndex = i; return true; }
    }
    return false;
}

А теперь тот же смысл через optional:

#include <cstddef>
#include <optional>
#include <vector>

std::optional<std::size_t> find_task_index_by_id(const std::vector<Task>& tasks, int id) {
    for (std::size_t i = 0; i < tasks.size(); ++i) {
        if (tasks[i].id == id) return i;
    }
    return std::nullopt;
}

Почему optional часто проще именно сейчас, на вашем уровне?

Потому что с optional «результат» лежит там, где его ожидает читатель — в return. А bool‑подход хорош, когда нужно вернуть несколько значений или когда вы следуете API, где так принято. Но в нашей сегодняшней задаче «индекс либо есть, либо нет» — optional делает это прямым текстом.

Вот маленькая сравнительная табличка (да, таблицы — это не bullet list, так что мы легально):

Подход Что возвращаем Как кодируем «не найдено» Типичный риск
«магическое значение»
int
-1
забыли проверить, получили странный индекс
bool + out
bool
false
забыли проверить bool, используете мусор в out
optional
optional<size_t>
nullopt
разыменовали *opt без проверки

То есть риски остаются везде, но у optional риск обычно самый заметный и самый легко ловимый взглядом: «ты точно проверил, что оно не пустое?»

7. optional для парсинга: «число получилось / не получилось» без магии

Мы уже делали ввод, строки и конвертацию строк в числа (например, через stoi в демонстрациях). Но у конвертации есть философская проблема: иногда строка — не число, и это нормально. Это не «катастрофа вселенной», это обычная ветка логики.

Поэтому очень удобно делать парсер, который возвращает optional<int>: число либо получилось, либо нет.

Сделаем простой учебный парсер неотрицательного целого числа (без знака +/-, без пробелов, без переполнений — мы честно признаемся, что это «учебная версия»):

#include <optional>
#include <string>

std::optional<int> parse_non_negative_int(const std::string& s) {
    if (s.empty()) return std::nullopt;

    int value = 0;
    for (char c : s) {
        if (c < '0' || c > '9') return std::nullopt;
        value = value * 10 + (c - '0');
    }
    return value;
}

И теперь использование выглядит очень по‑человечески:

#include <iostream>
#include <string>

int main() {
    std::string text;
    std::getline(std::cin, text);

    auto n = parse_non_negative_int(text);
    if (!n) {
        std::cout << "Not a number\n";
        return 0;
    }
    std::cout << "n=" << *n << '\n';
}

В чём кайф именно optional?

В том, что «не число» — это не «верну -1, но вдруг -1 тоже допустим». Это просто «нет результата». Тип заставляет нас это учесть.

8. Полезные нюансы optional

value_or: когда дефолт — это часть смысла, а не костыль

Иногда по логике программы вы хотите: «если числа нет — возьму значение по умолчанию». Например, пользователь не ввёл лимит задач для вывода, значит показываем 10.

Для таких случаев у optional есть удобный метод value_or(default) — «верни значение, если оно есть, иначе верни default».

Важно понимать смысл: value_or не означает «успешно нашли». Он означает «я согласен на дефолт».

Пример:

#include <iostream>
#include <optional>

int main() {
    std::optional<int> maybeLimit = std::nullopt;

    int limit = maybeLimit.value_or(10);
    std::cout << "limit=" << limit << '\n'; // limit=10
}

В нашем TaskList это может быть полезно, например, для команды list, где пользователь может ввести «сколько показывать». Если ввёл плохо — это не повод ломать программу, можно перейти на дефолт. Но если это id для удаления задачи, то дефолт обычно опасен: удалить «по умолчанию» — звучит как начало фильма ужасов.

То есть value_or — мощная штука, но её нужно применять там, где дефолт логически безопасен.

Когда optional не нужен (и даже мешает)

Иногда, попробовав optional, хочется «опционализировать» всё вокруг: «пусть у меня даже возраст будет optional, и имя optional, и вообще жизнь optional». На практике это превращает код в болото проверок.

Если результат всегда существует, optional не нужен. Например, функция int add(int a, int b) не должна возвращать optional<int> — там нечего «не вернуть».

Если вам важна причина ошибки, optional тоже уже слабоват: он сообщает только «есть/нет». Сегодня это нормально (мы учимся базовой дисциплине), но держите в голове: «нет результата» и «почему нет» — разные уровни детализации.

И ещё один момент: optional — это не замена std::vector, не замена string, и точно не замена внимательности. Это просто удобный способ сделать контракт честным.

9. Типичные ошибки при работе с std::optional

Ошибка №1: разыменование без проверки (*opt когда opt пустой).
Самая частая проблема новичка: «я же уверен, что там есть значение». Уверенность — это хорошо, но программа не обязана разделять ваши чувства. Правильный стиль на этом этапе такой: сначала if (opt), и только внутри ветки — *opt. Если вы хотите убедить себя, что ветка невозможна, лучше перепроверьте логику поиска/парсинга, а не разыменовывайте на удачу.

Ошибка №2: возврат «магического значения» внутри optional.
Иногда встречается странный гибрид: функция возвращает optional<int>, но при ошибке возвращает -1, а при успехе — число. Это уничтожает весь смысл optional: отсутствие результата должно быть nullopt, а не «особое число в коробочке».

Ошибка №3: путаница между «нет значения» и «значение по умолчанию».
std::nullopt означает «значения нет». А value_or(0) означает «если нет — подставь 0». Ноль при этом не становится признаком ошибки. Если вы потом начинаете трактовать 0 как «не получилось», вы возвращаетесь к магическим значениям, только теперь они замаскированы под дефолты.

Ошибка №4: попытка хранить ссылку внутри optional без понимания последствий.
Новички иногда хотят optional<T&>, потому что «я же не хочу копировать». В стандартном std::optional так нельзя напрямую (ссылки — особая история). На нашем уровне проще и правильнее вернуть optional<size_t> (индекс), а уже по индексу работать с объектом в vector.

Ошибка №5: превращение кода в бесконечные проверки там, где отсутствие невозможно.
Если в вашей функции результат гарантирован (например, вы уже проверили ввод выше, или работаете с заранее корректными данными), optional добавит шум: появятся лишние if, а смысл не усилится. optional — лекарство, но если пить его «на всякий случай» каждое утро, можно вылечить читаемость насмерть.

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