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
Очень хочется сделать универсальное правило в одну строку, но жизнь не любит однострочные правила. Поэтому давайте сделаем практичную таблицу: что выражает каждый подход, какие проверки ждём в коде, и где чаще всего стреляют себе в ногу.
| Подход | Что означает “пусто” | Что вы реально возвращаете | Типичная проверка | Где удобнее всего |
|---|---|---|---|---|
|
|
доступ к объекту (адрес) | |
«найти объект», «объект может отсутствовать» |
|
|
значение-результат | |
«получить значение», «результат может отсутствовать» |
| sentinel (npos, -1, etc.) | «особое значение» | обычный тип (индекс/код) | |
исторические 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).
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ