Использование std::optional в C++17
В данной статье описывается std:optional
– новый вспомогательный тип, добавленный в C++17. Это тип-обёртка для вашего типа и флаг, указывающий, инициализировано значение или нет. Давайте посмотрим, где он может быть полезен, и как вы можете его использовать.
Содержание
Возьмем пару типов <ВашТип, bool>
– что вы можете сделать с такой композицией?
Введение
Добавляя логический флаг к другим типам, вы можете добиться того, что называется «типами, допускающими нулевое значение» (nullable type). Как уже упоминалось, флаг используется для указания, доступно значение или нет. Такая обёртка представляет собой объект, который может быть пуст в выразительном виде (то есть не через комментарии :))
Хотя вы можете достичь «возможности обнуления», используя уникальные значения (-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>
и использовать этот тип.
Такая обертка по-прежнему является типом значения (поэтому вы можете копировать ее через глубокое копирование). Более того, std::optional
не требует выделения памяти в свободном хранилище.
std::optional
является частью словарных типов C++ наряду с std::any
, std::variant
и std::string_view
.
Когда использовать
Обычно вы можете использовать обертку optional
в следующих сценариях:
- если вы хотите красиво представить тип, допускающий нулевое значение:
- вместо использования уникальных значений (например, -1,
nullptr
,NO_VALUE
или что-то в этом роде); - например, второе имя пользователя указывать необязательно. Вы можете предположить, что здесь подойдет пустая строка, но может быть важно знать, ввел ли пользователь что-то или нет. С помощью
std::optional<std::string>
вы получите больше информации;
- вместо использования уникальных значений (например, -1,
- вернуть результат некоторого вычисления (обработки), которое не дает значения и не является ошибкой:
- например, найти элемент в словаре: если под ключом нет элемента, это не ошибка, но нам нужно разобраться с ситуацией;
- выполнить ленивую загрузку ресурсов:
- например, тип ресурса не имеет конструктора по умолчанию, а конструкция является существенной. Таким образом, вы можете определить его как
std::optional<Resource>
(и передавать его по системе), а загружать его потом, только при необходимости;
- например, тип ресурса не имеет конструктора по умолчанию, а конструкция является существенной. Таким образом, вы можете определить его как
- чтобы передать необязательные параметры в функции.
Мне нравится описание из boost::optional
, в котором резюмируется, когда мы должны использовать тип:
Из документации boost::optional
: Когда использовать optional
optional<T>
рекомендуется использовать в ситуациях, когда имеется ровно одна ясная (для всех сторон) причина отсутствия значения типаT
, и когда отсутствие значения так же естественно, как и наличие любого обычного значенияT
.
Хотя иногда решение об использовании optional
может быть размытым, вы не должны использовать его для обработки ошибок. Так как он лучше всего подходит для случаев, когда значение пустое и это нормальное состояние программы.
Простой пример
Вот простой пример того, что вы можете сделать с optional
:
std::optional<std::string> UI::FindUserNick()
{
if (nick_available)
return { mStrNickName };
return std::nullopt; // то же самое, что и return { };
}
// использование:
std::optional<std::string> UserNick = UI->FindUserNick();
if (UserNick)
Show(*UserNick);
В приведенном выше коде мы определяем функцию, которая возвращает значение optional
, содержащее строку. Если ник пользователя доступен, то она вернет строку. Если нет, то возвращается nullopt
. Позже мы можем присвоить его optional
и проверить (преобразуется в bool
), содержит ли optional
какое-либо значение или нет. optional
определяет operator*
, поэтому мы можем легко получить доступ к содержащемуся значению.
В следующих разделах вы увидите, как создать 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); // используется выведение типа
// 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};
// вызовет вектор с прямой инициализацией {1, 2, 3}
std::optional<std::vector<int>> oVec(std::in_place, {1, 2, 3});
// копирующее присваивание:
auto oIntCopy = oInt;
Как можете видеть в приведенном выше примере, при создании optional
у вас есть большая гибкость. Это очень просто для примитивных типов, и эта простота распространяется даже на сложные типы.
Конструкция in_place
особенно интересна, а тег std::in_place
также поддерживается в других типах, таких как any
и 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};
// вместо
std::optional<Point> opt{{0, 1}};
Это позволяет избежать создания временного объекта Point
.
Возврат std::optional
Если вы возвращаете optional
из функции, то очень удобно возвращать просто std::nullopt
или вычисленное значение.
std::optional<std::string> TryParse(Input input)
{
if (input.valid())
return input.asString();
return std::nullopt;
}
В приведенном выше примере вы можете видеть, что я возвращаю std::string
, вычисленный из input.asString()
, и эта строка заключена в optional
. Если значение недоступно, вы можете просто вернуть std::nullopt
.
Конечно, вы также можете объявить пустой optional
в начале функции и переназначить его, если у вас есть вычисленное значение. Таким образом, мы могли бы переписать приведенный выше пример следующим образом:
std::optional<std::string> TryParse(Input input)
{
std::optional<std::string> oOut; // пустой
if (input.valid())
oOut = input.asString();
return oOut;
}
Какая версия лучше, вероятно, зависит от контекста. Я предпочитаю короткие функции, поэтому выбрал бы первый вариант (с несколькими возвратами).
Доступ к сохраненному значению
Вероятно, самая важная операция для optional
(кроме создания) – это способ извлечения содержащегося в нем значения.
Для этого есть несколько вариантов:
operator*
иoperator->
– аналогичны итераторам. Если значения нет, поведение не определено!value()
– возвращает значение или выдаетstd::bad_optional_access
value_or(значение_по_умолчанию)
– возвращает значение, если оно доступно, илизначение_по_умолчанию
в противном случае
Чтобы проверить, представлено ли значение, вы можете использовать метод has_value()
или просто проверить, if (optioonal)
, поскольку 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
Давайте посмотрим, какие еще есть операции с этим типом:
Изменение значения
Если у вас есть объект optional
, вы можете легко изменить содержащееся в нем значение, используя несколько операций, таких как emplace
, reset
, swap
, присваивание. Если вы присваиваете (или сбрасываете) с помощью nullopt
, то, если optional
содержит значение, будет вызван его деструктор.
Вот небольшое резюме:
#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
позволяет сравнивать содержащиеся в нем объекты почти «как обычно», но с некоторыми исключениями, когда операнды имеют значение 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
Парсинг int
-ов из командной строки
#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
В приведенном выше коде используется параметр optional
, чтобы указать, выполнили ли мы преобразование или нет. Обратите внимание, что мы фактически преобразовали в optional
обработку исключений, поэтому мы пропускаем ошибки, которые могут появиться. Это может быть «спорно», поскольку обычно мы должны сообщать об ошибках.
Другие примеры
- Представление других необязательных записей для ваших типов, как в примере с записью пользователя. Лучше писать
std::optonal<Key>
, а не использовать комментарий для заметок вроде// если ключ равен 0x7788, то он пуст
или что-то в этом роде :) - Возвращаемые значения для функций
Find*()
(при условии, что вас не волнуют ошибки, такие как обрыв соединения, ошибки базы данных или тому подобное)
Производительность и память
\Когда вы используете std::optional
, вы платите увеличением объема памяти. Требуется хотя бы один дополнительный байт.
Концептуально ваша версия стандартной библиотеки относительно optional
может быть реализована примерно как:
template <typename T>
class optional
{
bool _initialized;
std::aligned_storage_t<sizeof(T), alignof(T)> _storage;
public:
// операции
};
Короче, optional
просто оборачивает ваш тип, подготавливает для него место, а затем добавляет один логический параметр. Это означает, что он увеличит размер вашего типа в соответствии с правилами выравнивания.
По поводу этой конструкции был один комментарий:
«И никакая стандартная библиотека не может реализовать
optional
таким образом (она должна использоватьunion
из-заconstexpr
)». Таким образом, приведенный выше код предназначен только для демонстрации, а не реальной реализации.
Правила выравнивания важны, поскольку стандарт определяет:
Шаблон класса
optional
[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
обычно занимает только один байт, а тип optional
должен подчиняться правилам выравнивания, и поэтому вся обертка больше, чем просто sizeof(ВашТип) + 1
байт.
Например, если у вас есть следующий тип:
struct Range
{
std::optional<double> mMin;
std::optional<double> mMax;
};
он займет больше места, чем при использовании вашего пользовательского типа:
struct Range
{
bool mMinAvailable;
bool mMaxAvailable;
double mMin;
double mMax;
};
В первом случае мы используем 32 байта! Вторая версия занимает 24 байта.
Проверьте код с помощью Compiler Explorer
Особый случай: optional<bool>
и optional<T*>
Хотя вы можете использовать optional
для любого типа, вам нужно особо внимательными при попытке обернуть логические значения или указатели.
std::optional<bool> ob
– что это моделирует? С такой конструкцией у вас есть логическое значение с тремя состояниями. Поэтому, если вам это действительно нужно, то, возможно, лучше поискать настоящий bool
с тремя состояниями, такой как boost::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
, естественно, «обнуляемый», поэтому обертывание в optional
делает его очень сложным в использовании.
Заключение
Уфф… Про optional
было много текста, но всё же это не всё :)
Тем не менее, мы рассмотрели основы использования, создания и работы с этим полезным типом обертки. Я считаю, что есть много случаев, когда optional
подходит идеально и намного лучше, чем использование некоторых предопределенных значений для представления типов, допускающих нулевое значение.
Я хотел бы напомнить следующее вещи об std::optional
:
std::optional
– это тип-обертка для выражения типов, допускающих значениеnull
;std::optional
не использует динамическое размещение;std::optional
содержит значение или пуст;- используйте
operator*
,operator->
,value()
илиvalue_or()
для доступа к базовому значению; std::optional
неявно преобразуется вbool
, поэтому вы можете легко проверить, содержит ли он значение или нет.
В следующей статье я попытаюсь объяснить обработку ошибок и объяснить, почему optional
, возможно, не лучший выбор.