Использование битовых масок в веб-программировании

Как гласит боянистая шутка, в мире есть 10 категорий людей: те, кто знает двоичное исчисление, и те, кто не знает. Так вот, те кто знают и умеют выполнять простейшие побитовые операции, после прочтения статьи узнают как хранить несколько признаков в одном целом числе. А те, кто не знает двоичное исчисление - имеют хорошую возможность с ним познакомиться, а потом применить свои знания на примере.
Дважды два четыре: битовая арифметика
Для того, чтобы понять о чем ведется речь в статье, читатель должен иметь хотя бы минимальные понятия о двоичной системе исчисления и уметь преобразовать десятичное число к двоичному и наоборот. Если это не так, то советую сперва прочитать статью Двоичная система счисления в Википедии.
Теперь нам понадобится понимание трех логических операций в их побитовой интерпретации: конъюнкции (логическое И), дизъюнкции (логическое ИЛИ) и отрицания (инвертирования). Здесь я приведу простые примеры каждой из этих операций, а более подробно можно прочитать в той же Википедии: Битовые операции
Пускай у нас есть 2 числа: a=3 и b=5. В двоичном представлении они будут выглядеть так:
a = 011
b = 101
Логическое И:
В PHP операция имеет вид:
$c = $a & $b;
Суть ее заключается в том, что в результате устанавливаются в 1 те и только те биты, которые есть и в первом и во втором входе. Т.е., результат будет 001, что в десятичной системе соответствует числу 1.
Логическое ИЛИ:
В PHP операция имеет вид:
$c = $a | $b;
В этом случае устанавливаются биты, которые есть или в первом или во втором входе. Результат будет 111, что соответствует 7 в десятичной системе.
Логическое НЕ (инвертирование):
В отличие от предыдущих двух, эта операция является унарной, тоесть имеет один вход.
В PHP операция имеет вид:
$c1 = ~$a;
$c2 = ~$b;
В этом случае устанавливаются биты, которые не установлены на входе. Т.е., в первом случае мы получим 100, а во втором - 010 - соответственно 4 и 2 в десятичной системе.
Лепим маски: постановка задачи
Здесь можно ненадолго отвлечься от битовых операций и представить себе следующую ситуацию. У нас имеется таблица пользователей, о которых нужно хранить такую информацию:
- имя
- год рождения
- есть ли у него домашнее животное (не важно какое)
- женат ли он (или замужем, если это она)
- имеет ли доступ к Интернету
Самым очевидным решением такой задачи будет создание таблички из 6 полей: одного строкового и пяти целочисленных типов. Этот вариант вполне работоспособный, но...
... Но если внимательно посмотреть, то станет видно, что три последних аттрибута могут принимать только 2 значения: да или нет (0 или 1). Здесь и нужно вспомнить о том, что двоичная запись числа фактически представляет собой массив значений 0/1, при чём, как оказывается, с довольно удобным интерфейсом их изменения (через битовые операции). Зададим каждому свойству короткое имя и соответствующую степень двойки начиная с нуля. Для удобства, дальше будем называть это число степенью свойства. Кроме того, у каждого свойства будет двоичное и десятичное значение, которое будет равно двойке, возведенной в соответствующую ему степень.
| Свойство | Имя свойства | Степень свойства | Двоичное значение | Десятичное значение |
|---|---|---|---|---|
| домашнее животное | pet | 0 | 001 | 1 |
| женат ли он | married | 1 | 010 | 2 |
| имеет ли доступ к Интернету | internet_access | 2 | 100 | 4 |
Получается, что человеку, у которого нету домашних животных, но он женат и имеет доступ в интернет - соответствует двоичное число 110 (или 6 в десятичной системе), а человеку, который неженат, сидит без инета, но с котом - 001 (1).
Что такое битовые маски?
Битовая маска — определённые данные, которые используются для выбора битов из двоичной строки или числа. Более подробное теоретическое описание можно получить в статье Mask (computing) английской Википедии, а практические примеры работы с ними показаны ниже в статье.
Битовые маски довольно широко используются в системном программированиие и компьютеной графике.
Самым наглядным примером неявного использования битовых масок является команда chmod.
Маскарад начинается: битовые маски в веб-программировании
Итак, мы можем составить десятичное число для каждого пользователя, назовём его status, которое содержит в себе информацию о наличии или отсутствии того или иного свойства. Чтобы установить в status свойство, нужно выполнить операцию побитового ИЛИ над статусом и десятичным представлением свойства и записать в статус результат. Чтобы исключить - нужно записать в статус результат операции логического И над статусом и инвертированным значением свойства.
Чтобы проверить установлено ли то или иное свойство в данный status, нужно наложить битовую маску свойства на значение статуса: сравнить результат операции логического И над статусом и свойством со значением этого свойства.
В приведенном ниже примере мы инициализируем значение переменной $status нулём (отсутствие каких-либо свойств), затем добавим в него свойства pet, married, internet_access и уберем свойство married. После каждого действия будем "расшифровывать" статус.
$status = 0;
$status = $status | 1;
print_status($status);
//Статус: 1 - имеет домашнее животное
$status = $status | 2;
print_status($status);
//Статус: 3 - имеет домашнее животное, женат
$status = $status | 4;
print_status($status);
//Статус: 7 - имеет домашнее животное, женат, имеет доступ к Интернету
$status = $status & ~2;
print_status($status);
//Статус: 5 - имеет домашнее животное, имеет доступ к Интернету
function print_status($s)
{
$result = array();
if ( ($s & 1) == 1)
$result[] = 'имеет домашнее животное';
if ( ($s & 2) == 2)
$result[] = 'женат';
if ( ($s & 4) == 4)
$result[] = 'имеет доступ к Интернету';
echo 'Статус: '.$s.' - '.(isset($result[0])?implode(', ', $result):'ни одно из свойств не установлено').'<br/>';
}
Занавес: переносим на CУБД
Стоит заметить, что в СУБД MySQL и PostgreSQL, аналогичные операции обозначаются точно так же, поэтому составить запросы на выборку пользователей, имеющих определенные свойства и запросы на изменение статуса не составляет труда.
Вернувшись к описанной ситуации, мы можем теперь построить таблицу не с пятью, а с тремя полями (здесь не указаны ключи, посколько внимание акцентируется совершенно на другом):
`name` VARCHAR(100),
`birth_year` INTEGER(4) UNSIGNED,
`status` INTEGER UNSIGNED
)
Тогда базовые запросы будут иметь такой вид:
- Выборка всех пользователей, имеющих свойство married (степень 1) и не имеющих свойства pet (степень 0)
- Установка пользователю с name = "vasya" свойства pet (степень 0) и сброс свойства internet_access (степень 2)
- Добавление нового пользователя с именем "petya", у которого установлено свойство pet (степень 0) и свойство married (степень 1)
UPDATE `test`.`users` SET `status` = (`status` | 1) & ~4 WHERE `name` = "vasya";
INSERT INTO `test`.`users` (`name`, `birth_year`, `status`) VALUES ("petya", 1980, (1|2));
Ограничения
Как нетрудно догадаться, в одном значении можно хранить ограниченное количество признаков. Ограничено это количество типом переменной (или столбца таблицы БД). Если нам нужно хранить информацию о n различных свойствах, то "хранилище" должно иметь способность сохранять значения не меньше 2^n.
Например, в MySQL столбец BIGINT с параметром UNSIGNED может хранить числа до 18446744073709551615 (2^64 -1), что позволяет описать 63 свойства. Тем не менее, для нормальной работы с PHP рекомендуется назначать не более 31 свойства на одно значение, так как PHP не совсем корректно работает с целыми числами, которые превышают 32 бита. Если же такого количества свойств оказалось мало, можно использовать вторую колонку в таблице базы данных.
Второе ограничение - это индексирование по этим полям. По идее, индекс не должен влиять на скорость выполнения битовых операций. Однако, в силу природной "шустрости" этих операций, быстродействие не должно стать узким местом такого подхода. Собственно, лучший способ убедиться в этом - сделать тест производительности.
Кто быстрее?
Легенда примерно следующая: у нас список пользователей, каждый из которых имеет уникальный id, и 15 свойств (p1, p2 ... p15). Каждое из свойств может принимать значения 1 или 0. Список дублируется в двух таблицах, одну из которых будем называть "классической" - там свойства будут храниться каждое в своей колонке с типом BOOLEAN, который MySQL автоматически расценивает как TINYINT(1), а вторая "бм-таблица" будет содержать всего 2 столбца: автоинкрементный id и статус типа INTEGER. Пока обе таблицы не имеют индекса, потом индекс будет построен для обеих таблиц.
`id` INTEGER UNSIGNED NOT NULL AUTO_INCREMENT,
`p1` BOOLEAN,
`p2` BOOLEAN,
`p3` BOOLEAN,
`p4` BOOLEAN,
`p5` BOOLEAN,
`p6` BOOLEAN,
`p7` BOOLEAN,
`p8` BOOLEAN,
`p9` BOOLEAN,
`p10` BOOLEAN,
`p11` BOOLEAN,
`p12` BOOLEAN,
`p13` BOOLEAN,
`p14` BOOLEAN,
`p15` BOOLEAN,
PRIMARY KEY (`id`)
)
ENGINE = MyISAM;
CREATE TABLE `test`.`users_bm` (
`id` INTEGER UNSIGNED NOT NULL AUTO_INCREMENT,
`status` INTEGER UNSIGNED,
PRIMARY KEY (`id`)
)
ENGINE = MyISAM;
Наполняем
Теперь нужно наполнить таблицы тестовыми данными. Будем заполнять каждую из таблиц миллионом записей, которые аналогичны по своему значению. Наличие того или иного свойства у того или иного пользователя генерируется случайно.
Код примеров можно скачать здесь. Используются классы, описанные в следующей статье (BitMaskPackage - средство генерации SQL-запросов с использованием битовых масок).
Система:
Все запросы выполнялись на такой платформе:
ОС: Windows 2003 ServerPHP: 5.2.3
MySQL: 5.0.24
Описание запросов
Сначала приводится неформальное описание условий выбора, ниже запросы на языке SQL для таблиц users_classic и users_bm, ещё ниже - табличка с результатами эксперимента.
- №1 - свойство p1 не установлено;
- №2 - свойства p1, p2 не установлены И свойства p3, p4, p5 установлены;
- №3 - (свойства p1, p2 не установлены И свойство p15 установлено) ИЛИ (свойства p4, p5, p6, p15 установлены);
- №4 - свойства p1, p2, p3, p4, p5 установлены И (p6 или p7 не установлено ) И (p8 или p9 p10 или p11 не установлено) И (p12 или p13 установлено или (p14 установлено и p15 не установлено));
Запросы к таблице users_classic
SELECT `id` FROM `users_classic` WHERE `p1` = 0;
/*Query 2*/
SELECT `id` FROM `users_classic` WHERE `p1` = 0 AND `p2` = 0 AND `p3` = 1 AND `p4` = 1 AND `p5` = 1;
/*Query 3*/
SELECT `id` FROM `users_classic` WHERE (`p1` = 0 AND `p2` = 0 AND `p3` = 0 AND `p15` = 1) OR (`p4` = 1 AND `p5` = 1 AND `p6` = 1 AND `p15` = 1);
/*Query 4*/
SELECT `id` FROM `users_classic` WHERE (`p1` = 1 AND `p2` = 1 AND `p3` = 1 AND `p4` = 1 AND `p5` = 1 AND (`p6` = 0 OR `p7` =0) AND (`p8` = 0 OR `p9` = 0 OR `p10` = 0 OR `p11` = 0) AND (`p12` = 1 OR `p13` = 1 OR (`p14` = 1 AND `p15` = 0)));
Запросы к таблице users_bm
Приведенные ниже запросы были сгенерированы с помощью класса BitCondition, который будет описан в следующей статье.
SELECT `id` FROM `users_bm` WHERE `status` & 1 = 0;
/*Query 2*/
SELECT `id` FROM `users_bm` WHERE `status` & 28 = 28 AND `status` & 3 = 0;
/*Query 3*/
SELECT `id` FROM `users_bm` WHERE ((`status` & 16384 = 16384 AND `status` & 7 = 0) OR (`status` & 16440 = 16440));
/*Query 4*/
SELECT `id` FROM `users_bm` WHERE `status` & 31 = 31 AND ((((`status` & 32 = 0) OR (`status` & 64 = 0))) AND (((`status` & 128 = 0) OR (`status` & 256 = 0) OR (`status` & 512 = 0) OR (`status` & 1024 = 0))) AND (((`status` & 2048 = 2048) OR (`status` & 4096 = 4096) OR (`status` & 8192 = 8192 AND `status` & 16384 = 0))));
Результаты
А вот и результаты. Все запросы выполнялись из консольного клиента mysql. Обозначение (И) говорит о том, что на момент выполнения запросов для таблицы был построен индекс. Для каждого запроса указано время и количество возвращенных строк.
| users_bm | users_bm (И) | users_classic | users_classic (И) | |
|---|---|---|---|---|
| Запрос №1 | 4.26 (500110) | 4.35 (500110) | 4.25 (500110) | 11.97 (500110) |
| Запрос №2 | 0.89 (31410) | 0.89 (31410) | 1.03 (31410) | 0.80 (31410) |
| Запрос №3 | 1.91 (117258) | 1.90 (117258) | 2.13 (117258) | 2.16 (117258) |
| Запрос №4 | 0.80 (17850) | 0.72 (17850) | 0.95 (17850) | 0.70 (17850) |
Таким образом, можно сделать вывод, что хранение свойств с помощью битовых масок очень даже оправдывает себя. В большинстве запросы к таблице users_bm оказались быстрее запросов к таблице users_classic.
Продолжение следует
На пути к использованию такого подхода стоит ещё одна проблема: использовать в коде запросы с непонятными числами, пускай даже они будут откоментированны, не очень хорошо. Можно задать эти числа константами, но в таком случае всеравно остаются на виду битовые операции, которые тоже выглядят не очень аккуратно.
В следующей статье (BitMaskPackage - средство генерации SQL-запросов с использованием битовых масок) представлен код и примеры использования классов BitPropertiesCollection, BitAction и BitCondition, которые позволяют строить SQL запросы типов INSERT, SELECT, UPDATE, используя битовые маски как в части задания условия, так и в части указания значения.


— Написал: Ivan Klishch · 2008-06-24 17:21 · #
Дуже дякую за статтю ;)
Подібний метод використовують в OpenAds (тепер OpenX) для зберігання прав користувачів.
— Написал: Деня · 2008-07-13 19:46 · #
Да, битовые маски хорошо подходят для шифрования, кстати!