Контейнер ConditionalBitset — небольшое хранилище для условий выполнения

92b0712a2d89e48d8335f4138257f363.jpg

Вводное слово

У каждого программиста бывает желание велосипедостроения. Это случилось и у меня. Решил зарефакторить кусочек кода в приложении на текущей работе. Было приложение, которое выполняло работу в бесконечном цикле. И в этом цикле работа выполнялась, когда приложение было активно. То, что приложение активно в данный момент определялось через группу булевых значений. То есть каждую итерацию цикла приложение проверяла все эти булевы условия. Я подумал, что не лучше ли будет проверять одно значение вместо пачки. Так родился простой велосипед небольшого хранилища булевых условий в битах целочисленного типа.

В статье все примеры взяты из головы. Все совпадения случаны

Проблема и решение

Так вот. Как-то столкнулся я с кодом

bool hasFocus     { false };
bool hasWindow    { false };
bool isInitialized{ false };
bool isVisible    { false };

Который дальше имел такую конструкцию:

bool isActive() const
{
    return isInitialized && hasFocus && isVisible && hasWindow;
}

Отсутствие constexpr не имеет значения для статьи. Это просто пример.

И подумалось мне, а почему так? Для чего проверять каждый раз пачку булевых значений, когда в принципе можно только одно. isActive например. И тоже булево. С точки зрения оптимизации, если isActive используется для проверки в цикле, где задержки нежелательны, проверить одно значение быстрее, чем 4. Да и те, что я предоставил в пример, это лишь пример. Условий может быть куда больше. Я видел как-то код с 12 условиями.

Так вот, почему бы не использовать одно значение? Запретов как бы нет. Но возникает другой вопрос. А как удостовериться, что все условия соблюдены? И полученная истина в isActive явно указывает на то, что и hasFocus, и hasWindow, и isInitialized, и isVisible истины? То есть можно написать методы setHasFocus, setHasWindow, seIsVisible и setIsInitialized. И в каждом из них проверять наличие всех условий. Например:

void setHasFocus(bool value)
{
    hasFocus = value;
    isActive = isInitialized && hasFocus && isVisible && hasWindow;
}

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

Я решил, что здесь хорошо подойдут битовые поля. Но использовать std: bitset я не хотел, так как это немного не то. Вот что внутри у std: bitset clang’а:

__storage_type __first_[_N_words];

Работа с массивом. И вот это вот всё. Старые добрый битовые операции — всё, что нужно. Почему нет? Берём целочисленный тип, и принимаем, что 0 — это истина. То есть, если все биты памяти этого типа стоят в 0, то значит все условия соблюдены. Если есть хотя бы один бит, то не все условия соблюдены. Логично? Логично. Выставляя биты — устанавливаются условия. Убирая биты — условия выполняются. Даже текстом выглядит просто.

Теперь вопрос в том, как выставлять биты? Здесь поможет степень двойки, битовый сдвиг и перечисления.

enum class AppIsActiveConditions : uint8_t
{
    NONE        = 0,
    INITIALIZED = (1 << 0),
    HAS_FOCUS   = (1 << 1),
    HAS_WINDOW  = (1 << 2),
    IS_VISIBLE  = (1 << 3),
};

Пример простой, но уже надеюсь понятно что к чему. Внутренний тип перечисления uint8_t нужен для создания числа, в котором поместятся все условия. По умолчанию у перечислений в C++ внутренний тип int. Можно по сути использовать любой целочисленный. Со знаком или без — это не важно. Главное, чтобы все условия имели положительные и не повторяющиеся значения степени двойки. Условие NONE с нулём вспомогательное. Оно никак не используется. Но применение и ему можно найти, если захотеть.

Объявление переменной хранилища выглядит так:

ConditionalBitset  isActive {
    AppIsActiveConditions::INITIALIZED,
    AppIsActiveConditions::HAS_FOCUS,
    AppIsActiveConditions::HAS_WINDOW,
    AppIsActiveConditions::IS_VISIBLE
};

Переменная isActive хранит в себе биты условий. И не равно 0. Далее остаётся только указывать, какие условия выполнились. Или заново установить условия, если условие перестало быть истинным. Делается это через методы

isActive.reach(AppIsActiveConditions::INITIALIZED); // достигли условия
isActive.lose(AppIsActiveConditions::INITIALIZED);  // условие потеряли

Оба метода возвращают булево для того, чтобы проинформировать. true возвращается, когда методы выполнили работу. false же означает:

  • для метода reach, что при достижении условия — условие либо уже было достигнуто, либо не устанавливалось изначально.

  • для метода lose, что при потере условия — условие либо ещё не было достигнуто, либо было установлено ранее.

Немного заумно, но так проще понять.

Проверить наличие условия можно через метод

isActive.isReached(AppIsActiveConditions::INITIALIZED);

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

Осталось только показать код класса.
(Спойлер в markdown почему-то не работает. Оставлю так, портянкой — для объёму)

Код класса

// Предварительное проверочное объявление класса,
// чтобы случайно не ввести другие типы. 
// Потому что нам нужны только перечисления
template ::value>
class ConditionalBitset;

// Основное тело класса
template 
class ConditionalBitset  final
{
    // Выделяется внутренний тип перечисления
    using EnumUnderlyingType = typename std::underlying_type::type;

public:
    // Указываю, что нужно использовать только этот конструктор 
    template 
    explicit constexpr ConditionalBitset (Args&&...args) noexcept
    {
        ((add(std::forward(args))),...);
    }

    // Преведение типа к булеву для простоты проверки
    // Сравнение с нулём для наглядности, можно указать `!value`
    constexpr operator bool() const noexcept { return value == 0; }

    constexpr bool isReached(EnumType condition) const noexcept
    {
        return !has(condition);
    }

    constexpr bool lose(EnumType condition) noexcept
    {
        if (has(condition))
        {
            return false;
        }

        add(condition);
        return true;
    }

    constexpr bool reach(EnumType condition) noexcept
    {
        if (!has(condition))
        {
            return false;
        }

        remove(condition);
        return true;
    }

private:
    // Вспомогательные приватные методы тоже для наглядности

    constexpr void add(EnumType condition) noexcept
    {
        value |= static_cast(condition);
    }

    constexpr bool has(EnumType condition) const noexcept
    {
        return (value & static_cast(condition));
    }

    constexpr void remove(EnumType condition) noexcept
    {
        value ^= static_cast(condition);
    }

    // Целое число, как битовое поле
    EnumUnderlyingType value; 

};

Просто и наглядно.

Минусы решения

И в при таком решении есть свои минусы.

Основной минус, что нельзя динамически добавлять условия. Но мне это и не было нужно.

Так же нет проверки на одинаковые значения условий. Поэтому важным критерием является не повторяющиеся значения для условий.

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

И ещё — нельзя «выключить» условие, можно только либо «погасить» его в переменной где-то в коде, либо удалить его из списка инициализации переменной.

На самом деле минусов куда больше. Я указал только часть

Benchmarks

Их нет. Этот велосипед — это велосипед. И не рассматривался, как конечное решение для прода. А для статьи для Хабра — самое то.

На этом всё. Спасибо за внимание!

© Habrahabr.ru