1. Проєктування nullable API
Майже в будь-якому реальному коді рано чи пізно трапляється ситуація, коли «такого значення немає». Користувач увів неіснуючий id, пошук нічого не знайшов, рядок виявився порожнім, парсер не зміг прочитати число. І тут у новачка часто вмикається старий добрий режим: «поверну -1» або «поверну порожній рядок, а далі нехай розбираються».
Проблема в тому, що -1 — це не відсутність, а звичайне значення з множини int. І порожній рядок — теж значення. Коли ви змішуєте «результату немає» і «результат дорівнює нулю або порожньому рядку», код починає жити подвійним життям: зовні все виглядає нормально, а всередині накопичуються приховані домовленості.
Сьогодні ми розберемо три підходи, якими зазвичай виражають стан «може бути відсутнім», і навчимося вибирати підхід за змістом, а не за настроєм.
Обʼєкт відсутній чи немає результату?
Перш ніж вибирати T*, std::optional або «магічне значення», варто поставити собі правильне запитання. Воно звучить так: «чи може не існувати обʼєкт, до якого я хочу дати доступ, чи в мене може не зʼявитися значення-результат?»
Якщо ви шукаєте елемент у колекції й хочете дати доступ до знайденого обʼєкта, це схоже на ситуацію «обʼєкт може бути відсутнім». Якщо нічого не знайшли, то й обʼєкта наче немає.
Якщо ж ви намагаєтеся розпарсити число з рядка, то не шукаєте «обʼєкт», а хочете отримати значення, яке може й не вийти. Тут набагато природніша модель: «значення-результат може бути відсутнім».
Ця різниця здається філософією лише до першого великого проєкту, де половина багів зводиться до одного: «ми переплутали значення з відсутністю».
2. Вказівник (T* / const T*) + nullptr
Вказівники — перший природний спосіб виразити стан «може не бути». Вказівник у C++ може зберігати адресу обʼєкта, а може містити nullptr, що буквально читається як «ні на що не вказує». І це дуже добре відповідає контракту: обʼєкт може бути відсутнім.
Є важливий стилістичний момент: вказівник як результат майже завжди сприймається так, ніби ви повертаєте доступ до вже наявного обʼєкта, а не «створюєте нове значення». Тобто T* — це радше «доступ із можливістю бути порожнім».
Невеликий приклад: шукаємо книгу за id і повертаємо вказівник
Продовжимо наш навчальний консольний проєкт. Нехай це буде мінікаталог «LibraryLite», де ми зберігаємо книги в std::vector. Книга — звичайна структура.
#include <string>
struct Book {
int id{};
std::string title;
};
Тепер функція пошуку:
#include <vector>
Book* FindBookById(std::vector<Book>& books, int id) {
for (Book& b : books) {
if (b.id == id) return &b;
}
return nullptr;
}
Сигнатура тут говорить сама за себе: «я можу повернути адресу книги, а можу повернути nullptr, якщо книги немає».
Використання:
#include <iostream>
#include <vector>
int main() {
std::vector<Book> books{{1, "Dune"}, {2, "1984"}};
if (Book* b = FindBookById(books, 2)) {
std::cout << b->title << "\n"; // 1984
} else {
std::cout << "Не знайдено\n";
}
}
Зверніть увагу: перевірка if (b) майже спонукає вас обробити випадок відсутності. Майже — бо ніщо не заважає викликати функцію, а потім одразу звернутися через -> і влаштувати собі пригоду.
const T* як «знайшов, але чіпати не можна»
Якщо функція не повинна змінювати знайдений обʼєкт, це чесно виражається через const:
#include <vector>
const Book* FindBookById(const std::vector<Book>& books, int id) {
for (const Book& b : books) {
if (b.id == id) return &b;
}
return nullptr;
}
Це той самий «контракт доступу»: «можу повернути доступ, але тільки для читання».
Коли вказівник — найкращий вибір
Вказівник — чудовий вибір, коли ви повертаєте доступ до обʼєкта, який уже десь існує, а «порожнеча» означає «обʼєкта немає». Саме тому вказівники так часто використовують у функціях «знайти елемент», «знайти налаштування», «знайти сутність за ключем».
3. std::optional<T>: значення може бути відсутнім
std::optional<T> — це тип-обгортка, яка зберігає або значення T, або стан «значення немає». Це не магія і не винятки, а просто чесна модель «є / немає результату».
Концептуально optional дуже зручний, бо він не вдає, що -1 — це відсутність. Він змушує вас у коді явно побачити розвилку: або значення є, або його немає. У стандартній бібліотеці навіть є окремий std::nullopt для цього «порожнього» стану.
Невеликий приклад: парсимо int з рядка без винятків і повертаємо optional
Припустімо, користувач вводить id книги текстом. Ми хочемо надійно розібрати цей рядок як число. За змістом ми не «шукаємо обʼєкт», а намагаємося отримати значення (int), і це може не вдатися.
#include <charconv>
#include <optional>
#include <string_view>
std::optional<int> ParseInt(std::string_view s) {
int value = 0;
auto [ptr, ec] = std::from_chars(s.data(), s.data() + s.size(), value);
if (ec != std::errc{}) return std::nullopt;
if (ptr != s.data() + s.size()) return std::nullopt;
return value;
}
Тут ми чесно повертаємо std::nullopt, якщо парсинг не вдався. До речі, std::from_chars хороший тим, що не кидає винятків, а повідомляє про помилку через код. Це ідеально поєднується з optional.
Використання:
#include <iostream>
int main() {
if (auto id = ParseInt("42")) {
std::cout << "id=" << *id << "\n"; // id=42
} else {
std::cout << "Некоректний id\n";
}
}
Зверніть увагу на стиль: if (auto id = ParseInt(...)) виглядає як «перевірити, чи є значення». Це майже як if (p) для вказівників, але семантика тут інша: немає ні адреси, ні часу життя — є лише «значення як результат».
Невеликий приклад: повернути індекс знайденої книги як optional<size_t>
Іноді вам потрібен не обʼєкт, а позиція. Позиція — це знов-таки значення, а не обʼєкт, і «немає позиції» зручно виражати через optional.
#include <cstddef>
#include <optional>
#include <vector>
std::optional<std::size_t> FindBookIndexById(const std::vector<Book>& books, int id) {
for (std::size_t i = 0; i < books.size(); ++i) {
if (books[i].id == id) return i;
}
return std::nullopt;
}
Використання:
#include <iostream>
#include <vector>
int main() {
std::vector<Book> books{{1, "Dune"}, {2, "1984"}};
if (auto pos = FindBookIndexById(books, 99)) {
std::cout << books[*pos].title << "\n";
} else {
std::cout << "Такої книги немає\n"; // Такої книги немає
}
}
Коли optional — найкращий вибір
optional ідеальний, коли «може бути відсутнім» стосується результату обчислення, а не обʼєкта. Парсинг, обчислення, пошук індексу, обчислення середнього, отримання налаштувань за даними — усе це часто краще виражати через optional, а не через вказівник.
І ще один важливий момент: optional добре читається як сигнал: «автор функції очікує, що ви це обробите». Це така маленька дисципліна, зафіксована в типах.
4. Sentinel: «спеціальне значення того самого типу»
Sentinel — це підхід на кшталт: «ми домовилися, що певне значення означає “немає результату”». Класичний приклад у C++ — std::string::npos. Метод find повертає позицію (size_t), а якщо підрядок не знайдено — повертає npos.
Чому це взагалі існує? Тому що історично деякі API влаштовані саме так: вони повертають звичайний тип, а в нього «вбудоване» особливе значення. Це дешево, швидко й не потребує додаткової обгортки.
Але є й ціна: відсутність результату не видно в типі, її сховано в домовленості. І якщо ви забудете про перевірку, програма не впаде одразу — що було б навіть милосердніше, — а почне робити дивні речі.
Приклад: використовуємо find і npos правильно
#include <iostream>
#include <string>
int main() {
const std::string title = "The Lord of the Rings";
const std::size_t pos = title.find("Lord");
if (pos == std::string::npos) {
std::cout << "Не знайдено\n";
} else {
std::cout << "Знайдено на позиції " << pos << "\n"; // Знайдено на позиції 4
}
}
Тут усе коректно: ми перевірили sentinel і лише потім використовуємо позицію.
Приклад: «обгортаємо» sentinel у зрозумілий bool-API
Якщо ви не хочете постійно памʼятати про npos, можна зробити функцію, яка повертає bool:
#include <string>
bool Contains(const std::string& text, const std::string& what) {
return text.find(what) != std::string::npos;
}
Тепер коду, що викликає цю функцію, взагалі не потрібно знати про sentinel — він бачить чесний bool.
Коли sentinel — нормальний вибір
Sentinel цілком доречний, коли:
- У типі є офіційно прийнята константа (як std::string::npos), а не «-1 навмання».
- Це значення неможливо переплутати зі звичайним результатом.
- Ви можете тримати перевірку «поруч» із використанням або обгорнути її у функцію, щоб не загубити в майбутньому.
5. Критерії вибору: optional, вказівник чи sentinel
Дуже хочеться сформулювати універсальне правило в один рядок, але життя не любить однорядкових правил. Тож давайте складемо практичну таблицю: що виражає кожен підхід, яких перевірок ми очікуємо в коді й де найчастіше стріляють собі в ногу.
| Підхід | Що означає «порожньо» | Що ви реально повертаєте | Типова перевірка | Де найзручніше |
|---|---|---|---|---|
|
|
доступ до обʼєкта (адреса) | |
«знайти обʼєкт», «обʼєкт може бути відсутнім» |
|
|
значення-результат | |
«отримати значення», «результат може бути відсутнім» |
| sentinel (npos, -1, тощо) | «особливе значення» | звичайний тип (індекс / код) | |
історичні API, позиції в рядках, випадки з усталеною константою |
Якщо ви повертаєте «доступ до обʼєкта», думайте в бік вказівника. Якщо повертаєте «значення як результат обчислення» — думайте в бік optional. Якщо ж повертаєте звичайний тип і для нього є загальноприйнятий sentinel, використовуйте його, але робіть перевірку якомога ближче до місця використання або ховайте її всередині невеликої функції.
Щоб закріпити, ось маленька блок-схема ухвалення рішення:
flowchart TD
A["Потрібно виразити: «може бути відсутнім»"] --> B{"Повертаємо доступ до обʼєкта?"}
B -->|Так| C["Повертаємо T* / const T* і nullptr"]
B -->|Ні| D{"Повертаємо значення-результат?"}
D -->|Так| E["Повертаємо std::optional<T>"]
D -->|Ні| F{"Є офіційний sentinel (npos тощо)?"}
F -->|Так| G["Повертаємо sentinel і перевіряємо поруч"]
F -->|Ні| H["Найімовірніше, потрібен optional або варто переглянути API"]
Діаграма не замінює мислення, але економить кілька нервових клітин — а нервові клітини, як відомо, у Git не закомітите.
6. Практичний міні-рефакторинг «LibraryLite»: робимо API чесним
Зараз ми поєднаємо три ідеї в один невеликий шматочок застосунку, щоб побачити різницю не на абстрактних прикладах, а так, ніби ми справді пишемо програму.
Уявімо, що в main() ми обробляємо команду користувача «find <id>» і хочемо: розпарсити id, знайти книгу, вивести результат.
Парсинг: optional<int>
#include <iostream>
#include <optional>
#include <string>
std::optional<int> ParseInt(std::string_view s);
int main() {
const std::string idText = "2";
const auto id = ParseInt(idText);
if (!id) {
std::cout << "Некоректний id\n";
return 0;
}
std::cout << "Розібраний id=" << *id << "\n"; // Розібраний id=2
}
Ми не повертаємо «-1», бо -1 може бути валідним id, хай і дивним. Ми повертаємо optional, бо це результат обчислення.
Пошук обʼєкта: const Book*
#include <iostream>
#include <vector>
const Book* FindBookById(const std::vector<Book>& books, int id);
int main() {
const std::vector<Book> books{{1, "Dune"}, {2, "1984"}};
if (const Book* b = FindBookById(books, 2)) {
std::cout << b->title << "\n"; // 1984
} else {
std::cout << "Не знайдено\n";
}
}
Ми повертаємо вказівник, бо «результат» — це не нове значення, а доступ до вже наявної книги.
Пошук «усередині рядка»: sentinel (npos)
Припустімо, ми хочемо підсвітити книги, у назві яких є слово «Ring»:
#include <iostream>
#include <string>
bool Contains(const std::string& text, const std::string& what);
int main() {
const std::string title = "The Lord of the Rings";
if (Contains(title, "Ring")) {
std::cout << "Збіг знайдено\n"; // Збіг знайдено
}
}
Усередині Contains сховано npos, і коду, що викликає цю функцію, не обовʼязково памʼятати, що у find є sentinel. Це приклад «трішки API-дизайну», який робить код у main() простішим.
7. Типові помилки
Помилка № 1: повертати «магічне число» (-1) замість std::optional.
Спочатку це здається зручним: «ну в мене індекс, нехай -1 означає “не знайдено”». Потім зʼясовується, що індекс у вас size_t, і -1 перетворюється на величезне число. Або що -1 — це валідне значення в іншому контексті. std::optional робить відсутність явною і прибирає з коду приховані домовленості.
Помилка № 2: використовувати T* як універсальне nullable-повернення навіть для значень.
Іноді новачки починають повертати вказівник узагалі на все: «у мене може не бути результату, поверну int*». Це майже завжди поганий знак: ви змішали «значення-результат» і «обʼєкт у памʼяті», а ще породили запитання «хто володіє цим int* і скільки він живе». Для значень набагато природніше використовувати std::optional<int>.
Помилка № 3: sentinel перевіряють «десь потім» або взагалі не перевіряють.
З npos і подібними значеннями головна небезпека не в самому підході, а в забудькуватості. Сьогодні ви памʼятали, що треба порівняти з npos, а завтра зробили рефакторинг і випадково використали позицію так, ніби вона завжди коректна. Якщо використовуєте sentinel, намагайтеся перевіряти його одразу або ховайте цю логіку в маленьку функцію, яка повертає bool або optional.
Помилка № 4: повертати optional<T>, але одразу перетворювати його назад на sentinel.
Іноді трапляється дивна гібридна логіка: функція повертає optional<size_t>, а код, що її викликає, робить щось на кшталт «поверни значення або -1». Так ви просто переносите проблему на наступний рівень і знову ховаєте відсутність в «особливому значенні». Якщо вже вибрали optional, нехай відсутність і далі живе в типі, а гілка обробки буде явною.
Помилка № 5: ігнорувати сенс «обʼєкт vs результат» і вибирати тип за звичкою.
Найпідступніша помилка — вибирати T* просто тому, що «я вмію nullptr», або вибирати optional лише тому, що «це модно». На практиці найкращий критерій — семантика: вказівник говорить про доступ до обʼєкта, optional — про значення-результат, а sentinel — про історичні або низькорівневі інтерфейси та випадки, де він офіційно закріплений, як-от npos.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ