|
|||||||
Использование типа std::optional в С++17
Время создания: 16.01.2024 12:57
Автор: Dmitrii E. AKA Neon Mercury
Текстовые метки: c++, std, optional, in_pace, nullopt, value, value_or, has_value
Раздел: Компьютер - Программирование - Язык C++ (Си++)
Запись: xintrea/mytetra_syncro/master/base/1705399069o5u43tuupc/text.html на raw.github.com
|
|||||||
|
|||||||
Давайте возьмём пару от двух типов <YourType, bool> — что вы можете сделать с композицией подобного рода? В этой статье я расскажу вам про std::optional — новый вспомогательный тип, добавленный в C++17. Это обёртка для вашего типа и флаг показывает, инициализировано ваше значение или нет. Давайте посмотрим, где это может быть полезно. Добавлением логических флагов к другим типам вы можете достичь то, что называется "Nullable типы". Как было сказано ранее, флаг используется для обозначения того, доступно значение или нет. Такая обёртка выразительно представляет объект, который может быть пустым (не через комментарии :). Вы можете достигнуть пустого значения объекта с помощью использования уникальных идентификаторов (-1, бесконечность, nullptr), но это не так точно выражает мысль, как отдельный тип-обёртка. Вы даже можете использовать std::unique_ptr<Type> и трактовать пустой указатель как неинициализированный объект — это сработает, но вместе с этим вы должны будете смириться с затратами на выделения памяти для объекта там, где в этом нет необходимости. Опциональные типы — это то, что пришло из мира функционального программирования, принеся с собой безопасность типов и выразительность. Большинство других языков имеют что-то похожее: например std::option в Rust, Optional<T> в Java, Data.Maybe в Haskell. std::optional был добавлен в C++17 из boost::optional, где был доступен многие годы. Начиная с C++17, вы можете просто написать #include <optional> для использования этого типа. Этот тип является типом-значением (value-type) (таким образом, вы можете копировать его, если параметризуемый тип поддерживает копирование). Более того, для std::optional не нужно отдельно выделять память. std::optional является частью словарных типов C++ на ряду с std::any, std::variant и std::string_view. Обычно, опциональный тип может быть использован в следующих сценариях:
Мне нравится определение опционального типа из boost, которое подводит итог по тем ситуациям, когда нам следует его использовать. Из документации boost : Шаблонный класс std::optional управляет опциональным значением, т. е. значением, которое может быть представлено, а может и не быть. Обычным примером использования опционального типа данных является возвращаемое значение функции, которая может вернуть ошибочный результат в процессе выполнения. В отличии от других подходов, таких как std::pair<T, bool>, опциональный тип данных хорошо управляется с тяжёлыми для конструирования объектами и является более читабельным, поскольку явно выражает намерения разработчика. Хотя иногда тяжело решить, стоит ли использовать опциональный тип, вы точно не должны его использовать для обработки ошибок. Он лучше всего подходит для тех случаев, когда отсутствие значения является нормальным поведением программы. Ниже вы можете увидеть простой пример того, что можно сделать с использованием опционального типа: std::optional<std::string> UI::FindUserNick() { if (nick_available) return { mStrNickName }; return std::nullopt; // То же самое, как если вернуть просто { }; } // Использование: std::optional<std::string> UserNick = UI->FindUserNick(); if (UserNick) Show(*UserNick); В коде выше мы объявили функцию, которая возвращает опциональную строку. Если имя пользователя доступно, она вернёт строку. Если нет, то вернёт std::nullopt. Позже мы сможем присвоить это значение опциональному типу и проверить его (у std::optional есть оператор приведения к типу bool), содержит оно реальное значение или нет. Тип std::optional также перегружает operator*() для более простого доступа к содержащемуся значению. В следующих параграфах вы сможете увидеть как создавать std::optional, работать с ним, передавать и даже оценить его производительность, которую вам наверняка интересно увидеть. Есть несколько вариантов создания std::optional: // Варианты создания пустого optional: std::optional<int> oEmpty; std::optional<float> oFloat = std::nullopt; // Варианты создания optional с заданным значением: std::optional<int> oInt(10); std::optional oIntDeduced(10); // Параметризуемый тип выводится автоматически // Создание через методы make_optional() и make_optional<>() auto oDouble = std::make_optional(3.0); auto oComplex = make_optional<std::complex<double>>(3.0, 4.0); // Создание через std::in_place std::optional<std::complex<double>> o7{std::in_place, 3.0, 4.0}; // Создание optional c 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::string, полученную из input.asString() и оборачиваю её в std::optional. Если значение недоступно, то функция просто вернёт std::nullopt. Конечно, вы также можете просто объявить пустой опциональный объект в начале вышей функции и присвоить ему посчитанное значение, если оно корректно. Таким образом, мы можем переписать код выше следующим образом: std::optional<std::string> TryParse(Input input) { std::optional<std::string> oOut; // empty if (input.valid()) oOut = input.asString(); return oOut; } Какая версия лучше, зависит от контекста. Я предпочитаю короткие функции, поэтому мой выбор — версия №1 (с несколькими return). Возможно, самая важная операция для опционального типа (помимо его создания) — это то, как вы можете получить сохранённое значение. Для этого есть несколько вариантов:
Чтобы проверить, есть ли реальное значение в объекте, вы можете использовать метод 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"; Давайте посмотрим, какие ещё есть возможности у опционального типа: Изменение значенияЕсли у вас уже существует опциональный объект, вы можете легко поменять его значение с помощью методов emplace, reset, swap и assign. Внимание! Если вы присваиваете (или обнуляете) объекту std::nullopt, то у реального объекта, который хранится в опциональном, будет вызван деструктор. Вот небольшой пример: #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 подходит идеально. Имя пользователя с необязательным никнеймом и возрастом#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, вы платите за это увеличенным использованием памяти. Как минимум, одним дополнительным байтом. Если подходить абстрактно, то ваша версия 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 В то время как bool обычно занимает один байт, опциональный тип занных вынужден подчиняться правилам выравнивания. Таким образом, размер std::optional<T> больше, чем sizeof(T) + 1. Например, если у вас есть такой тип: struct Range { std::optional<double> mMin; std::optional<double> mMax; }; То он займёт больше места, чем если бы вы использовали свой тип вместо std::optional: struct Range { bool mMinAvailable; bool mMaxAvailable; double mMin; double mMax; }; В первом случае размер структуры равен 32 байтам! Во втором случае всего лишь 24. Тестовый пример на Compiler Explorer . По ссылке великолепное объяснение насчёт производительности и использованию памяти, взятое из документации boost: вопросы производительности . И в статье "Эффективные опциональные значения" автор рассуждает, как написать обёртку для опционального типа, которая может быть немного быстрее. Интересно, есть ли шанс использовать хоть какую-то магию компилятора и повторно использовать некоторое пространство, чтобы поместить этот дополнительный флаг "инициализации объекта” внутри опционального типа. Тогда бы никакого дополнительного пространства не было бы необходимо. Особенный случай: std::optional<bool> и std::optional<T*> В то время как вы можете использовать std::optional для любого типа, которого захотите, вам надо проявить особое внимание при использовании опционального типа с логическим типом и указателями. std::optional<bool> ob — о чём это говорит? С этой конструкцией вы имеете логический тип с тремя состояниями. Поэтому, если он вам и правда нужен, возможно лучше использовать настоящий троичный тип — std::tribool boost::tribool ( правка : Antervis) . Более того, использование такого типа может сбивать с толку, потому что 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 типов. Я бы хотел напомнить следующие вещи про опциональный тип:
|
|||||||
Так же в этом разделе:
|
|||||||
|
|||||||
|