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

https://www.bfilipek.com/2018/04/refactoring-with-c17-stdoptional.html
  • Перевод
  • Tutorial


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


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


Поделиться публикацией
Комментарии 16
    0

    С моей т.з. у std::optional один небольшой недостаток. Нет поддержки sentinel value, т.е. когда какое-то значение исходного типа считается nothing. Это позволило бы, к примеру, паковать указатели или сырые хендлы из сторонних библиотек без дополнительного флага, а следовательно совместимо по бинарному представлению с исходным типом. Как следствие — можно было бы делать красивый интерфейс для низкоуровневых библиотек без доп. перепаковки памяти.

      +2
      На самом деле, автор лукавит. Финальный вариант кода потерял кой-какие свойства, которые были у изначального кода с сырыми указателями:

      1. Вызывающая сторона могла вместо out-параметра передать nullptr, тем самым сказав «мне это не нужно, можешь не вычислять»
      2. Позитивный code path вызывающего кода растет *вглубь* отступов, вместо того чтобы оставаться на верхнем уровне, используя вложенные блоки для короткой обработки побочных неуспешных сценариев

      И почему-то в C++ такое постоянно. Вводятся новые модные плюшки, которые вроде бы как бы должны заменять старые олдскульные подходы, многие из которых тянутся со времен pure C. Но всё время оказывается что новые фишки покрывают не все случаи, которые были возможны старыми способами, поэтому полностью от них отказаться нельзя.
        0
        Тут нужен compact_optional, которого пока нет.
        https://akrzemi1.wordpress.com/2015/07/15/efficient-optional-values/
          +1
          Не увидел по ссылке ничего относящегося к тому что я сказал. Там занимаются другой проблемой — в некоторых случаях использование std:optional даёт существенный оверхед по памяти.
            0
            Идея иметь compact_optional который будет занимать столько же места.
            И использовать примерно так:
            using opt_count = compact_optional<evp_int<int, -1>, class cnt_tag>;
          0
          Здравствуйте!

          Мне кажется, Вы тоже немного лукавите. Тот факт, что существует std::optional не означает, что его следует использовать всегда и везде:

          1. Если требуется иметь описанное Вами поведение, то std::optional не нужен. Если же очень хочется, то всегда можно оставить логику, связанную с nullptr: в случае, если результат не интересен, передавать nullptr в качестве значения std::optional для опционального выходного аргумента (хотя это и костыль). Отдельно хотелось бы отметить, что может иметь смысл создание перегрузки, которая не принимает этот самый выходной аргумент, который может быть иногда не нужен — если результат часто бывает не нужен, то такой вариант сократит возможность возникновения ошибки. (Если я не ошибаюсь, выходные аргументы в современном C++ считаются немного не актуальными в общем случае, но у меня нет под рукой ссылок для подтверждения.)
          2. Мне кажется, углубление позитивной ветки кода логично, если данная ветка должна работать со значением, которого может не быть. Винить в этом нововведения не разумно — раньше Вам точно также пришлось бы проверять, вернулось ли что-то, если оно могло не вернуться. В случае же, когда std::optional используется для возврата значений, которые должны быть возвращены всегда, в коде могут существовать более серьёзные проблемы, чем лишний уровень вложенности.


          Не забывайте, что C++ — это, в первую очередь, инструмент. И добавление в него «новых модных плюшек» вовсе не означает, что они должны полностью заменять то, что уже есть в языке. Вот если бы наряду с добавлением, скажем, std::function удалили бы возможность использования указателей на функции, тогда можно было бы говорить о том, что новые возможности ущербны, ведь они что-то там не позволяют делать.
            0
            2. углубление позитивной ветки плохо с точки зрения читаемости. Не просто так в Rust сделали макрос try!
              +1
              Плохо, конечно. Но:
              • Если значение является не обязательным, то мне всё ещё кажется вполне логичным написать if с проверкой его существования и обработать его в соответствующей блоке кода.
              • Если в обработка опционального значения увеличивает глубину вложенности на 1 — это не страшно в общем случае. По-настоящему плохо становится тогда, когда это уже 3-5-8 уровень вложенности. Но в этом случае проблема уже явно не в необходимости проверки чего-либо, а в низком качестве кодовой базы (Линус как-то высказывался на этот счёт в выражениях типа «your code is already fucked up», если мне не изменяет память)
              • Если очень хочется и есть такая возможность, то можно использовать ранний возврат:
                auto optional_value = function_returning_optional();
                
                if (!optional_value) {
                	return;
                }
                
                auto& my_precious_value = *optional_value;
                
                // Great code that cannot be put under "if"
              • Если в блоке кода может появится много опциональных значений, то возможно проблема в самом коде, надо что-то исправить.
            0
            1. Для этого предпочтительнее использовать перегрузку функций. Если у вас есть функция с набором опциональных null-параметров, то, возможно, вам пора подумать о рефакторинге кода, потому что скорее всего такая функция нарушает принцип единственной ответственности.

            2. Никто не мешает проверять optional на отсутствие значений, так же как и указателей на nullptr. Мне кажется, это скорее дело вкуса и стиля, а не новомодных плюшек.

            Я с вами асбсолютно согласен! Как подумаю, что какую массу возможностей теряю, перестав использовать void *, так буквально плакать хочется…
              0
              По поводу первого пункта, как насчет такого варианта?
              godbolt.org/g/4XsWU3

              Можно не просто не вычислять то, что не нужно, но и даже проверки в конечный код не попадет. Как бонус можно задать вычисление только самого последнего аргумента, не перечисляя всех предыдущих, как в случае с out-параметрами.
              0
              Мне статья рассказывает что вот меняем аут-параметры на optional и становится хорошо. Умалчивая, что не всегда и не всё становится лучше при такой замене. В этом и состоит моя претензия.
                +2
                Вроде всё понял, не понял только зачем вот это:
                bool anyCivilUnits { false };
                bool anyCombatUnits {false};
                int numAnimating { 0 };



                Чем тут фигурные скобки лучше обычной инициализации? Просто новая мода какая-то?
                  0
                  Небольшая разница есть:

                  struct A
                  {
                   int a = 1.0; // Компилируется
                   int c{2.0}; // Получаем по рукам от компилятора
                  };
                    +2
                    ну у меня
                    int a = 1.0;
                    дает
                    warning C4244: 'initializing': conversion from 'double' to 'int', possible loss of data

                    т.е. это для тех, кому на предупреждения наплевать? понятно.
                  0
                  Спасибо за перевод. К сожалению, некоторые плюсовые фишки, заимствованные из других экосистем, неполны и не везде консистентны. Если бы в плюсах была поддержка монад, тип optional был бы на порядок удобнее, а так же другие типы, имещие монадические свойсива (expected, future, корутины).

                  Я бы хотел высказать одну смелую мысль: ничто так не помогает в изучении современных «плюсов» (вот такие типы + шаблонная магия), как изучение чуточки Haskell.
                    +1
                    bool anyCivilUnits { false };
                    bool anyCombatUnits {false};
                    int numAnimating { 0 };

                    вырвиглазное форматрирование

                    Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                    Самое читаемое