JavaRush /Курси /C++ SELF /Nullable API: вказівники, std::optional і sentinel-значен...

Nullable API: вказівники, std::optional і sentinel-значення

C++ SELF
Рівень 41 , Лекція 0
Відкрита

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

Дуже хочеться сформулювати універсальне правило в один рядок, але життя не любить однорядкових правил. Тож давайте складемо практичну таблицю: що виражає кожен підхід, яких перевірок ми очікуємо в коді й де найчастіше стріляють собі в ногу.

Підхід Що означає «порожньо» Що ви реально повертаєте Типова перевірка Де найзручніше
T* / const T*
nullptr
доступ до обʼєкта (адреса)
if (p)
«знайти обʼєкт», «обʼєкт може бути відсутнім»
std::optional<T>
std::nullopt
значення-результат
if (opt)
«отримати значення», «результат може бути відсутнім»
sentinel (npos, -1, тощо) «особливе значення» звичайний тип (індекс / код)
if (x == sentinel)
історичні 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.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ