Флаги в аргументах функций +20

Вы когда-нибудь сталкивались с таким кодом?

process(true, false);

Эта функция, судя по названию, что-то обрабатывает (process). Но что означают параметры? Какой параметр здесь true, а какой false? По вызывающему коду об этом нельзя судить.

Нам придется заглянуть в объявление функции, которое дает подсказку:

void process(bool withValidation,
             bool withNewEngine);

Очевидно, автор использует два параметра типа bool как флаги (toggles). Реализация функции может быть похожа на это:

void process(bool withValidation,
             bool withNewEngine)
{
  if (withValidation)  // используется 1-й флаг
    validate(); // % подтвердить
 
  do_something_toggle_independent_1
 
  if (withNewEngine)   // используется 2-й флаг
    do_something_new();
  else
    do_something_old();
 
  do_something_toggle_independent_2();
}

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

process(false, true);

Но я перепутал порядок аргументов.

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

process(/*withValidation=*/ false, /*withNewEngine=*/ true);

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

// В C++ такого нет:
process(withValidation: false, withNewEngine: true);

Но если бы даже в C++ такое было, вряд ли это было бы совместимо с прямой передачей (perfect forwarding):

std::function<void(bool, bool)> callback = &process;
callback(???, ???); // какие имена использовать?

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

struct Base
{
  virtual void process(bool withValidation,
                       bool withNewEngine);
};
 
struct Derived : Base
{
  void process(bool withNewEngine,
               bool withValidation) override;
};

Компилятор не заметит проблемы, поскольку параметры различаются только по именам, а их типы одинаковы (оба bool).

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

std::vector<int> vec;
process(vec.data(), vec.size());

Более распространенная проблема — с использованием bool в конструкторах. Пускай есть класс с двумя конструкторами:

class C
{
  explicit C(bool withDefaults, bool withChecks);
  explicit C(int* array, size_t size);
};

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

Однако есть причина, почему люди обычно используют bool для представления флагов. Это единственный встроенный тип, доступный «из коробки» и предназначенный для представления только двух возможных значений.

Перечисления


Чтобы решить указанные проблемы, мы должны располагать типом, отличным от bool, который удовлетворял бы следующим требованиям:

— для каждого флага создается уникальный тип,
— неявные преобразования запрещаются.

C++11 вводит понятие классов перечислений, которые удовлетворяют обоим требованиям. Также мы можем использовать тип bool как базовый тип перечисления; таким образом, мы гарантируем, что перечисление содержит только два возможных значения и имеет размер одного bool. Вначале определяем классы флагов:

enum class WithValidation : bool { False, True };
enum class WithNewEngine  : bool { False, True };

Теперь мы можем объявить нашу функцию:

void process(WithValidation withValidation,
             WithNewEngine  withNewEngine);

Есть некоторая избыточность в этом объявлении, но зато порядок использования функции теперь такой, какой нужен:

process(WithValidation::False, WithNewEngine::True); // ok

И если я поставлю флаги в неправильном порядке, то получу ошибку компиляции из-за несоответствия типов:

process(WithNewEngine::True, WithValidation::False); // ошибка!

Каждый флаг имеет уникальный тип, который исправно работает при прямой передаче (perfect forwarding), и Вы никак не сможете расположить параметры в неправильном порядке в объявлениях функций и переопределениях виртуальных методов.

Но использование перечислений в качестве флагов имеет свою цену. Флаги в некоторой мере похожи на значения типа bool, но классы перечислений не имитируют эту схожесть. Неявные преобразования в bool и обратно не работают (и это хорошо), но явные преобразования тоже не работают, и это проблема. Если мы взглянем еще раз на тело функции process, то поймем, что оно не компилируется:

void process(WithValidation withValidation,
             WithNewEngine  withNewEngine)
{
  if (withValidation)  // ошибка: нет контекстного преобразования в bool
    validate();
 
  // ...
}

Мне приходится использовать явное преобразование:

if (bool(withValidation))  // ok
  validate();

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

if (bool(withNewEngine) || bool(withValidation))
  validate();

Кроме того, для экземпляра класса перечисления Вы не можете сделать прямую инициализацию из bool:

bool read_bool();

class X
{
  WithNewEngine _withNewEngine;

public:
  X() 
    : _withNewEngine(read_bool()) // ошибка
    {}
};

Опять придется делать явное преобразование:

class X
{
  WithNewEngine _withNewEngine;
 
public:
  X() 
    : _withNewEngine(WithNewEngine(read_bool())) // ok
    {}
};

Это можно считать дополнительной гарантией безопасности, но здесь слишком много явных преобразований. В классах перечислений больше «explicit», чем в конструкторах и операторах преобразования, объявленных как «explicit».

tagged_bool


Из-за проблем, возникающих при использовании bool и классов перечислений, мне пришлось сделать свой собственный инструмент, который называется tagged_bool. Вы можете найти его реализацию здесь. Она совсем небольшая. С ее помощью, классы флагов объявляются вот так:

using WithValidation = tagged_bool<class WithValidation_tag>;
using WithNewEngine  = tagged_bool<class WithNewEngine_tag>;

Вам придется сделать предварительное объявление класса-тэга, такого как «WithValidation_tag». Определение для него писать не нужно. Он используется для создания уникальной специализации шаблона класса tagged_bool. Эта специализация может быть явно преобразована в bool и обратно, а также в другие специализации шаблона tagged_bool, поскольку, как обычно бывает на практике, какой-нибудь bool, передающийся на нижние уровни приложения, становится впоследствии другим флагом с другим именем. Использовать созданные таким образом флаги можно вот так:

void process(WithValidation withValidation,
             WithNewEngine  withNewEngine)
{
  if (withNewEngine || withValidation)  // ok
    validate();
 
  // ...
}
 
process(WithValidation{true}, WithNewEngine{false}); // ok
process(WithNewEngine{true}, WithValidation{false}); // ошибка

Вот и все. tagged_bool — это часть библиотеки Explicit library, которая содержит несколько инструментов, позволяющих более явно выразить Ваши намерения при проектировании интерфейсов.

От переводчика


У Анджея ранее была другая статья про тэги — «Intuitive interface — Part I» от 5 июля 2013 г. (Part 2 так и не появилась на свет, не ищите). Если вкратце, то там поднималась такая проблема:

std::vector<int> v1{5, 6}; // 2 элемента: {5, 6}
std::vector<int> v2(5, 6); // 5 элементов: {6, 6, 6, 6, 6}

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

std::vector<int> v(5, 6);

Что такое 5 и 6? Это будет 5 шестерок или 6 пятерок? Если забыли — идите смотреть документацию.

И хотелось бы иметь еще один конструктор, создающий пустой вектор с заданной capacity: std::vector v(100). К сожалению, конструктор, принимающий один size_t, уже занят — он создает вектор с заданным size’ом, заполненный сконструированными по умолчанию объектами.

Анджей упоминает, что такой порядок вещей не дает в полной мере воспользоваться возможностями прямой передачи, но в комментариях ему разъяснили, что эта проблема решается без всяких тэгов.

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

std::vector<int> v1(std::with_size, 10, std::with_value, 6);

Применительно к настоящей статье, это выглядело бы так:

process(withValidation, true, withNewEngine, false); // ok
process(withNewEngine, true, withValidation, false); // ошибка

Разница в том, что тэг и значение теперь объединены в один объект. В статье «Competing constructors» от 29 июля 2016 г. Анджей мимоходом написал, что ему не нравится идея такого объединения.

vector<int> w {with_size{4}, 2};   // {2, 2, 2, 2}
vector<int> x {with_size{4}};      // {0, 0, 0, 0}
vector<int> y {with_capacity{4}};  // {}

Теперь это уже не тэги, а полноценные объекты. Кому-нибудь может прийти в голову положить их в контейнер:

vector<with_size> w {with_size{4}, with_size{2}};

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

С bool, однако, эта угроза не так страшна. Конструкторы контейнеров STL не принимают bool, иначе как в составе initializer_list’ов. Видимо, поэтому Анджей и решился в этот раз объединить тэг и значение.

Напоследок приведу перевод нескольких комментариев к статье.

Комментарии


kszatan
February 17, 2017 at 11:36 am
Я бы в первую очередь подумал об избавлении от всех этих флагов и вывел бы код для подтверждений (validations) и нового/старого движка (new/old engine) в отдельные классы, чтобы передавать их как аргументы. Функция «process» и так уже делает слишком много.

Andrzej Krzemienski
February 17, 2017 at 12:03 pm
В простых случаях отказ от любых флагов действительно может оказаться лучшим выбором. Но когда решение установить флаг принимается несколькими уровнями выше в стеке вызовов, такой рефакторинг может оказаться неосуществимым или непрактичным.

int main()
{
  Revert revert_to_old_functionality {read_from_config()};
  Layer1::fun(revert_to_old_functionality)
}
 
void Layer1::fun(Revert revert)
{
  // something else...
  Layer2::fun(revert);
};
 
void Layer2::fun(Revert revert)
{
  // something else...
  Layer3::fun(revert);
};
 
void Layer3::fun(Revert revert)
{
  // something else...
  if (revert)
    do_poor_validation();
  else
    do_thorough_validation();
};

===Конец треда===

micleowen
February 17, 2017 at 10:41 pm

class C
{
    explicit C(bool withDefaults, bool withChecks);
    explicit C(int* array, size_t size);
};

«explicit» используется в конструкторах с одним параметром.

Andrzej Krzemienski
February 20, 2017 at 8:22 am

Есть основательная причина объявлять почти все конструкторы как «explicit», а не только конструкторы с 1 аргументом (особенно с выходом стандарта C++11). Иногда даже конструктор по умолчанию лучше объявить как «explicit». Кому интересно, советую обратиться вот к этой статье .
===Конец треда===

ARNAUD
February 18, 2017 at 6:39 pm

«Неявные преобразования в bool и обратно не работают (и это хорошо), но явные преобразования тоже не работают, и это проблема»

Не понимаю, что в этом плохого:

if(withValidation == WithValidation::True)

Сначала Вы используете общеизвестную возможность языка, и Ваш код прекрасно читается и понимается всеми специалистами по С++. Потом Вы переходите к использованию специального шаблона для автоматического преобразования в bool и обратно? Меня это не убеждает.

И еще. Представьте, что через какое-то время один из параметров перестанет быть bool и сможет принимать значения no_engine, engine_v1, engine_v2… Класс перечисления позволяет сделать такое расширение естественным путем, в отличие от Вашего tagged_bool.

Andrzej Krzemienski
February 20, 2017 at 8:36 am

Вы подняли два вопроса.

1. Выбор между

if (withValidation || withNewEngine)

и

if (withValidation == WithValidation::True 
    || withNewEngine == WithNewEngine::True)

И, в случае использования пространств имен:

if (withValidation == SomeNamespace::WithValidation::True 
    || withNewEngine == SomeNamespace::WithNewEngine::True)


Для меня это компромисс между желаемым уровнем безопасности и удобством использования. Мой личный выбор — что-то безопаснее bool, но не такое многословное как классы перечислений. Видимо, Ваш компромисс лежит ближе к классам перечислений.

2. Возможность добавить третье состояние

Если Вы предвидите, что в будущем Вам может понадобиться третье состояние, то классы перечислений и правда могут быть предпочтительнее. А могут и не быть. Потому что, когда Вы добавляете третье состояние, все Ваши if’ы продолжают исправно компилироваться, хотя Вы, может быть, желаете их отредактировать, чтобы добавить проверку третьего состояния.

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

===Конец треда===

mftdev00
March 13, 2017 at 1:05 pm

Я вообще не люблю флаги. Они противоречат принципу единственной ответственности. Делай что-то, если true, делай что-то другое, если false…

Andrzej Krzemienski
March 13, 2017 at 1:10 pm

Согласен. Везде, где это возможно, нужно обходиться без флагов.

===Конец треда===

SebB
March 21, 2017 at 6:09 pm

Можно ли вместо явного удаления конструкторов для каждого типа:

constexpr explicit tagged_bool (bool v) : value {v} {}
 
constexpr explicit tagged_bool (int) = delete;
constexpr explicit tagged_bool (double) = delete;
constexpr explicit tagged_bool (void*) = delete;

…просто удалить их для всех типов (кроме bool) разом?

constexpr explicit tagged_bool (bool v) : value {v} {}
 
template <typename SomethingOtherThanBool>
constexpr explicit tagged_bool(SomethingOtherThanBool) = delete;

Andrzej Krzemienski
March 22, 2017 at 7:32 am

Я просто не учел такую возможность, когда разрабатывал интерфейс. Может, и полезно было бы это добавить. Но теперь, когда Вы это предложили, я вижу один случай, где это имело бы отрицательный эффект: кто-то может использовать свой собственный (безопасный) логический тип с неявным преобразованием в bool. В этом случае, нам, может быть, нужно позволить этому типу работать с tagged_bool.

Вы можете помочь и перевести немного средств на развитие сайта

Теги:



Комментарии (21):

  1. Varim
    /#10680874

    но явные преобразования тоже не работают
    Так работают же!?:
    if (bool(withValidation))  // ok

    • clubs
      /#10680886

      Имеются в виду контекстные преобразования.

      Есть один момент, который не все знают. Если объявить оператор преобразования как explicit:

          struct A {
              explicit operator bool() { return false; }
          };


      то компилятор не сможет делать неявные преобразования:

          auto foo = [](bool) { return; };
      
          A a;
          foo(a); // не компилируется


      А контекстные — сможет:

          while (a);
          do { if (a) assert(false); } while (a);
          assert(!a && (a || true));
          for(a ? 5 : 6; a;);
          // кроме того, explicit operator bool допускает использование объектов
          // класса в выражениях static_assert и noexcept

  2. hdfan2
    /#10681010

    В Visual Studio раньше был варнинг C4800: 'type': forcing value to bool 'true' or 'false' (performance warning). Но в VS2017 эти идиоты его убрали, т.к. он, видишь ли, был слишком «шумный».

    • Jamdaze
      /#10681426

      А как это влияет на производительность?

      • Myzrael
        /#10681718

        работа с байтами медленнее, чем с двойными словами (если речь про x86).

  3. PkXwmpgN
    /#10681052

    Очевидно, автор использует два параметра типа bool как флаги (toggles)

    Обычно, бывает так, что там где два подобных параметра, там со временем их становится 10. И как ты эти параметры не называй лучше от этого не станет.
    Здесь нужно сосредоточить свои силы не на том как назвать эти параметры, а в принципе пересмотреть подобный интерфейс с отдачей наружу функции с 2-10 флажками (раз уж мы говорим о C++), тем более если это какая-та обработка. Например, декомпозировать, выделить отдельные сущности (возможно полиморфные), определить входные/выходные параметры, выстроить процесс обработки обходом четко определенных объектов (все зависит от конкретной задачи). Поддержать хоть какую-то гибкость и расширяемость.


    Т.е. проблема здесь не во флажках, а в коде типа


    ...
    if (withNewEngine)
        do_something_new();
    else
        do_something_old();
    ...

    • clubs
      /#10681174 / +1

      Комментаторы в оригинальной статье тоже это заметили. После самой статьи я привожу перевод нескольких таких комментариев с ответами автора.

    • andy_p
      /#10681624

      На самом деле часто бывает так:


      // Many lines of code

      if(flag) {
      // A few lines
      }

  4. multiprogramm
    /#10681134 / +7

    Проблема поставлена хорошо, да и решение предложено неплохое. Но ИМХО использование перечислений — шаг вперёд, а завязка их на булевы значения (и тем более каст к ним) — шаг назад. При определении перечислений нужно забыть, что переменные, из-за которых они возникают, были из множества {false, true}. Т.е. вместо

    enum class WithValidation : bool { False, True };
    enum class WithNewEngine  : bool { False, True };

    я бы написал как-то так:
    enum class ValidationMode : bool { NoValidation, Validation };
    enum class EngineVersion : bool { Old, New };

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

    Кстати, по опыту, часто функции (bool,…, bool) не обрабатывают весь спектр 2 в степени n индивидуально, она просто болеет «добавлением ещё флажка». Т.е., например, если у нас есть функция
    void PaintControl( bool is_visible, bool is_enable );

    То при первом аргументе false второй вообще не важен: какая разница, доступный контрол или нет, если мы его даже не будем отрисовывать по факту. Из-за этого напрашивается сделать что-то такое:
    enum class ControlVisibility { Hidden, Disabled, Enabled };
    void PaintControl( ControlVisibility visibility );

    • clubs
      /#10681212

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

      Автор пишет, что использовать в качестве флагов перечисления или tagged_bool — это вопрос личных предпочтений. Почитайте комментарий ARNAUD к оригинальной статье — я его тоже перевел.

  5. kITerE
    /#10681446

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


    process(/*withValidation=*/ false, /*withNewEngine=*/ true);

    Существует подход именованных локальных переменных:


    constexpr auto withValidation = false;
    constexpr auto withNewEngine = false;
    process(withValidation, withNewEngine);

    • Ndochp
      /#10682480

      Кажется мне, что читабельности это не добавляет. Булевы переменные должны читаться утвердительно. То есть

      constexpr auto withValidation = true;
      constexpr auto noValidation = false;
      constexpr auto withNewEngine = true;
      constexpr auto withOldEngine = false;
      process(noValidation, withOldEngine);
      
      

      • kITerE
        /#10682682

        Булевы переменные должны читаться утвердительно.

        На мой взгляд, это зависит от гайдлайнов, принятых при кодировании. Мне, например, выражение с двойным отрицанием сильно режет глаз:


        constexpr auto noValidation = false;

        Для меня было бы привычней:


            {
                constexpr auto validation = false;
                process(validation);
            }
        
            {
                constexpr auto validation = true;
                process(validation);
            }

        Кажется мне, что читабельности это не добавляет.

        Часто это избавляет от перехода h-файлу (для того что бы увидеть прототип функции).

        • Ndochp
          /#10682732

          Главное, чтобы вызов функции читался однозначно. То есть или присвоение и вызов подряд (как у вас), или имя булевой переменной несёт в себе и значение (как у меня).
          Но не определили переменную withValidation = false, а потом используем её кучу раз ниже по тексту.
          В итоге, если отлаживаешь нижний вызов, то уже наивно думаешь, что process(withValidation) это всё-таки с валидацией.
          В совсем крайнем случае можно флаг назвать нейтрально (Validation, Engine), как в вашем случае, чтобы не было ненужных ассоциаций

          • kovserg
            /#10684060

            Что бы вызов функции читался однозначно она не должна иметь параметров и быть членом класса.


            something.process();

  6. kovserg
    /#10681602 / +1

    Какие-то проблемы на ровном месте


    enum { WithValidation=1, NewEngine=2 };
    void process( int flags );
    ...
    process( NewEngine | WithValidation );

  7. behindyou
    /#10681714

    В случае использования флагов-перечислений удобнее полагаться на перегрузку во избежание сравнения.
    jeelabs.org/2011/05/20/c-overloading-with-enums

    • kovserg
      /#10682240

      Вы создаёте лишние проблемы и сущности на ровном месте. Если не нравятся флаги можно писать так:

      struct Process {
        typedef Process This;
        Process() {
          validation=false;
          new_engine=false;
        }
        int run() {
          //...
          return 0;
        }
        This& WithValidation(bool value=true) { validation=value; return *this; }
        This& UseNewEngine(bool value=true) { new_engine=value; return *this; }
      protected:
        bool validation, new_engine;
      };
      ...
      Process process;
      process
        .UseNewEngine()
        .WithValidation(false)
        .run();
      

      Но с флагими работет. И работает хорошо. А как гласит народная мудрость: работает не трож.

  8. BalinTomsk
    /#10682186

    --Я вообще не люблю флаги. Они противоречат принципу единственной ответственности.

    этy проблему в какой-то мере решили в С# добавляя к любому типу состояние NULL.

    bool? value[] = {true, false, null};

  9. dmitryredkin
    /#10683288

    В таких случаях у нас обычно флаги конвертируются в set из enum-ов.
    И сразу все становится понятно.