Хабрахабр

[Перевод] Использование std::optional в С++17

Давайте возьмём пару от двух типов <YourType, bool> — что вы можете сделать с композицией подобного рода?

Это обёртка для вашего типа и флаг показывает, инициализировано ваше значение или нет. В этой статье я расскажу вам про std::optional — новый вспомогательный тип, добавленный в C++17. Давайте посмотрим, где это может быть полезно.

Вступление

Как было сказано ранее, флаг используется для обозначения того, доступно значение или нет. Добавлением логических флагов к другим типам вы можете достичь то, что называется "Nullable типы". Такая обёртка выразительно представляет объект, который может быть пустым (не через комментарии :).

Вы даже можете использовать std::unique_ptr<Type> и трактовать пустой указатель как неинициализированный объект — это сработает, но вместе с этим вы должны будете смириться с затратами на выделения памяти для объекта там, где в этом нет необходимости. Вы можете достигнуть пустого значения объекта с помощью использования уникальных идентификаторов (-1, бесконечность, nullptr), но это не так точно выражает мысль, как отдельный тип-обёртка.

Большинство других языков имеют что-то похожее: например std::option в Rust, Optional<T> в Java, Data. Опциональные типы — это то, что пришло из мира функционального программирования, принеся с собой безопасность типов и выразительность. Maybe в Haskell.

Начиная с C++17, вы можете просто написать #include <optional> для использования этого типа. std::optional был добавлен в C++17 из boost::optional, где был доступен многие годы.

Более того, для std::optional не нужно отдельно выделять память. Этот тип является типом-значением (value-type) (таким образом, вы можете копировать его).

std::optional является частью словарных типов C++ на ряду с std::any, std::variant и std::string_view.

Использование

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

  • Если вы хотите красиво представить nullable-тип.
    • Это лучше, чем использовать уникальные значения (например, -1, nullptr, NO_VALUE или что-то подобное).
    • Например, среднее имя пользователя является опциональным. Вы можете предположить, что пустой строки будет для этого достаточно, но может быть важно само понимание того, что пользователь что-то ввёл. С помощью std::optional<std::string> вы сможете получить больше информации.
  • Вернуть результат каких-либо вычислений, которые не смогли дать конечный результат, но это не является ошибкой.
    • Например, поиск элемента в словаре: если нет элемента, соответствующего заданному ключу, то это не ошибка, но нам стоит обработать эту ситуацию.
  • Для получения ресурсов с отложенной загрузкой.
    • Например, если у какого-либо ресурса нет конструктора по умолчанию и конструирование объекта занимает довольно длительное время. Тогда вы можете объявить std::optional<Resource>, и передать этот объект дальше системе, а выполнять загрузку уже позднее по необходимости.
  • Чтобы передать опциональные параметры в функции.

Из документации boost: Мне нравится определение опционального типа из boost, которое подводит итог по тем ситуациям, когда нам следует его использовать.

е. Шаблонный класс std::optional управляет опциональным значением, т. В отличии от других подходов, таких как std::pair<T, bool>, опциональный тип данных хорошо управляется с тяжёлыми для конструирования объектами и является более читабельным, поскольку явно выражает намерения разработчика. значением, которое может быть представлено, а может и не быть.
Обычным примером использования опционального типа данных является возвращаемое значение функции, которая может вернуть ошибочный результат в процессе выполнения.

Он лучше всего подходит для тех случаев, когда отсутствие значения является нормальным поведением программы. Хотя иногда тяжело решить, стоит ли использовать опциональный тип, вы точно не должны его использовать для обработки ошибок.

Простой пример

Ниже вы можете увидеть простой пример того, что можно сделать с использованием опционального типа:

std::optional<std::string> UI::FindUserNick()
; return std::nullopt; // то же самое, как если вернуть просто { };
} // Использование:
std::optional<std::string> UserNick = UI->FindUserNick();
if (UserNick) Show(*UserNick);

Если имя пользователя доступно, она вернёт строку. В коде выше мы объявили функцию, которая возвращает опциональную строку. Позже мы сможем присвоить это значение опциональному типу и проверить его (у std::optional есть оператор приведения к типу bool), содержит оно реальное значение или нет. Если нет, то вернёт std::nullopt. Тип std::optional также перегружает operator*() для более простого доступа к содержащемуся значению.

В следующих параграфах вы сможете увидеть как создавать std::optional, работать с ним, передавать и даже его производительность, которую вам наверняка интересно увидеть.

Серия

Вот список других тем, про которые я рассказываю: Эта статья является частью моей серии про библиотечные утилиты C++17.

  • Рефакторинг с использованием C++17 std::optional.
  • Использование std::optional (этот пост).
  • Обработка ошибок при использовании std::optional (англ. язык).
  • Использование std::variant.
  • Использование std::any.
  • In place конструкторы для std::optional, std::variant и std::any.
  • Использование std::string_view.
  • Утилиты C++17 для поиска и конвертации строк.
  • Работа с std::filesystem.
  • Что-то ещё? 🙂

Ресурсы по C++17 STL:

OK, теперь давайте поработаем с std::optional.

Создание std::optional

Есть несколько вариантов создания std::optional:

// пустой:
std::optional<int> oEmpty;
std::optional<float> oFloat = std::nullopt; // прямой:
std::optional<int> oInt(10);
std::optional oIntDeduced(10); // deduction guides // make_optional
auto oDouble = std::make_optional(3.0);
auto oComplex = make_optional<std::complex<double>>(3.0, 4.0); // in_place
std::optional<std::complex<double>> o7{std::in_place, 3.0, 4.0}; // вызвать vector с прямой инициализацией {1, 2, 3}
std::optional<std::vector<int>> oVec(std::in_place, {1, 2, 3}); // копирование/присваивание:
auto oIntCopy = oInt;

Создание объекта очень простое как для примитивных типов, так и для более сложных. Как вы можете видеть в примере выше, вам доступна удивительная гибкость создания объекта.

Создание через in place особенно интересно и тег std::in_place также поддерживается в других типах, таких как std::any и std::variant.

Например, вы можете написать:

// https://godbolt.org/g/FPBSak
struct Point
{ Point(int a, int b) : x(a), y(b) { } int x; int y;
}; std::optional<Point> opt{std::in_place, 0, 1};
// vs
std::optional<Point> opt{{0, 1}};

Это экономит создание временного объекта Point.

Я расскажу про std::in_place позже, не переключайте канал и оставайтесь с нами.

Возврат std::optional из функции

Если вы возвращаете опциональное значение из функции, то очень удобно вернуть или std::nullopt, или результирующее значение:

std::optional<std::string> TryParse(Input input)
{ if (input.valid()) return input.asString(); return std::nullopt;
}

Если значение недоступно, то функция просто вернёт std::nullopt. В примере выше вы можете видеть, что я возвращаю std::string, полученную из input.asString() и оборачиваю её в std::optional.

Таким образом, мы можем переписать код выше следующим образом: Конечно, вы также можете просто объявить пустой опциональный объект в начале вышей функции и присвоить ему посчитанное значение, если оно корректно.

std::optional<std::string> TryParse(Input input)
{ std::optional<std::string> oOut; // empty if (input.valid()) oOut = input.asString(); return oOut;
}

Я предпочитаю короткие функции, поэтому мой выбор — версия №1 (с несколькими return). Какая версия лучше, зависит от контекста.

Получение значения

Возможно, самая важная операция для опционального типа (помимо его создания) — это то, как вы можете получить сохранённое значение.

Для этого есть несколько вариантов:

  • Использовать operator*() и operator->() так же, как в итераторах. Если объект не содержит реального значения, то поведение не определено!
  • value() — возвращает значение или бросает исключение std::bad_optional_access.
  • value_or(default) — возвращает значение, если доступно, или же возвращает default.

Чтобы проверить, есть ли реальное значение в объекте, вы можете использовать метод has_value() или просто проверить объект с помощью if (optional) {...}, так как у опционального типа перегружен оператор приведения к bool.

Например:

// с помощью operator*()
std::optional<int> oint = 10;
std::cout<< "oint " << *opt1 << '\n'; // с помощью value()
std::optional<std::string> ostr("hello");
try
{ std::cout << "ostr " << ostr.value() << '\n'; }
catch (const std::bad_optional_access& e)
{ std::cout << e.what() << "\n";
} // с помощью value_or()
std::optional<double> odouble; // пустой
std::cout<< "odouble " << odouble.value_or(10.0) << '\n';

Таким образом, наиболее удобно, возможно, будет проверить, есть ли реальное значение в опциональном объекте, и затем использовать его:

// функция вычисления строки:
std::optional<std::string> maybe_create_hello(); // ... if (auto ostr = maybe_create_hello(); ostr) std::cout << "ostr " << *ostr << '\n'; else std::cout << "ostr is null\n";

Возможности std::optional

Давайте посмотрим, какие ещё есть возможности у опционального типа:

Изменение значения

Если вы присваиваете (или обнуляете) объекту std::nullopt, то у реального объекта, который хранится в опциональном, будет вызван деструктор. Если у вас уже существует опциональный объект, вы можете легго поменять его значение с помощью методов emplace, reset, swap и assign.

Вот небольшой пример:

#include <optional>
#include <iostream>
#include <string> class UserName
{
public: explicit UserName(const std::string& str) : mName(str) { std::cout << "UserName::UserName(\'"; std::cout << mName << "\')\n"; } ~UserName() { std::cout << "UserName::~UserName(\'"; std::cout << mName << "\')\n"; } private: std::string mName;
}; int main()
{ std::optional<UserName> oEmpty; // emplace: oEmpty.emplace("Steve"); // Вызовется ~Steve и создастся Mark: oEmpty.emplace("Mark"); // Обнулить объект oEmpty.reset(); // вызовется ~Mark // То же самое: //oEmpty = std::nullopt; // Присвоить новое значение: oEmpty.emplace("Fred"); oEmpty = UserName("Joe"); }

Этот код доступен здесь: @Coliru.

Сравнения

См. std::optional позволяет вам сравнивать содержащиеся в нём объекты почти "нормально", но с небольшими исключениями, когда операнды являются std::nullopt. ниже:

#include <optional>
#include <iostream> int main()
{ std::optional<int> oEmpty; std::optional<int> oTwo(2); std::optional<int> oTen(10); std::cout << std::boolalpha; std::cout << (oTen > oTwo) << "\n"; std::cout << (oTen < oTwo) << "\n"; std::cout << (oEmpty < oTwo) << "\n"; std::cout << (oEmpty == std::nullopt) << "\n"; std::cout << (oTen == 10) << "\n";
}

При выполнении кода выше, будет выведено:

true // (oTen > oTwo)
false // (oTen < oTwo)
true // (oEmpty < oTwo)
true // (oEmpty == std::nullopt)
true // (oTen == 10)

Этот код доступен здесь: @Coliru.

Примеры с std::optional

Ниже вы найдёте два примера, где std::optional подходит идеально.

Имя пользователя с необязательным никнеймом и возрастом

#include <optional>
#include <iostream> class UserRecord
{
public: UserRecord (const std::string& name, std::optional<std::string> nick, std::optional<int> age) : mName{name}, mNick{nick}, mAge{age} { } friend std::ostream& operator << (std::ostream& stream, const UserRecord& user); private: std::string mName; std::optional<std::string> mNick; std::optional<int> mAge; }; std::ostream& operator << (std::ostream& os, const UserRecord& user) { os << user.mName << ' '; if (user.mNick) { os << *user.mNick << ' '; } if (user.mAge) os << "age of " << *user.mAge; return os;
} int main()
{ UserRecord tim { "Tim", "SuperTim", 16 }; UserRecord nano { "Nathan", std::nullopt, std::nullopt }; std::cout << tim << "\n"; std::cout << nano << "\n";
}

Этот код доступен здесь: @Coliru.

Парсинг целых чисел из командной строки

#include <optional>
#include <iostream>
#include <string> std::optional<int> ParseInt(char*arg)
{ try { return { std::stoi(std::string(arg)) }; } catch (...) { std::cout << "cannot convert \'" << arg << "\' to int!\n"; } return { };
} int main(int argc, char* argv[])
{ if (argc >= 3) { auto oFirst = ParseInt(argv[1]); auto oSecond = ParseInt(argv[2]); if (oFirst && oSecond) { std::cout << "sum of " << *oFirst << " and " << *oSecond; std::cout << " is " << *oFirst + *oSecond << "\n"; } }
}

Этот код доступен здесь: @Coliru.

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

Другие примеры

  • Представление других необязательных записей для ваших типов. Как в примере с аккаунтом пользователя. Лучше использовать std::optional<Key>, чем оставлять коментарии вроде: // если ключ равен 0xDEADBEEF, то он пустой или что-то в этом роде.
  • Возвращаемые значения дял функций поиска (предполагая то, что вас не заботят возникающие при этом ошибки, например: сброс соединения, ошибки БД и т. д.).

Производительность и анализ использования памяти

Как минимум, одним дополнительным байтом. Когда вы используете std::optional, вы платите за это увеличенным использованием памяти.

Если подходить абстрактно, то ваша версия STL может реализовывать опциональный тип данных как:

template <typename T>
class optional
{ bool _initialized; std::aligned_storage_t<sizeof(T), alignof(T)> _storage; public: // Методы
};

Это означает, что он увеличит размер вашего типа в соответствии с правилами выравнивания. В кратце, std::optional просто оборачивает ваш тип, подготавливает место для него и добавляет один логический параметр.

Поэтому код выше просто демонстрирует пример, а не реальную реализацию. Есть один коментарий для этой конструкции: "Ни одна стандартная библиотека не сможет реализовать std::optional так (она должна использовать union из-за constexpr)".

Правила выравнивания важны, как говорит стандарт:

Шаблонный класс optional [optional.optional]:
Содержащееся значение должно располагаться в регионе памяти, соответственно выровненному для типа T.

Например:

// sizeof(double) = 8
// sizeof(int) = 4
std::optional<double> od; // sizeof = 16 bytes
std::optional<int> oi; // sizeof = 8 bytes

Таким образом, размер std::optional<T> больше, чем sizeof(T) + 1. В то время как bool обычно занимает один байт, опциональный тип занных вынужден подчиняться правилам выравнивания.

Например, если у вас есть такой тип:

struct Range
{ std::optional<double> mMin; std::optional<double> mMax;
};

То он займёт больше места, чем если бы вы использовали свой тип вместо std::optional:

struct Range
{ bool mMinAvailable; bool mMaxAvailable; double mMin; double mMax;
};

Во втором случае всего лишь 24. В первом случае размер структуры равен 32 байтам!

Тестовый пример на Compiler Explorer.

По ссылке великолепное объяснение насчёт производительности и использованию памяти, взятое из документации boost: вопросы производительности.

И в статье "Эффективные опциональные значения" автор рассуждает, как написать обёртку для опционального типа, которая может быть немного быстрее.

Тогда бы никакого дополнительного пространства не было бы необходимо. Интересно, есть ли шанс использовать хоть какую-то магию компилятора и повторно использовать некоторое пространство, чтобы поместить этот дополнительный флаг "инициализации объекта” внутри опционального типа.

Миграция с boost::optional

Миграция с одной версии на другую должна быть простой, но, конечно же, есть небольшие отличия. std::optional был адаптирован напрямую из boost::optional, так что они достаточно похожу.

В документе N3793 — Предложение добавить служебный класс дял представления опциональных объектов (редакция 4) от 03.10.2013 я нашёл следующую таблицу (и я попытался привести её в соответствии с текущим состоянием):

Аспект

std::optional

boost::optional (версия 1.67.0)

Семантика перемещения (move semantics)

Да

Нет Да в текущей версии

noexcept

Да

Нет Да в текущей версии

Поддержка хэшей

Да

Нет

Доступ к значениу с выбрасыванием исключения

Да

Да

Литеральные типы (могут быть использованы в constrexpr)

Да

Нет

Конструкция in place

emplace, тег std::in_place | emplace(), теги in_place_init_if_t, in_place_init_t, утилита in_place_factor

Тег отсутствия значения

std::nullopt | none

Опциональные ссылки

Нет

Да

Конвертация из std::optional<T> в std::optional<U>

Да

Да

Явное преобразование к указателю (get_ptr)

Нет

Да

Гайдлайны для выведения типов

Да

Нет

Особенный случай: std::optional<bool> и std::optional<T*>

В то время как вы можете использовать std::optional для любого типа, которого захотите, вам надо проявить особое внимание при использовании опционального типа с логическим типом и указателями.

С этой конструкцией вы имеете логический тип с тремя состояниями. std::optional<bool> ob — о чём это говорит? Поэтому, если он вам и правда нужен, возможно лучше использовать настоящий троичный тип — std::tribool.

Более того, использование такого типа может сбивать с толку, потому что ob преобразуется в bool если в нём внутри есть значение и *ob возвращает хранимое значение (если оно доступно).

Похожая ситуация может проявиться с указателями:

// Не используйте так! Это только пример!
std::optional<int*> opi { new int(10) };
if (opi && *opi)
{ std::cout << **opi << std::endl; delete *opi;
}
if (opi) std::cout << "opi is still not empty!";

Указатель на int на самом деле является nullable типом, поэтому оборачивание в опциональный тип только усложнит его использование.

Итог

Да, это было очень много текста про опциональный тип, но это не всё. Фух!

Я считаю, что у нас есть много случаев, когда опциональный тип подходит намного лучше, чем использование некоторых предопределённых значений для представления nullable типов. Тем не менее, мы рассмотрели основное использование, создание и оперирование эти удобным типом.

Я бы хотел напомнить следующие вещи про опциональный тип:

  • std::optional является обёрткой для того, чтобы выразить nullable тип.
  • std::optional не использует динамическое выделение памяти.
  • std::optional может содержать значение или быть пустым.
    • Используйте operator*(), operator->(), value(), value_or() для получения реального значения.
  • std::optional неявно приводится к bool, таким образом вы можете легко проверить, содержится ли в нём какое-либо значение, или нет.
Показать больше

Похожие публикации

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»