Найти в Дзене
Анастасия Софт

⚙️ Побитовые сдвиги и двоичная арифметика: как Python скрывает сложности

Оглавление

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

Когда вы пишете x << 1 в Python, всё вроде бы просто — будто приумножаете x на два и всё.

Но под капотом Python исполняет тонны магии, которой не было бы в C или Java.

Давайте разбираться, как Python работает с битами, почему -1 >> 1 не то же самое, что в C, и почему "двоичная арифметика" не всегда бинарная.

🧠 Что такое двоичная арифметика?

Это арифметика, где все числа представлены в бинарном (двоичном) виде — только 0 и 1. Все операции производятся на уровне битов.

В низкоуровневых языках (например, C) тип int обычно фиксированного размера — например,
32 бита со знаком.

В Python такого ограничения нет: int может быть сколь угодно большим, потому что он — объект, а не просто набор битов.

🔁 Пример 1: Как работает << и >>

x = 5 # в двоичном: 0b0101
y = x << 1 # сдвиг влево на 1 бит
print(bin(y)) # 0b1010 (10 в десятичной системе)

Что произошло:

  • 5 в битах: 0101
  • << 1 — значит "умножь на 2": 1010 = 10
⚠️ В C int ограничен, и если бит «вылезет» — он отрежется. В Python он не отрежется никогда. Хотите 1000 битов? Пожалуйста.

🔄 Пример 2: Что делает >>

x = 20 # 0b10100
y = x >> 2 # сдвиг вправо на 2 бита
print(y) # 5

  • 20 = 10100
  • >> 2 удаляет 2 младших бита → 101 = 5
Внизкоуровневых языках >> бывает знаковым (arithmetical shift) или логическим (logical shift).

В Python —
всегда арифметический сдвиг, и знак сохраняется для отрицательных чисел.

😈 Пример 3: Сдвиги и отрицательные числа

x = -8
print(bin(x)) # -0b1000 — но это не вся история!
print(bin(x >> 1)) # -0b100

Под капотом:

Python использует знаковое представление: дополнительный код (two's complement), бесконечно расширяя его до нужной длины.

То есть -8 на 32 битах в C = 0b11111111111111111111111111111000

В Python — это бесконечная последовательность единиц, заканчивающаяся 1000.

Сдвигая -8 >> 1, Python сохраняет знак и даёт -4, а не что-то странное вроде 2147483644, как может быть в C без правильной маскировки.

🧪 Пример 4: Проверка, чётное ли число (и почему тут важны биты)

n = 42
if n & 1 == 0:
print("Число чётное")

  • n & 1 проверяет последний бит.
  • Если он 0 → число чётное (все чётные заканчиваются на 0 в двоичном виде).
⚡ Это работает быстрее, чем n % 2 == 0 и хорошо показывает, как биты помогают с простыми арифметическими задачами.

🔍 Пример 5: Маскирование и ограничение до 32 бит

Иногда вам нужно получить значение как будто в 32-битной системе.

x = -1
x_32 = x & 0xFFFFFFFF
print(bin(x_32)) # 0b11111111111111111111111111111111
print(x_32) # 4294967295

  • -1 в 32-битном представлении — это 32 единицы.
  • Маскирование & 0xFFFFFFFF имитирует unsigned int из C.
🔐 Этот приём полезен при работе с бинарными протоколами, криптографией и сериализацией.

💡 Что Python делает не так, как C или Java

-2

🛠 Практика: Задача — подсчитать количество установленных битов (битов, равных 1)

def count_ones(n):
count = 0
while n:
count += n & 1 # если последний бит равен 1, увеличиваем счётчик
n >>= 1 # сдвигаем вправо
return count

print(count_ones(42)) # 3 (101010)

🧪 Это один из классических вопросов на собеседованиях. Можно сделать ещё быстрее с n &= n - 1 (алгоритм Брайана Кернигана), но это уже для профи.

📌 Вывод

Python делает работу с битами удобной и безопасной, но под капотом всё так же сложно, как в C или Assembler. Он:

  • защищает от переполнения,
  • сохраняет знак при сдвигах,
  • позволяет оперировать бесконечно большими числами.

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

🧪 1. Как в Python эмулировать unsigned int

В C у вас есть uint32_t, uint64_t, и т. д. — то есть числа без знака, которые могут переполняться.

В Python
все числа со знаком, но можно эмулировать поведение unsigned int при помощи битовой маски.

Пример: имитация uint32_t

def to_uint32(n):
return n & 0xFFFFFFFF

Пример использования:

x = -1
print(to_uint32(x)) # 4294967295
print(bin(to_uint32(x))) # 0b11111111111111111111111111111111

🔍 Это работает, потому что 0xFFFFFFFF — это 32 единицы, и & просто "отрезает" всё лишнее.

📦 2. Мини-библиотека для сериализации бинарных структур

Допустим, у нас есть структура:

  • user_id: 32-битный unsigned int
  • age: 8-битный unsigned int
  • is_active: 1 бит

Используем struct для упаковки:

import struct

def pack_user(user_id, age, is_active):
flags = 0
if is_active:
flags |= 1 # младший бит
return struct.pack("<IB", user_id, age) + bytes([flags])

  • <I — 4 байта (uint32)
  • B — 1 байт (uint8)
  • flags — 1 байт (мы пока используем 1 бит)

Распаковка:

def unpack_user(data):
user_id, age = struct.unpack("<IB", data[:5])
flags = data[5]
is_active = bool(flags & 1)
return {"user_id": user_id, "age": age, "is_active": is_active}

Пример:

data = pack_user(12345, 28, True)
print(data) # Бинарная строка

info = unpack_user(data)
print(info) # {'user_id': 12345, 'age': 28, 'is_active': True}

📦 Эту технику можно расширить под полноценный бинарный протокол — хоть свой сериализатор пиши.

☠️ 3. Почему -128 >> 1 в C — это боль, особенно если не знаешь, знаковый ли сдвиг

В C выражение -128 >> 1 — зависит от реализации.

Почему?

В C (int x = -128;) битовое представление зависит от архитектуры:

  • Большинство систем используют дополнительный код (two’s complement): -128 → 10000000 (на 8 битах)
  • Сдвиг >> может быть:
    Арифметическим: знак сохраняется (старшие биты заполняются единицами)
    Логическим: в старшие биты приходит 0

То есть:

int x = -128;
int y = x >> 1;

На одних системах y == -64, на других — y == 64 или вообще UB (undefined behavior).

В Python всё безопасно:

x = -128
print(x >> 1) # -64

✅ Всегда арифметический сдвиг. Python не даст вам сделать логический сдвиг — и это хорошо.

📌 Вывод по бонусам:

  • 🧊 Unsigned int легко эмулируется маской & 0xFFFFFFFF
  • 📦 Сериализация двоичных структур делается через struct и побитовые флаги
  • ☢️ Сдвиг в C может быть опасным, если вы не знаете, знаковый он или нет