Использование std::optional в C++17

Добавлено 16 октября 2022 в 13:14

В данной статье описывается 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> вы получите больше информации;
  • вернуть результат некоторого вычисления (обработки), которое не дает значения и не является ошибкой:
    • например, найти элемент в словаре: если под ключом нет элемента, это не ошибка, но нам нужно разобраться с ситуацией;
  • выполнить ленивую загрузку ресурсов:
    • например, тип ресурса не имеет конструктора по умолчанию, а конструкция является существенной. Таким образом, вы можете определить его как 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, возможно, не лучший выбор.

Теги

C++ / CppC++17std::optionalSTL / Standard Template Library / Стандартная библиотека шаблонов

На сайте работает сервис комментирования DISQUS, который позволяет вам оставлять комментарии на множестве сайтов, имея лишь один аккаунт на Disqus.com.

В случае комментирования в качестве гостя (без регистрации на disqus.com) для публикации комментария требуется время на премодерацию.