Pull to refresh

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

Level of difficultyEasy
Reading time8 min
Views1.1K

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

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

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

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

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

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 <AppIsActiveConditions> 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 <typename Type, bool = std::is_enum<Type>::value>
class ConditionalBitset;

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

public:
    // Указываю, что нужно использовать только этот конструктор 
    template <typename...Args>
    explicit constexpr ConditionalBitset (Args&&...args) noexcept
    {
        ((add(std::forward<Args>(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<EnumUnderlyingType>(condition);
    }

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

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

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

};

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

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

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

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

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

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

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

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

upd.
В методе remove используется оператор XOR (^), который имеет особенность - он возвращает разницу между двумя операндами. Он может как удалить бит, если он был, так и установить его, если его не было. Это может стать проблемами в будущем, если вызывать метод remove повторно на одно и тоже значение.

Более идиоматическое «удаление» выглядит так

constexpr void remove(EnumType condition) noexcept
{
    value &= ~(static_cast<EnumUnderlyingType>(condition));
}

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

Benchmarks

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


upd.

Чуть более детальное описание и немного теории

Не вдаваясь в более детальные подробности укажу, что память храниться, как набор битов. В обычной переменной типа int для 32-битной системы 4 байта, или 32 бита, так как в одном байте 8 бит.
Тип int в памяти можно представить так

0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 

Число 3 будет выглядеть так (это всё те же 4 байта)

0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 

Выше в статье было указано такое выражение (1 << 0). Это выражение - битовый сдвиг влево (оператор <<), относящийся к битовым операция с памятью. В более общем виде, это выражение будет выглядеть так n << m. С помощь него можно сдвинуть некую память n на некое число битов m влево. В примере 1 << 0 число 1 сдвигается на 0 битов. И по сути результат выражения равен 1. Почему тогда не написать просто 1 вместо 1 << 0? Это сделано специально для наглядности. Ведь далее в коде статьи показаны и другие выражения смещения 1 << 1, 1 << 2, 1 << 3. И вместе они уже наглядно показывают, что производится смещение числа 1 на некоторое число битов.

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

Таким образов формируется перечисление, которое используется как набор Условий, который будут «храниться» в Контейнере.

Каждое Условие, по задумке, это целочисленное не повторяющееся положительное число равное числу, полученному из степень двойки, то есть 2x. А почему нужно число именно степени двойки? Потому что особенность всех чисел степени двойки в том, что их представление в двоичной системе исчисления содержит только 1 бит равный 1.

Например число 4 в одном байте выглядит так

0 0 0 0 0 1 0 0 

А число 32 - так

0 0 1 0 0 0 0 0 

Число 1, тоже степени двойки, только степень равно нулю (20 = 1). И представляется в памяти так

0 0 0 0 0 0 0 1 

И если сдвинуть память занимаемую числом 1 влево на два бита (1 << 2), то получим

0 0 0 0 0 1 0 0 

А это уже число 4. Таким образом, сдвигая память числа 1 влево, можно получать нужное число степени двойки.

Добавление Условий в Контейнер

И теперь представьте, что каждый бит хранимого в Контейнере числа можно использовать, как отдельное булево значение, где 1 это true (ИСТИНА), а 0 это false (ЛОЖЬ). Что, собствено, и есть на самом деле. Тип bool в языке C++ - это синтаксический сахар, чтобы удобнее было работать с таким типом. В языке C, к примеру, нет типа bool. Его «эмулирую» через целочисленный тип (обычно int).

Примем, что каждое Условие - это не повторяющееся число степени двойки. Остаётся только Условия «вложить» в Контейнер. Для этого используется битовый оператор | (логическое сложение, OR, ИЛИ). Выражение будет иметь вид n | m. Для примера «сложим» числа 32 и 4

0 0 1 0 0 0 0 0  // 32
                 // |
0 0 0 0 0 1 0 0  // 4
                 // =
0 0 1 0 0 1 0 0  // 36

Получится число 36.

В статье в коде класса оператор | применяется в приватном методе add. Метод add, как бы добавляет проверяемое Условие в Контейнер. Метод приватный и применяется в публичных конструкторе и методе lose. Конструктор указан как «явный» (explicit)., чтобы при объявлении переменной сразу выставить нужные Условия для проверки. Такова задумка изначально. Других конструкторов не предусмотрено. В методе 'lose', по задумке, выставляется Условие, которое перестало быть истинным. То есть «потеряли» его. Отсюда и название lose.

«Удаление» Условий из Контейнера

Для «отключения» битов в числе, хранимом в классе, применяется оператор ^ (логическое вычитание, исключающее ИЛИ, XOR). Выражение будет иметь вид n ^ m. Оператор ставит в 0 похожие биты числа, а не похожие в 1. «Вычтем» число 32 из числа 36.

0 0 1 0 0 1 0 0  // 36
                 // ^
0 0 1 0 0 0 0 0  // 32
                 // =
0 0 0 0 0 1 0 0  // 4

Получим число 4.

В статье в коде класса оператор ^ применяется в приватном методе remove. Метод remove, удаляет проверяемое Условие из числа, хранимого в классе. Метод приватный и применяется только в публичном методе reach. Название reach выбрано для описание того, что мы как бы «достигли» проверяемого Условия.

Результат работы

В итоге, после того, как все Условия были «достигнуты», в Контейнере должно остаться число 0. И именно оно принято за конечный результат. Для того, чтобы оставить логику булево значения, то есть, чтобы можно было объект класса использовать в ветвениях и циклах, в классе был определён оператор operator bool() для неявного приведения хранимого в классе числа к булеву значению.


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

Tags:
Hubs:
Total votes 3: ↑3 and ↓0+4
Comments51

Articles