Pull to refresh

Рефакторинг с использованием C++17 std::optional

Reading time 6 min
Views 16K
Original author: Bartlomiej Filipek


В разработке существует множество ситуаций, когда вам надо выразить что-то с помощью "optional" — объекта, который может содержать какое-либо значение, а может и не содержать. Вы можете реализовать опциональный тип с помощью нескольких вариантов, но с помощью C++17 вы сможете реализовать это с помощью наиболее удобного варианта: std::optional.


Сегодня я приготовил для вас одну задачу по рефакторингу, на который вы сможете научиться тому, как применять новую возможность C++17.


Вступление


Давайте быстро погрузимся в код.


Представим, что есть функция, которая принимает объект ObjSelection, представляющий из себя, например, текущую позицию указателя мыши. Функция сканирует выделение и находит количество анимированных объектов, есть ли там гражданские юниты и есть ли там военные юниты.


Существующий код выглядит так:


class ObjSelection
{
public:
    bool IsValid() const { return true; }
    // more code...
};

bool CheckSelectionVer1(const ObjSelection &objList, 
                        bool *pOutAnyCivilUnits, 
                        bool *pOutAnyCombatUnits, 
                        int *pOutNumAnimating);

Как вы можете видеть выше, функция содержит в основном выходные параметры (в виде сырых указателей) и возвращает true/false для индикации успеха своег выполнения (например, выделение может быть некорректным).


Я пропущу реализацию этой функции, но ниже вы можете увидеть код, который вызывает эту функцию:


ObjSelection sel;

bool anyCivilUnits { false };
bool anyCombatUnits {false};
int numAnimating { 0 };
if (CheckSelectionVer1(sel, &anyCivilUnits, &anyCombatUnits, &numAnimating))
{
    // ...
}

Почему эта функция не идеальна?


На это есть несколько причин:


  • Посмотрите на код, который её вызывает: нам надо создать все переменные, которые будут хранить выходные значения функции. Это может смотреться дублированием кода, если вы вызываете функцию в нескольких местах.
  • Выходные параметры: Core Guidelines рекомендуют не использовать их. (F.20: Для возвращаемых значений предпочитайте возвращаемые значения из функции, а не выходные параметры)
  • Сырые указатели необходимо проверять на корректность.
  • Что насчёт расширения функции? Что если вам надо будет добавить ещё один выходной параметр?

Что-нибудь ещё?


Как вы будете рефакторить это?


Руководствуясь Core Guidelines и новыми возможностями C++17, я планирую разделить рефакторинг на следующие шаги:


  1. Рефакторинг выходных параметров в std::tuple, который будет возвращаемым значением.
  2. Рефакторинг std::tuple в отдельную структуру и уменьшение std::tuple до std::pair.
  3. Использование std::optional чтобы подчеркнуть возможные ошибки.

Серия


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



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



OK, теперь давайте что-нибудь порефакторим.


std::tuple


Первый шаг — это конвертировать выходные параметры в std::tuple и вернуть его из функции.


В соответствии с F.21: Для возврата нескольких выходных значений предпочтительно использовать кортежи или структуры (англ. язык)


Возвращаемое значение документируется само как значение "только для возврата". Учтите, что функция в C++ может иметь несколько возвращаемых значений с помощью соглашения об использовании кортежей (в т. ч. и пар (std::pair), с дополнительным использованием (возможно) std::tie на вызывающей стороне.

После изменения наш код должен выглядеть вот так:


std::tuple<bool, bool, bool, int> 
CheckSelectionVer2(const ObjSelection &objList)
{
    if (!objList.IsValid())
        return {false, false, false, 0};

    // local variables:
    int numCivilUnits = 0;
    int numCombat = 0;
    int numAnimating = 0;

    // scan...

    return {true, numCivilUnits > 0, numCombat > 0, numAnimating };
}

Немного лучше, не правда ли?


  • Нет необходимости проверять значения сырых указателей.
  • Код стал довольно выразительным.

Более того, вы можете использовать структурированные привязки (англ. язык: Structured Bindings, прим. пер.: на русский язык пока нет устоявшегося названия) для того, чтобы обернуть кортеж на вызывающей стороне:


auto [ok, anyCivil, anyCombat, numAnim] = CheckSelectionVer2(sel);
if (ok)
{
    // ...
}

К сожалению, мне кажется, что это не самый лучший вариант. Я думаю, что легко забыть порядок выходных переменных в кортеже. На эту тему есть статья на SimplifyC++: Попахивающие std::pair и std::tuple (англ. язык).


Более того, до сих пор остаётся проблема расширения функции в будущем. Поэтому, когда вы захотите добавить ещё одно выходное значение, вам надо будет расширять кортеж и на вызывающей стороне.


Поэтому я предлагаю следующий шаг: структура (это же предлагается в Core Guidelines).


Отдельная структура


Выходные результаты представляют собой связанные данные. Поэтому, похоже, хорошая идея обернуть их в структуру с именем SelectionData:


struct SelectionData
{
    bool anyCivilUnits { false };
    bool anyCombatUnits { false };
    int numAnimating { 0 };
};

После этого мы можем переписать нашу функцию следующим образом:


std::pair<bool, SelectionData> CheckSelectionVer3(const ObjSelection &objList)
{
    SelectionData out;

    if (!objList.IsValid())
        return {false, out};

    // scan...

    return {true, out};
}

И на вызывающей стороне:


if (auto [ok, selData] = CheckSelectionVer3(sel); ok)
{
    // ...
} 

Я использовал std::pair, поэтому мы всё ещё сохраняем флаг успешной отработки функции, он не становится частью новой структуры.


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


Но std::pair<bool, MyType> ведь очень похожа на std::optional, не так ли?


std::optional


Ниже описание типа std::optional с CppReference:


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

Это, кажется, идеальный выбор для нашего кода. Мы можем убрать ok из нашего кода и полагаться на семантику опционального типа.


Для справки, std::optional был добавлен в C++17, но до C++17 вы могли бы использовать boost::optional, так как они практически идентичны.


Новая версия нашего кода выглядит так:


std::optional<SelectionData> CheckSelection(const ObjSelection &objList)
{   
    if (!objList.IsValid())
        return { };

    SelectionData out;   

    // scan...

    return {out};
}

и на вызывающей стороне:


if (auto ret = CheckSelection(sel); ret.has_value())
{
    // access via *ret or even ret->
    // ret->numAnimating
}

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


  • Чистая и выразительная форма.
  • Эффективность: реализация опционального типа не разрешает использовать дополнительную память (например, динамическую) для хранения значения. Значение должно храниться в той области памяти, которая была выделена опциональным типом для шаблонного параметра T.
  • Нет надо беспокоиться насчёт лишних выделений памяти.

Мне кажется, что версия с использованием опционального типа является лучшей в рассмотренном примере.


Код


Вы можете поиграть с кодом по этой ссылке.


Итог


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


С другой стороны, эта новыя реализация опускает важный аспект кода: обработка ошибок. На текущий момент вы не сможете узнать, по какой причине функция не смогла вычислить значение. В предыдущем примере, при реализации с std::pair, мы могли бы возвращать какой-либо код ошибки для указания причины.


Вот что я нашёл в документации boost (англ. язык):


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

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


Как бы вы отрефакторили первую версию кода?
Вы бы возвращали кортежи или создавали бы из них структуры?


Смотрите следующую статью: Использование std::optional.


Ниже вы можете увидеть некоторые статьи, которые помогли мне с этим постом:


Tags:
Hubs:
+25
Comments 16
Comments Comments 16

Articles