Способы переписать логические параметры в С++

от автора

Программисты читают код намного чаще, чем пишут его, поэтому важно писать понятный, последовательный, однозначный код. Автор книги С++17 in detail написал о способах избегать путаницы. Делимся его материалом к старту курса по разработке на С++.


Логические параметры в функциях могут вводить в заблуждение и затруднять его читаемость, если имя функции неинформативно:

DoImportantStuff(true, false, true, false);

Неясно, что означают эти параметры. Что значит первый true или последний false? Можно ли в таких случаях сделать код лучше? Давайте посмотрим на возможный рефакторинг.

Введение

Эта статья вдохновлена похожим текстом, который появился в блоге Анджея Кржеменски: Toggles in functions. Как пишет Анджей, весь смысл в том, чтобы улучшить код таких функций:

RenderGlyphs(glyphs, true, false, true, false);

Что если изменить порядок параметров? Тогда компилятор не слишком поможет. Подумаем о том, как сделать код лучше: сделаем его более безопасным и читаемым. Добавим комментарии:

RenderGlyphs(glyphs,              /*useChache*/true,               /*deferred*/false,               /*optimize*/true,               /*finalRender*/false);

И хотя код выше читается немного лучше, по-прежнему можно сделать его безопаснее. Но можно ли добиться большего?

Идеи

Вот несколько идей:

Крошечные перечисления

Напишем такое объявление:

enum class UseCacheFlag    { False, True }; enum class DeferredFlag    { False, True }; enum class OptimizeFlag    { False, True }; enum class FinalRenderFlag { False, True };  // и вызов, например: RenderGlyphs(glyphs,              UseCacheFlag::True,               DeferredFlag::False,               OptimizeFlag::True,               FinalRenderFlag::False);

И нужно изменить реализацию:

if (useCache) { } else { } if (deferred) { } else {}

Код сравнения:

if (useCache == UseCacheFlag::True) { } else { } if (deferred == DeferredFlag::True) { } else {}

Как видите, теперь нужно проверить значения перечислений, а не просто значения bool. Использовать перечисления — это хороший подход, но он имеет свои недостатки:

  • Требует много дополнительных имён.

  • Может быть, возможно многократно применять некоторые типы. Нужно ли иметь определённые общие флаги?

  • Значения не конвертируются в логические напрямую, поэтому сравнивать Flag::True нужно явно, внутри тела функции.

Требуемое явное сравнение — причина появления маленькой библиотеки Анджея, которая создаёт конвертируемые в bool переключатели. Я был разочарован отсутствием в языке непосредственной поддержки сильной типизации для перечислений. Но позже стал думать иначе.

Явное сравнение нетрудно написать, так что, возможно, включение сильной типизации в язык — перегиб? Явное преобразование типов может даже вызвать некоторые проблемы. Тем не менее я не совсем доволен необходимостью писать так много крошечных перечислений…

Битовые флаги

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

Вот упрощённый подход:

#include <type_traits>  struct Glyphs { };  enum class RenderGlyphsFlags {     useCache = 1,     deferred = 2,      optimize = 4,     finalRender = 8, };  // упрощение... RenderGlyphsFlags operator | (RenderGlyphsFlags a, RenderGlyphsFlags b) {     using T = std::underlying_type_t <RenderGlyphsFlags>;     return static_cast<RenderGlyphsFlags>(static_cast<T>(a) | static_cast<T>(b));     // todo: пропущенные проверки, находится ли значение в нужном диапазоне... }  constexpr bool IsSet(RenderGlyphsFlags val, RenderGlyphsFlags check) {     using T = std::underlying_type_t <RenderGlyphsFlags>;     return static_cast<T>(val) & static_cast<T>(check);     // todo: пропущенные дополнительные проверки... }  void RenderGlyphs(Glyphs &glyphs, RenderGlyphsFlags flags) {     if (IsSet(flags, RenderGlyphsFlags::useCache)) { }     else { }      if (IsSet(flags, RenderGlyphsFlags::deferred)) { }     else { }      // ... }  int main() {     Glyphs glyphs;     RenderGlyphs(glyphs, RenderGlyphsFlags::useCache | RenderGlyphsFlags::optimize);                                       }

Экспериментировать с кодом можно в @Compiler Explorer.

Что вы думаете об этом подходе? С некоторым дополнительным кодом и перегрузкой операторов в итоге можно получить типобезопасную, читабельную и красивую функцию. Добавив в мой код проверки, вы убедитесь, что в передаваемых значениях установлен нужный бит.

С версии С++23 можно воспользоваться std::to_underlying() из заголовочного файла <utility>. Эта функция уже реализована в GCC, Clang и MSVC: посмотрите мой пример в @Compiler Explorer.

Параметры структуры

Если у вас есть несколько параметров, например 4 или 5, в зависимости от контекста, почему бы не обернуть их в отдельные структуры?

struct RenderGlyphsParam {     bool useCache;     bool deferred;     bool optimize;     bool finalRender; }; void RenderGlyphs(Glyphs &glyphs, const RenderGlyphsParam &renderParam);  // вызов: RenderGlyphs(glyphs,              {/*useCache*/true,               /*deferred*/false,               /*optimize*/true,               /*finalRender*/false});

Это не очень помогло. Получился дополнительный код управления, а вызывающая сторона использует практически тот же код. Да, этот подход имеет следующие преимущества:

  • Он перемещает проблему. Применить сильную типизацию вы можете к отдельным членам структуры.

  • Если нужно больше параметров, можно расширить структуру.

  • Подход особенно полезен, когда много функций содержат одни и те же элементы структуры.

Переменную glyphs можно положить в RenderGlyphsParam, это только пример.

А что в С++20?

Благодаря обозначаемым инициализаторам, пришедшим в С++20, при конструировании структуры можно использовать именованные параметры. В основном при именовании передаваемых в функцию параметров вы можете воспользоваться подходом, как в именах аргументов С99:

struct RenderGlyphsParam {     bool useCache;     bool deferred;     bool optimize;     bool finalRender; }; void RenderGlyphs(Glyphs &glyphs, const RenderGlyphsParam &renderParam);  // вызов: RenderGlyphs(glyphs,              {.useCache = true,                .deferred = false,                .optimize = true,                .finalRender = false}); 

Посмотреть код в @Compiler Explorer.

Об этой новой функциональности можно прочитать в моём посте Designated Initializers in C++20.

Устранение логических параметров

Мы можем попробовать исправить синтаксис и написать понятный метод. Но что, если использовать метод ещё проще? Предоставить больше функций и просто устранить параметр?

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

RenderGlyphsDeferred(glyphs,              /*useCache*/true,               /*optimize*/true); RenderGlyphsForFinalRender(glyphs,              /*useCache*/true,               /*optimize*/true;

Сделаем изменение во взаимоисключающих параметрах: deferred и final не выполняются одновременно. Если разделить код не получится, можно иметь внешнюю функцию RenderGlyphsInternal.

Эта функция всё так же принимала бы эти параметры-переключатели. Но, по крайней мере, такой внутренний код будет скрыт от открытого API. Если это возможно, позже перепишите внешнюю функцию.

Думаю, полезно посмотреть на объявление функции и пересмотреть её на предмет взаимоисключающих параметров. Может, функция делает слишком много? Если да, разбейте её на функции меньше.

Написав этот раздел, я обратил внимание на совет Мартина Фаулера в статье, где он также пробует избегать переключателей. Можно прочитать эту статью здесь и ещё больше в книге Clean Code: A Handbook of Agile Software Craftsmanship.

Усиленные типы

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

Руководства С++

К счастью, у нас есть руководства по С++, куда мы можем обратиться за помощью. Вот одно из них: I.4: Make interfaces precisely and strongly typed, в нём рассказывается не только о логических параметрах, но и обо всех потенциально вводящих в заблуждение именах. Например:

draw_rect(100, 200, 100, 500); // what do the numbers specify?  draw_rect(p.x, p.y, 10, 20); // what units are 10 and 20 in?

Чтобы сделать код лучше, воспользуемся следующими подходами:

  • Передадим отдельную структуру, чтобы аргументы конвертировались в члены данных.

  • Рассмотрим использование флагов перечисления.

  • Передадим в какую-нибудь функцию std::chrono::milliseconds, а не int num_msec.

Более того, ниже предлагаемые инструментами анализа кода обязательные правила: посмотрите на функцию со множеством примитивных аргументов.

Инструменты

Если говорить об инструментах, один читатель предложил проверку Clang-Tidy, которая заставляет писать «комментарии именованных типов» рядом с аргументами. Эта функциональность называется bugprone-argument-comment.

Пример её работы:

void RenderGlyphs(Glyphs &glyphs,    bool useCache, bool deferred, bool optimize, bool finalRender, int bpp) {   }  int main() {     Glyphs glyphs;     RenderGlyphs(glyphs,              /*useCha=*/true,               /*deferred=*/false,               /*optimize=*/true,               /*finalRender=*/false,              /*bpppp=*/8);                                      }

Вы получите такое сообщение:

<source>:13:14: warning: argument name 'useCha' in comment does not            match parameter name 'useCache' [bugprone-argument-comment]              /*useCha=*/true,               ^ <source>:5:8: note: 'useCache' declared here   bool useCache, bool deferred, bool optimize, bool finalRender, int bpp)        ^

Форма комментария должна быть такой: /*arg=*/. Посмотрите пример в @Compiler Explorer.

Конкретный пример 

Недавно у меня была возможность применить некоторые идеи перечисления/усиленных типов в моём коде. Вот грубый набросок:

// функции: bool CreateContainer(Container *pOutContainer, bool *pOutWasReused);  void Process(Container *pContainer, bool bWasReused);  // применение bool bWasReused = false; if (!CreateContainer(&myContainer, &bWasReused))    return false;  Process(&myContainer, bWasReused);

Коротко: создаётся и обрабатывается контейнер. Он может применяться повторно через пул, повторное использование объектов, внутреннюю логику и т. д. Думаю, это некрасиво. Используется флаг, затем он передаётся какой-то другой функции.

Более того, мы передаём указатели, и должна быть дополнительная валидация. Кроме того, выходные параметры в современном С++ обескураживают, так что это всё равно не лучшая идея. Можно ли сделать лучше? Да. С помощью перечислений:

enum class ContainerCreateInfo { Err, Created, Reused }; ContainerCreateInfo CreateContainer(Container *pOutContainer);  void Process(Container *pContainer, ContainerCreateInfo createInfo);  // применение auto createInfo = CreateContainer(&myContainer) if (createInfo == ContainerCreateInfo::Err);    return false;  Process(&myContainer, createInfo);

Здесь нет вывода через указатели. Есть сильный тип для параметра-переключателя. Если перечислению CreateInfo нужно передать больше информации, можно просто добавить элемент перечисления и обработать в подходящем месте; прототипы функций не должны меняться.

Конечно, в реализации можно сравнивать значения перечислений, а не просто bool, но это несложно и даже подробнее. Код по-прежнему несовершенный, поскольку имеется pOutContainer, что неидеально.

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

enum class ContainerCreateInfo { Err, Created, Reused }; std::pair<Container, ContainerCreateInfo> CreateContainer();

Наша функция становится фабрикой функций, даже возвращает дополнительную информацию о процессе создания. Использовать это можно так:

// применение auto [myContainer, createInfo] = CreateContainer() if (createInfo == ContainerCreateInfo::Err);    return false;  Process(&myContainer, createInfo);

Резюме 

Читая оригинальную статью Анджея и эти дополнения от меня, я надеюсь, вы поняли идею о параметрах-переключателях. Они не совсем ошибочны, и, вероятно, невозможно избегать их полностью.

По-прежнему хорошо пересматривать ваш дизайн, если захочется добавить три или четыре параметра в ряд. Может быть, вы сможете сократить количество переключателей/флагов, получив более выразительный код.

Список для чтения:

Немного вопросов:

  • Вы пробовали переписать параметры-переключатели?

  • Использовали ли вы сильную типизацию в коде?

Поделитесь отзывом в комментариях.

А мы поможем прокачать ваши навыки и освоить профессию, которая останется востребованной в любое время:

Выбрать другую востребованную профессию.

Краткий каталог курсов и профессий


ссылка на оригинал статьи https://habr.com/ru/company/skillfactory/blog/654253/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *