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. Це не число і не «поганий індекс». Це окремий стан: «значення немає».
Якщо ви любите читати код як текст, то ця функція буквально звучить так:
«Поверни можливий індекс; якщо знайшов — поверни його; інакше — поверни відсутність».
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 << "знайдено за індексом " << *idx << '\n'; // знайдено за індексом 1
} else {
std::cout << "не знайдено\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 << "Немає завдання з id=" << id << '\n';
return;
}
tasks[*idx].done = true;
}
Зверніть увагу, як добре тут працює ранній return: якщо не знайшли — повідомили й вийшли, не створюючи зайвої вкладеності з if.
Якщо порівняти це з підходом «поверну -1», то тут зникає цілий клас помилок: ми не зможемо випадково використати -1 як індекс, бо в нас більше немає -1 взагалі. Є лише «є індекс / немає індексу».
6. optional vs bool + вихідний параметр: що краще для початківця і чому
Без 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 говорить про це прямо.
Ось невелика порівняльна таблиця:
| Підхід | Що повертаємо | Як кодуємо «не знайдено» | Типовий ризик |
|---|---|---|---|
| «магічне значення» | |
|
забули перевірити — отримали дивний індекс |
| bool + вихідний параметр | |
|
забули перевірити bool і використовуєте неініціалізоване значення в out |
| optional | |
|
розіменували *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 << "Не число\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 << '\n'; // ліміт=10
}
У нашому TaskList це може бути корисно, наприклад, для команди list, де користувач може ввести «скільки показувати». Якщо він увів некоректне значення, це не привід ламати програму: можна перейти до значення за замовчуванням. Але якщо це id для видалення завдання, то значення за замовчуванням зазвичай небезпечне.
Тобто value_or — корисний інструмент, але застосовувати його потрібно там, де значення за замовчуванням логічно безпечне.
Коли 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 — це ліки, але якщо вживати їх на всяк випадок щоранку, можна серйозно зашкодити читабельності.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ