JavaRush /Курсы /C++ SELF /Nullable API: pointer, std::optional и sentinel-значения

Nullable API: pointer, std::optional и sentinel-значения

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

1. Nullable-дизайн

Почти любой «реальный» код рано или поздно упирается в ситуацию «а такого значения нет». Пользователь ввёл несуществующий 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 << "Not found\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 << "bad 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 << "No such book\n"; // No such book
    }
}

Когда 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 << "Not found\n";
    } else {
        std::cout << "Found at " << pos << "\n"; // Found at 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 vs pointer vs sentinel

Очень хочется сделать универсальное правило в одну строку, но жизнь не любит однострочные правила. Поэтому давайте сделаем практичную таблицу: что выражает каждый подход, какие проверки ждём в коде, и где чаще всего стреляют себе в ногу.

Подход Что означает “пусто” Что вы реально возвращаете Типичная проверка Где удобнее всего
T* / const T*
nullptr
доступ к объекту (адрес)
if (p)
«найти объект», «объект может отсутствовать»
std::optional<T>
std::nullopt
значение-результат
if (opt)
«получить значение», «результат может отсутствовать»
sentinel (npos, -1, etc.) «особое значение» обычный тип (индекс/код)
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 << "Bad id\n";
        return 0;
    }
    std::cout << "Parsed id=" << *id << "\n"; // Parsed 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 << "Not found\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 << "Matched\n"; // Matched
    }
}

Внутри 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 про исторические/низкоуровневые интерфейсы и случаи, где sentinel официально закреплён (как npos).

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