В разработке существует множество ситуаций, когда вам надо выразить что-то с помощью "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, я планирую разделить рефакторинг на следующие шаги:
- Рефакторинг выходных параметров в
std::tuple
, который будет возвращаемым значением. - Рефакторинг
std::tuple
в отдельную структуру и уменьшениеstd::tuple
доstd::pair
. - Использование
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:
- Полное руководство по C++17 от Николая Йосуттиса (англ. язык).
- Основы C++, включая 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
.
Ниже вы можете увидеть некоторые статьи, которые помогли мне с этим постом:
- Эффективные опциональные значения (англ. язык)
- Ссылочные спецификаторы (англ. язык)
Беглый C++: Упрощение интерфейсов с использованием std::optional<T>
Автор: NeonMercury