Pull to refresh

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

Reading time 11 min
Views 80K
Original author: Bartlomiej Filipek


Давайте возьмём пару от двух типов <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.


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


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


  • Если вы хотите красиво представить 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()
{
    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, работать с ним, передавать и даже его производительность, которую вам наверняка интересно увидеть.


Серия


Эта статья является частью моей серии про библиотечные утилиты 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::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).


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


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


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


  • Использовать 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


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


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


Если у вас уже существует опциональный объект, вы можете легго поменять его значение с помощью методов 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


Ниже вы найдёте два примера, где 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

В то время как 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 типов.


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


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

Articles