1. Теория. Битовые операции
«Вся магия высших абстракций в конечном счете сводится к быстрому переключению битов в кремнии».
— Брайан Керниган
В эпоху микросервисных архитектур, сборщиков мусора, распределенных очередей и высокоуровневых дженериков легко поддаться иллюзии, будто память и вычисления абстрактны. Но кремний не знает ни JSON, ни HTTP, ни деревьев поиска. На уровне регистров центрального процессора любая программа — это лишь манипуляция машинными словами, состоящими из нулей и единиц.
Побитовые операции (Bitwise Operations) — это не архаичный синтаксис из учебников по ассемблеру PDP-11, а один из самых мощных инструментов в арсенале senior-инженера:
- Экстремальная производительность: Битовые инструкции выполняются процессором за 1 машинный такт (или суб-такт через параллельные порты АЛУ) с нулевыми аллокациями и нулевой нагрузкой на шину памяти.
- Плотность данных и экономия кэша L1: Упаковка сотен флагов или множеств в машинные слова экономит мегабайты памяти, повышая Hit Rate кэш-линий CPU до предела.
- Фундамент системного дизайна: Сетевые маски подсетей (CIDR, TCP/IP headers), криптография (AES, SHA), сериализация (Protocol Buffers varints), вероятностные структуры данных (Bloom Filter, HyperLogLog) и движки баз данных (Bitmap Indexes) построены на битовой логике.
flowchart LR
classDef reg fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef alu fill:#1e1b4b,stroke:#818cf8,stroke-width:2px,color:#f8fafc
classDef out fill:#064e3b,stroke:#34d399,stroke-width:2px,color:#f8fafc
R1["Регистр A (RAX): 0b1100"]:::reg --> ALU{"АЛУ Процессора<br>(AND, OR, XOR, AND NOT)"}:::alu
R2["Регистр B (RBX): 0b1010"]:::reg --> ALU
ALU --> OUT["Результат в регистре (1 такт CPU):<br>AND=1000 | OR=1110 | XOR=0110"]:::out
1. Базовый набор операций в Go: Полная сводная таблица
В отличие от C/C++, в языке Go строгая типизация: логические операторы && и || работают исключительно с типом bool, а для целых чисел существуют специализированные побитовые операторы. Кроме того, Go обладает уникальным оператором Bit Clear (&^), которого нет в большинстве других языков.
| Операция | Оператор в Go | Таблица истинности (A, B $\to$ Out) | Смысл операции | Инструкция x86-64 / ARM64 |
|---|---|---|---|---|
| AND (И) | & |
$1, 1 \to 1$ остальные $\to 0$ |
Фильтрация, выделение битовой маски | AND / AND |
| OR (ИЛИ) | \| |
$0, 0 \to 0$ остальные $\to 1$ |
Установка (включение) битов | OR / ORR |
| XOR (Искл. ИЛИ) | ^ |
$1, 0 \to 1; 0, 1 \to 1$ $0, 0 \to 0; 1, 1 \to 0$ |
Инверсия битов, поиск различий | XOR / EOR |
| NOT (НЕ) | ^ (унарный) |
$0 \to 1; 1 \to 0$ | Побитовая инверсия (дополнение до 1) | NOT / MVN |
| AND NOT (Bit Clear) | &^ |
$A = 1, B = 0 \to 1$ если $B = 1 \to 0$ |
Сброс (очистка) битов маски: $A \ \& \ (\sim B)$ | ANDN (BMI1) / BIC |
| Shift Left (Сдвиг влево) | << |
x << k |
Сдвиг влево, эквивалент $x \cdot 2^k$ | SHL / LSL |
| Shift Right (Сдвиг вправо) | >> |
x >> k |
Логический или арифметический сдвиг | SHR / SAR |
Уникальный оператор Go: Bit Clear (&^)
Оператор &^ (AND NOT) — это гордость создателей Go:
// Сбросить 3-й бит числа x (сделать его 0):
x = x &^ (1 << 3)
В других языках программисту приходится писать громоздкую связку x = x & (~(1 << 3)). В Go оператор &^ очищает биты числа x, которые установлены в единицу во втором операнде. На процессорах с набором инструкций BMI1 компилятор Go превращает эту операцию в одну ассемблерную инструкцию ANDN.
2. Представление чисел: Two's Complement и ловушка разрядности int
Дополнительный код (Two's Complement)
Современные процессоры представляют отрицательные целые числа в виде дополнения до двух:
$$-x = \hat{\phantom{}}x + 1$$
// Пример для 8-битного знакового числа (int8):
// 5: 0000 0101
// ^5: 1111 1010 (инверсия всех бит)
// -5: 1111 1011 (^5 + 1)
3. Архитектурная ловушка: Разрядность int и знаковый бит в Go
Опасность переполнения знакового бита
Посмотрим, что произойдет, если написать:
mask := 1 << 31
- На 64-битной машине:
1 << 31— это положительное число $2\,147\,483\,648$ (бит 31 установлен, знаковый бит 63 равен 0). - На 32-битной машине: бит 31 — это знаковый бит! Число мгновенно становится отрицательным (
-2147483648илиmath.MinInt32). Попытка сдвинуть на 32 бита (1 << 32) на 32-битной системе приведет к ошибке компиляции или неопределенному результату.
Золотое правило системного инженера: Если ваш алгоритм зависит от конкретного количества бит (маски флагов, криптография, сетевые пакеты), никогда не используйте int или uint. Всегда явно указывайте типы фиксированной ширины: uint32, uint64, int32, int64.
4. Арифметический против логического сдвига (>>)
Как процессор сдвигает число вправо?
- Логический сдвиг (Logical Shift Right,
SHR): Освобождающиеся старшие биты всегда заполняются нулями. - Арифметический сдвиг (Arithmetic Shift Right,
SAR): Освобождающиеся старшие биты заполняются копией знакового бита (знаковое расширение / sign extension), сохраняя знак числа.
В Go выбор инструкции определяется типом переменной:
var a int8 = -8 // 0b11111000 (-8)
b := a >> 1 // 0b11111100 (-4) — компилятор выбрал SAR (знак сохранен)
var c uint8 = 248 // 0b11111000 (248)
d := c >> 1 // 0b01111100 (124) — компилятор выбрал SHR (старший бит стал 0)
5. Золотой фонд битовых трюков (Bit Twiddling Hacks)
Эти алгоритмические паттерны необходимо знать наизусть каждому разработчику:
1. Проверка, является ли число степенью двойки
Число $N > 0$ является степенью двойки ($1, 2, 4, 8, 16, \dots$) тогда и только тогда, когда в его двоичной записи содержится ровно одна единица:
func isPowerOfTwo(n int) bool {
return n > 0 && (n & (n - 1)) == 0
}
Механика: Если $n = 16$ (0b10000), то $n - 1 = 15$ (0b01111). Операция 16 & 15 дает чистый 0. Если же единиц было больше одной, общий префикс сохранится.
2. Сброс младшей единицы (Brian Kernighan's Algorithm)
Выражение n & (n - 1) сбрасывает самый младший единичный бит в ноль. Это основа алгоритма подсчета единиц за время, пропорциональное количеству единиц, а не разрядности числа:
func countBitsKernighan(n int) int {
count := 0
for n != 0 {
n &= n - 1 // Уничтожаем младшую единицу за 1 шаг
count++
}
return count
}
3. Быстрый обмен значений без временной переменной (XOR Swap)
a ^= b
b ^= a
a ^= b
6. Mechanical Sympathy: Пакет math/bits и аппаратные интринсики
В Go стандартный пакет math/bits — это не просто набор функций, это прямой интерфейс к аппаратным инструкциям процессора.
Компилятор Go (cmd/compile) содержит специальные правила распознавания (Intrinsics): если процессор поддерживает соответствующие инструкции, вызов функции из math/bits заменяется на одну процессорную инструкцию:
| Функция Go | x86-64 инструкция | ARM64 инструкция | Что делает |
|---|---|---|---|
bits.OnesCount64(x) |
POPCNT |
VCNT |
Подсчет установленных единиц |
bits.LeadingZeros64(x) |
LZCNT / BSR |
CLZ |
Число ведущих нулей (Count Leading Zeros) |
bits.TrailingZeros64(x) |
TZCNT / BSF |
CTZ |
Число замыкающих нулей |
bits.Reverse64(x) |
Табличный алгоритм | RBIT |
Реверс всех бит в слове |
package main
import (
"fmt"
"math/bits"
)
func main() {
x := uint64(0b00101000)
fmt.Println(bits.OnesCount64(x)) // 2 (POPCNT)
fmt.Println(bits.TrailingZeros64(x)) // 3 (TZCNT)
}
Никогда не пишите свои циклы подсчета битов в продакшене — math/bits работает со скоростью аппаратуры.
7. Битовые маски и упаковка структур (Memory Footprint Optimization)
Представьте структуру HTTP-сессии:
// НЕОПТИМАЛЬНО: Занимает 32 байта из-за Padding и выравнивания в памяти
type UserPermissionsBad struct {
CanRead bool // 1 байт
CanWrite bool // 1 байт
CanDelete bool // 1 байт
IsAdmin bool // 1 байт
IsPremium bool // 1 байт
IsBlocked bool // 1 байт
}
Каждое поле bool занимает 1 байт, а компилятор дополняет размер структуры до границы машинного слова (8 байт). При миллионе пользователей в памяти мы тратим десятки мегабайт впустую.
Оптимальное решение: Битовое поле (1 байт)
type Permission uint8
const (
PermRead Permission = 1 << 0 // 0b00000001
PermWrite Permission = 1 << 1 // 0b00000010
PermDelete Permission = 1 << 2 // 0b00000100
PermAdmin Permission = 1 << 3 // 0b00001000
PermPremium Permission = 1 << 4 // 0b00010000
PermBlocked Permission = 1 << 5 // 0b00100000
)
type UserState struct {
Perms Permission // Ровно 1 байт!
}
// Добавление права
func (u *UserState) Grant(p Permission) { u.Perms |= p }
// Проверка наличия права
func (u *UserState) Has(p Permission) bool { return (u.Perms & p) != 0 }
// Отзыв права (оператор Bit Clear)
func (u *UserState) Revoke(p Permission) { u.Perms &^= p }
Конкурентность: Атомарные битовые операции
8. Архитектурный кейс: Проектирование Bloom Filter
Фильтр Блума (Bloom Filter) — это вероятностная структура данных, используемая в Cassandra, RocksDB и PostgreSQL для исключения дорогостоящих обращений к диску. Он хранит битовый массив огромного размера.
flowchart TD
classDef hash fill:#1e3a5f,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef bitarray fill:#1e1b4b,stroke:#818cf8,stroke-width:2px,color:#f8fafc
classDef check fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
Key["Ключ 'user:1234'"] --> H1["Хэш h1 = 137"]:::hash
Key --> H2["Хэш h2 = 285"]:::hash
H1 --> W1["Word 2 (137 >> 6), Bit 9 (137 & 63)"]:::bitarray
H2 --> W2["Word 4 (285 >> 6), Bit 29 (285 & 63)"]:::bitarray
W1 --> Check{"Все биты установлены?"}:::check
W2 --> Check
Check -- "Да" --> Found["Возможно существует (идти на диск)"]
Check -- "Нет" --> NotFound["Точно НЕ существует (диск не трогать!)"]
package main
// CompactBitSet представляет эффективный битовый массив
type CompactBitSet struct {
words []uint64 // Каждое машинное слово вмещает 64 бита
size int
}
func NewBitSet(size int) *CompactBitSet {
return &CompactBitSet{
words: make([]uint64, (size+63)/64),
size: size,
}
}
// Set устанавливает i-й бит в 1
func (b *CompactBitSet) Set(i int) {
wordIdx := i >> 6 // Быстрое деление на 64 (i / 64)
bitOffset := i & 63 // Быстрый остаток от деления на 64 (i % 64)
b.words[wordIdx] |= (1 << bitOffset)
}
// Test проверяет, равен ли i-й бит единице
func (b *CompactBitSet) Test(i int) bool {
wordIdx := i >> 6
bitOffset := i & 63
return (b.words[wordIdx] & (1 << bitOffset)) != 0
}
Деление на 64 заменено сдвигом >> 6, а вычисление остатка — битовой маской & 63. На высоконагруженном потоке запросов это исключает дорогостоящую инструкцию целочисленного деления DIV (которая занимает 15–40 тактов процессора), заменяя ее на 1-тактные инструкции.
9. Резюме
- Биты — это 1 такт CPU: Побитовые операции не создают аллокаций, не обращаются к RAM и выполняются за доли наносекунды.
- Bit Clear (
&^): Фирменный оператор Go для сброса битов без многословных конструкций с инверсией. - Остерегайтесь
int: Помните о 32/64-битной вариативности. Для битовых манипуляций всегда используйте строго типизированныеuint32илиuint64. - Используйте
math/bits: Стандартный пакет Go транслируется напрямую в процессорные инструкции (POPCNT,LZCNT). - Атомарность: Модификация битов в многопоточном коде требует методов
atomic.Uint32/Uint64(Or,And).
В следующей лекции мы разберем математическое чудо операции «Исключающее ИЛИ» и научимся решать задачи на поиск элементов с гарантией $O(1)$ по памяти: 2. XOR паттерны.