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

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 Krzemieński
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 Krzemieński
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 Krzemieński
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 Krzemieński
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 Krzemieński
March 22, 2017 at 7:32 am

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