Битовые маски и флаги в .NET C#

22 октября, 2020DEV

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

Перед тем как начать, хочется сделать небольшое вступление и напомнить, что все десятичные числа могут быть представлены в двоичном виде, в котором каждый разряд является минимальной и неделимой единицей информации — битом; т.е. такая маленькая коробочка, в которую можно положить либо ноль (ложь), либо единицу (правда). Только так и ничего другого. Как перевести число из десятичной в двоичную систему (или любую другую) в интернете написано масса информации и повторяться смысла нет. Это самый базовый и начальный курс computer science, школьная программа информатики. Поэтому я подразумеваю, что читатель уже умеет это делать. В конечном итоге из десятичного числа у вас должен получиться ряд из нулей и единиц. К примеру, для десятичного числа 91 ряд будет выглядеть примерно следующим образом (рисовал на планшете, авторское исполнение 🙂 но, я уверен, в этом есть некоторая определённая прелесть):

Теперь про саму идею — мы будем хранить одно или множество значений в одном числе (переменной), где каждый бит будет прикреплён к какому-либо заранее предетерминированному флагу\значению\смыслу. Возьмём, к примеру классическую задачу организации доступа юзера и примем, что у нас на сайте есть абстрактные группы, в которых есть следующие виды «допусков»: чтение статей, создание статей, удаление статей, чтение комментариев, написание комментариев, удаление комментариев, создание юзеров и блокировка юзеров. Всего 8 штук и это число я взял не случайно — именно столько бит в байте, а значит все возможные комбинации этих групп мы можем уместить всего-лишь в одном байте. Выглядит это следующий образом:

Или вот пример из личного опыта — совсем недавно занимался обработкой изображений и по долгу службы работал с CMYK системой цветов. По задаче мне необходимо было обрабатывать цветовые каналы отдельно друг от друга или же вместе. В любых комбинациях, соответственно, для CMYK это 15 всевозможных комбинаций. Подобное множество запрограммировать «просто и быстро» — не просто и не быстро 🙂 Но, если применить битовые маски, где, скажем, 4й бит это С, 3й бит — М, 2й — Y и 1й — K, всё становится на порядок легче и проще! Можно передавать всевозможные комбинации используя всего-лишь один байт (и то, займём там только половину «полезной площади»).

Таблица истинности

Итого, мы умеем переводить числа в двоичное представление. Теперь необходимо вспомнить таблицу истинности (хотя, конечно, это не про «вспомнить», а про элементарную логику, которая обычно выводится — ничего запоминать не нужно). О таблице, которая описывает все логические функции — какие аргументы поступают на входе, какая применяется операция и какой результат на выходе. Всё это в сокращённом виде (для базовых функций AND, OR, XOR) выглядит так:

ABANDORXOR
00000
10011
01011
11110
Таблица истинности

Логические операторы

Как правило все вышеописанные логические функции представлены во всех языках программирования в том или ином виде и конкретно для «си-подобной» группы языков это, как правило, абсолютно всегда одинаковый синтаксис. Вы встретите это в C, С++, C#, PHP, JavaScript, Python и ещё много где. Это всё в некотором роде уже стало стандартом синтаксиса для битовых (bitwise) операций.

Оператор & для AND

Логическое И или же конъюнкция. Результатом работы данной логической операции является множество (т.к. на вход поступает два операнда из множеств нулей и единиц), вычисляемое по таблице истинности, т.е. если оба бита в текущем столбце строки являются единицами (или true), то возвращаем тоже единицу. Так проходим по всем элементам нашей строки.

Разберём пример — для строчки кода 111 & 222 ответ будет 78, т.к. 111 в десятичной системе это 01101111 в двоичной, а 22211011110. Проходя каждый элемент и применяя поочерёдно логическую операцию AND мы получим 01001110 (единица там и только там, где в исходных битах обоих операндов были единицы), что является 78 в десятичной системе.

Вот ещё несколько примеров логического И (биты, которые «отработали» положительно я выделил жирным):

decbinANDdecbinResultdecbin
11101101111&22211011110=7801001110
4300101011&9601100000=3200100000
13110000011&8701010111=300000011
4700101111&25511111111=4700101111
Примеры конъюнкции

Оператор | для OR

Логическое ИЛИ или же дизъюнкция. Здесь, в отличии от AND, нам для «положительного» результата необходимо, чтобы хотя бы один из операндов равнялся единицы. Без разницы левый, правый или оба — возвращаем 1, в противном случае — 0. Как видно из таблицы истинности для логического ИЛИ мы почти всегда будет получать единицу, кроме случая, когда оба операнда слева и справа равны нулю.

Разберём пример (я буду рассматривать такие же числа во всех примерах, чтобы можно было понять\уловить разницу). 111 | 222 будет равняться 255, т.к. 01101111 ИЛИ 11011110 вернут все единицы 11111111 (у нас нет ни одного случая при котором было бы два ноля).

Также, как и в предыдущем примере, добавляю таблицу примером логического ИЛИ:

decbinORdecbinResultdecbin
11101101111|22211011110=25511111111
4300101011|9601100000=10701101011
13110000011|8701010111=21511010111
4700101111|25511111111=25511111111
Примеры дизъюнкции

Оператор ^ для XOR

Исключающее ИЛИ или строгая дизъюнкция — моя любимая логическая операция, если, конечно, можно испытывать чувства к этому. Также известная как XOR или сложение по модулю 2, поразрядное дополнение, инвертирование по маске, жегалкинское сложение, логическое вычитание или логическая неравнозначность. Эта прекрасная операция вернёт вам единицу или true в том случае, если строго один (любой, но только один) операнд равен нулю.

Для нашего примера из чисел 111 ^ 222 ответом будет 177, потому что 01101111 XOR 11011110 даст 10110001.

decbinXORdecbinResultdecbin
11101101111^22211011110=17710110001
4300101011^9601100000=7501001011
13110000011^8701010111=21211010100
4700101111^25511111111=20811010000
Примеры строгой дизъюнкции

Оператор битового смещения (сдвига) влево <<

Битовый сдвиг влево — это перенос всех бит на одну позицию влево с потерей крайней левой (если речь идёт о процессорах, то, как правило, крайнее левое значение сохранение в регистр флага переноса) и добавлением одного нуля справа. Битовый сдвиг влево на 1 умножает число на 2, сдвиг на 2 — на 4 и т.д. К примеру, 13 << 1 даст 26, а 13 << 2 даст уже 52.

Оператор битового смещения (сдвига) вправо >>

Обратная операция левому сдвигу — битовый сдвиг вправо — сдвигает все биты вправо с потерей крайнего правого бита и добавлением одного нуля слева. Данный сдвиг — делит. К примеру, сдвиг на 1 делит число на 2, сдвиг на 2 — на 4 и т.д. 24 >> 1 — получим 12, а в случае 24 >> 2 — получим 6.

Оператор ~ для NOT

Самый простой оператор ~, работающий с одним операндом (т.е. унарная операция) — это логическое НЕ, которое инвертирует наши входные данные на противоположные (true на false и наоборот), т.е. если есть 1 — то возвращаем 0 и, соответственно, если 0 — то 1. Здесь без таблиц, всё более менее должно быть очевидно.

Есть только один ньюанс — инвертирование изменяет также знак числа. К примеру ~123 станет -124, т.к. первый разряд битового представления отвечает за знак числа — если там установлена единица, значит число отрицательное. Таким образом мы можем умножать числа на -1 путём инвертирования и прибавления 1: ~123+1. Этот способ называется дополнительный код (или two’s complement) — наиболее простой и эффективный вариант представления отрицательных чисел для компьютера.

Работа с битами

Вооружившись логическими операторами мы теперь имеем достаточно инструментария, чтобы устанавливать конкретные биты в нужные места, проверять их состояние, изменять или удалять. Но перед тем, как мы начнём производить манипуляции над битами, хочу напомнить вам ещё кое что — каждый разряд бита в двоичном представлении это ряд степени двойки, т.е. 2^n. Посмотрите, как единичка «двигается» по диагонали в зависимости от степени — именно так мы и будем «вычленять» или, наоборот, добавлять нужные нам флаги:

ndecB7B6B5B4B3B2B1B0
0100000001
1200000010
2400000100
3800001000
41600010000
53200100000
66401000000
712810000000

Теперь пример. Для того, чтобы заменить в числе 0b11110111 ноль на единицу, мы должны изменить четвёртый бит. Для этого необходимо применить логическое ИЛИ (OR) с десятичной восьмёркой (которая и несёт в себе этот самый бит — посмотрите в табличке), т.е. 0b11110111 | 0b1000 и мы получим 255, т.е. все восемь единиц 11111111.

Для того, чтобы убрать эту же единицу (т.е. совершить обратную операцию), мы должны воспользоваться исключающим ИЛИ (XOR): 0b11111111 ^ 0b1000 = 0b11110111.

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

.NET C# пример перечисления

Давайте перенесём наш изначальный пример с ACL в код и создадим стандартый enum на языке C#:

[Flags]
public enum AccessLevels : uint
{
	None = 0,

	//Articles
	READ_ARTICLES = 1,
	WRITE_ARTICLES = 2,
	DELETE_ARTICLES = 4,

	//Comments
	READ_COMMENTS = 8,
	WRITE_COMMENTS = 16,
	DELETE_COMMENTS = 32,

	//Profiles
	CREATE_PROFILES = 64,
	BLOCK_PROFILES = 128,

	ALL = ~None,
}

Данная структура позволяет нам создавать перечисления с типом uint и организовать примитивный и очень легковесный ACL (access control list), т.е. систему доступов. Обратите также внимание, что каждый бит у нас пронумерован согласно степени двойки и увеличивается в два раза для каждого последующего элемента. Также важно оставлять первый элемент None с значением нуля, т.к. по-умолчанию структура принимает значение 0 — это считается хорошей и правильной практикой. Последним элементом идёт ALL равный инверсией None (т.е. заполнены все биты, все возможные варианты значений).

Здесь же вы можете заметить, что все последующие элементы «привязаны» к значениям степени двойки (1, 2, 4, 8, 16, 32…) — это те самые биты, которые мы и будем «устанавливать». К слову, записывать эти значения можно разными способами:

//Left shift
[Flags]
public enum CMYK1 : uint
{
	None = 0,
	C = 1,
	M = C << 1,
	Y = M << 1,
	K = Y << 1,
}

//HEX
[Flags]
public enum CMYK2 : uint
{
	None = 0,
	C = 0x1,
	M = 0x2,
	Y = 0x4,
	K = 0x8,
}

//BIN
[Flags]
public enum CMYK3 : uint
{
	None = 0,
	C = 0b1,
	M = 0b1_0,
	Y = 0b1_00,
	K = 0b1_000,
}

Обратите внимание на последний способ записи — очень удобно маркировать значения в бинарном представлении чисел просто отделяя дополнительными нулями.

Также над нашими перечислениями можно наблюдать атрибут Flags, который является индикатором того, что данные enum можно использовать для битовых операций. Этот флаг не обязательный и всё будет работать и без него, но в некоторых случаях форматирования и обработки он помогает сохранять целостность нашего представления. К примеру, для ToString форматирования:

//Если мы не указываем атрибут [Flags]:
AccessLevels level = AccessLevels.READ_ARTICLES | AccessLevels.WRITE_ARTICLES;
Console.WriteLine(level.ToString("G")); //В выводе консоли мы увидим лишь число '3' (битовое значение в десятичном представлении).

//Если мы указываем атрибут [Flags]
Console.WriteLine(level.ToString("G")); //Здесь вывод будет отличаться: 'READ_ARTICLES, WRITE_ARTICLES'.

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

.NET C# пример битовых манипуляций

Рассмотрим следующий пример. Для того, чтобы «разрешить» все операции на чтение, нам необходимо «нанизать» нужные доступы через ИЛИ, т.е. каждый элемент который начинается с строки READ_:

AccessLevels level = AccessLevels.READ_ARTICLES | AccessLevels.READ_COMMENTS;
// или
AccessLevels level = AccessLevels.None;
level |= AccessLevels.READ_ARTICLES;
level |= AccessLevels.READ_COMMENTS;

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

level = level ^ AccessLevels.READ_COMMENTS;
// или
level ^= AccessLevels.READ_COMMENTS;

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

bool b = (level & AccessLevels.READ_ARTICLES) == READ_ARTICLES;
// или для последних версий .NET Framework и всех версий .NET Core можно использовать метод Enum.HasFlag:
bool b = level.HasFlag(AccessLevels.READ_ARTICLES);

Все эти операции (добавить, проверить, удалить) для наглядности и тренировки можно завернуть в специальный враппер-оболочку, чтобы абстрагировать нашу работу с AccessLevels и получить более высокоуровневый подход. Создадим класс BitOperations:

public class BitOperations
{

	public AccessLevels Value { get; private set; }

	public BitOperations() => this.Value = AccessLevels.None;

	public BitOperations(AccessLevels value) => this.Value = value;

	public void Add(AccessLevels value) => this.Value |= value;

	public void Remove(AccessLevels value) => this.Value ^= value;

	public bool Contains(AccessLevels value) => (this.Value & value) == value;

	public override string ToString() => this.Value.ToString("G");

}

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

Данный код вы можете найти в моём «блогерском» репозитории здесь.

Дополнительно

Для тренировки и самоконтроля битовых калькуляций можно использовать разнообразные бесплатные инструменты. К примеру, в онлайне есть прекрасный инструмент bitwisecmd, которые позволяет проводить любые битовые операции над числами (AND, OR, XOR, битовые смещения и т.д.). А также, вы можете посмотреть мой старый проект BitJuggling на GitHub, который позволяет делать всё то же самое, но только как стандартное приложение Windows:

alt text

Выводы

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

Leave a comment

Ваш адрес email не будет опубликован.

Prev Post Next Post