Перейти к содержанию

Лекция 4. Машинное представление: числа, кодировки, байты

В этой лекции — как данные физически хранятся в памяти компьютера и как с этим работать в Python и Go. План:

  • системы счисления (двоичная, восьмеричная, шестнадцатеричная);
  • представление целых чисел (прямой и дополнительный код);
  • представление вещественных чисел (IEEE 754);
  • кодировки текста (ASCII, кодовые страницы, Unicode, UTF-8/16);
  • байтовые строки (bytes, bytearray, memoryview в Python; []byte в Go);
  • преобразование между текстом и байтами.

Системы счисления

У человека десять пальцев — отсюда десятичная система. Любое число можно записать как сумму цифр, умноженных на степени базы:

\[1573 = 3 \cdot 10^0 + 7 \cdot 10^1 + 5 \cdot 10^2 + 1 \cdot 10^3\]

У компьютера два «пальца» — ток есть или нет. Поэтому база — двойка, цифры — 0 и 1.

Перевод двоичная → десятичная

\[101101_2 = 1 \cdot 2^0 + 0 \cdot 2^1 + 1 \cdot 2^2 + 1 \cdot 2^3 + 0 \cdot 2^4 + 1 \cdot 2^5 = 1 + 4 + 8 + 32 = 45_{10}\]

Перевод десятичная → двоичная

Алгоритм: ищем максимальную степень 2, не превосходящую число; вычитаем; повторяем для остатка.

Для \(57_{10}\):

  • \(2^5 = 32 \leq 57\), разряд 1, остаток \(25\);
  • \(2^4 = 16 \leq 25\), разряд 1, остаток \(9\);
  • \(2^3 = 8 \leq 9\), разряд 1, остаток \(1\);
  • \(2^2 = 4 > 1\), разряд 0;
  • \(2^1 = 2 > 1\), разряд 0;
  • \(2^0 = 1 \leq 1\), разряд 1.

\(57_{10} = 111001_2\).

Восьмеричная и шестнадцатеричная

В программировании часто используют:

  • восьмеричную (база 8, цифры 0–7) — три бита упаковываются в одну цифру;
  • шестнадцатеричную (база 16, цифры 0–9, A–F) — четыре бита упаковываются в одну цифру.

Hex особенно удобен — один байт = две цифры:

0xFF   = 255   = 11111111
0x80   = 128   = 10000000
0xCAFE = 51966 = 1100101011111110

Литералы в коде

В Python и Go одинаковые префиксы:

0b10011    # двоичная — 19
0o23       # восьмеричная — 19
0x13       # шестнадцатеричная — 19
1_000_000  # подчёркивания для читаемости
const a = 0b10011
const b = 0o23
const c = 0x13
const d = 1_000_000

Преобразования

В Python:

# Из строки в число
int("19")          # 19
int("10011", 2)    # 19 — из двоичной
int("FF", 16)      # 255

# Из числа в строку
bin(19)            # '0b10011'
oct(19)            # '0o23'
hex(255)           # '0xff'

# Через format / f-strings
f"{255:b}"         # '11111111'
f"{255:08b}"       # '00000000' с шириной 8 + лидирующие нули
f"{255:o}"         # '377'
f"{255:x}"         # 'ff'
f"{255:X}"         # 'FF'

В Go — пакет strconv:

n, _ := strconv.ParseInt("FF", 16, 64)   // 255
s := strconv.FormatInt(255, 16)          // "ff"

// fmt.Printf
fmt.Printf("%b\n", 255)    // 11111111
fmt.Printf("%08b\n", 255)  // 11111111
fmt.Printf("%o\n", 255)    // 377
fmt.Printf("%x\n", 255)    // ff

Целые числа в памяти

Беззнаковые

Все биты — под число. В одном байте (8 бит) — числа от 0 до 255.

0    = 00000000
1    = 00000001
127  = 01111111
255  = 11111111

Знаковые: прямой код

Самый старший (левый) бит — знак: 0 = плюс, 1 = минус. В байте — числа от -127 до +127.

+5  = 00000101
-5  = 10000101  (только знак изменился)

Проблема: два представления нуля (00000000 и 10000000), сложная арифметика — нужно отдельно обрабатывать знак.

Знаковые: дополнительный код

Современные компьютеры используют дополнительный код (two's complement). Алгоритм для отрицательного числа -m:

  1. записать |m| в прямом коде;
  2. инвертировать все биты (получится обратный код);
  3. прибавить 1.

Пример для -52 в 8 битах:

 52 = 00110100   (прямой код модуля)
~   = 11001011   (обратный код)
+1  = 11001100   (дополнительный код = -52)

Преимущества:

  • одно представление нуля;
  • сложение/вычитание — обычное двоичное сложение, без проверок знака;
  • диапазон в байте: -128..+127 (на одно отрицательное больше).

В Python тип int имеет неограниченную разрядность — переполнение невозможно. Но при битовых операциях нужно помнить про знак:

~5            # -6  (инверсия + знаковый бит)
5 & 0xFF      # 5
(-1) & 0xFF   # 255 — обрезаем до байта

В Go типы фиксированной разрядности, переполнение возможно и не вызывает ошибки:

var a int8 = 127
a++             // -128 — overflow, без паники!

var b uint8 = 0
b--             // 255 — wrap around

Чтобы безопасно проверять — math/bits, errors.As(err, &strconv.ErrRange) при парсинге.

Размеры целых в Go:

Тип Бит Диапазон
int8 / uint8 8 -128..127 / 0..255
int16 / uint16 16 -32768..32767 / 0..65535
int32 / uint32 32 -2.1·10⁹..+2.1·10⁹ / 0..4.3·10⁹
int64 / uint64 64 ±9.2·10¹⁸ / 0..1.8·10¹⁹
int / uint 32 или 64 (зависит от платформы)

Вещественные числа: IEEE 754

Современные компьютеры представляют дробные числа в формате с плавающей запятой по стандарту IEEE 754.

Идея — нормализованная экспоненциальная запись:

\[a = \pm m \cdot 2^q\]

где m — мантисса, q — порядок (экспонента).

Структура float32 (single precision, 32 бита)

Поле Бит Назначение
знак 1 0 = положительное, 1 = отрицательное
смещённый порядок 8 q + 127
мантисса 23 дробная часть нормализованной записи (целая всегда 1, не хранится)

Пример: -25.625 в float32

  1. Перевод в двоичную: \(25 = 11001_2\), \(0.625 = 0.101_2\)\(-25.625_{10} = -11001.101_2\).
  2. Нормализация: \(-11001.101_2 = -1.1001101_2 \cdot 2^4\).
  3. Смещённый порядок: \(127 + 4 = 131 = 10000011_2\).
  4. Мантисса (без ведущей 1): 1001101 + нули до 23 бит.
знак порядок мантисса
1 10000011 10011010000000000000000

В hex: 0xC1CD0000.

Точность

float32 хранит примерно 7 значащих десятичных цифр, float64 — около 15-17. Это и причина классической проблемы:

0.1 + 0.1 + 0.1
# 0.30000000000000004 — не 0.3!

0.1 в двоичной системе — бесконечная периодическая дробь, которую обрезают. Накапливаются микроскопические ошибки.

Для денежных расчётов никогда не используйте float. Используйте:

  • Python: decimal.Decimal (точная десятичная арифметика), fractions.Fraction (точные рациональные числа).
  • Go: math/big.Float, shopspring/decimal (сторонняя библиотека).
from decimal import Decimal

Decimal("0.1") + Decimal("0.1") + Decimal("0.1")
# Decimal('0.3') — точно

Специальные значения IEEE 754

  • +0.0 и -0.0 — два нуля (равны при сравнении);
  • +Inf / -Inf — переполнение или деление ненулевого на ноль;
  • NaN (Not a Number) — результат 0/0, sqrt(-1), ...; не равно ни чему, включая себя:
import math

x = float("nan")
print(x == x)              # False!
print(math.isnan(x))       # True — правильная проверка

print(1.0 / 0)             # ZeroDivisionError в Python
print(float("inf"))        # inf
import "math"

n := math.NaN()
fmt.Println(n == n)         // false
fmt.Println(math.IsNaN(n))  // true

inf := math.Inf(1)          // +Inf

Кодировки текста

Цифры понятны: пишем в двоичной системе. А что делать с буквами и знаками препинания?

ASCII (1963)

American Standard Code for Information Interchange — 7-битная кодировка, 128 символов:

  • 0–31, 127 — управляющие (\n, \t, BEL, ...);
  • 32 — пробел;
  • 33–47, 58–64, 91–96, 123–126 — знаки пунктуации;
  • 48–57 — цифры 09 (важно: коды 0x300x39, младшие 4 бита = значение цифры);
  • 65–90 — AZ;
  • 97–122 — az.
ord("A")          # 65
ord("a")          # 97
ord("0")          # 48

chr(65)           # 'A'
chr(48)           # '0'

# Преобразование цифрового символа в число
int("5")          # 5
ord("5") - ord("0")  # 5

Кодовые страницы (8-bit)

ASCII занимает 7 бит, восьмой свободен — можно использовать для 128 дополнительных символов. Из этого выросли национальные кодировки:

  • CP866 — DOS-кириллица;
  • Windows-1251 — Windows-кириллица (актуальна для legacy-Windows);
  • KOI8-R — Unix-кириллица;
  • ISO-8859-1 (Latin-1) — западноевропейские.

Проблема: один и тот же байт 0xE0 — в windows-1251 это «а», в KOI-8 — другой символ. Тексты не переносятся без указания кодировки. Файл, открытый «не той» кодировкой, выглядит как «крокозябры».

Unicode

В начале 1990-х придумали Unicode — кодировка со всеми символами всех современных и многих мёртвых письменностей плюс математические, музыкальные и эмодзи. Каждый символ имеет уникальный код-поинт U+XXXX:

  • U+0041A (латинская);
  • U+0430а (кириллическая);
  • U+221A;
  • U+1F600 — 😀.

Сейчас в Unicode более 140 000 символов.

Представления Unicode: UTF-8, UTF-16, UTF-32

Unicode — это отображение «символ → число». Чтобы записать его в файл/память, нужно представление (encoding form):

Представление Размер символа Применение
UTF-8 1–4 байта Web, nix, Python source, JSON, де-факто стандарт*
UTF-16 2 или 4 байта Windows API, Java, JavaScript внутри движка
UTF-32 всегда 4 байта Редко — память расходуется неэффективно

UTF-8 — главный

  • ASCII-совместимость: первые 128 кодов кодируются одним байтом, идентично ASCII. Любой ASCII-файл уже валидный UTF-8.
  • Самосинхронизация: старшие биты байта говорят, начало это символа или продолжение.
  • Переменная длина: a — 1 байт, я — 2 байта, — 3 байта, эмодзи — 4 байта.
A     → 41           (1 байт)
я     → D1 8F        (2 байта)
中    → E4 B8 AD     (3 байта)
😀    → F0 9F 98 80  (4 байта)

Из-за переменной длины число символов в строке не равно числу байт:

s = "Привет"
len(s)              # 6 — символов
len(s.encode("utf-8"))  # 12 — байт

Big-endian и little-endian

UTF-16 и UTF-32 могут хранить старший байт «слева» (BE) или «справа» (LE). Чтобы файл можно было прочитать, в начале ставят BOM (Byte Order Mark): 0xFEFF для BE, 0xFFFE для LE.

UTF-8 в BOM не нуждается (один байт = одна единица), но Windows-приложения иногда добавляют EF BB BF в начало — это «UTF-8 BOM», часто создающий проблемы парсерам.

Текст и байты в Python

В Python 3 строгое разделение:

  • str — Unicode-строка, абстракция «последовательность символов»;
  • bytes — последовательность байт, абстракция «сырые данные»;
  • bytearray — изменяемый bytes;
  • memoryview — «окно» в чужие байты без копирования.

strbytes

s = "Привет"
b = s.encode("utf-8")        # b'\xd0\x9f\xd1\x80\xd0\xb8\xd0\xb2\xd0\xb5\xd1\x82'

text = b.decode("utf-8")     # "Привет"

# С обработкой ошибок
s.encode("ascii")                       # UnicodeEncodeError
s.encode("ascii", errors="ignore")      # b'' — потерянно
s.encode("ascii", errors="replace")     # b'??????' — заменили
s.encode("ascii", errors="backslashreplace")  # b'\\u041f...'

Главное правило: в коде программы — всегда str (Unicode), на границах (файлы, сеть) — bytes.

Литералы

# str
s1 = "обычная"
s2 = "с экранированием П"   # символ Unicode по коду
s3 = r"raw\nстрока"               # обратный слэш не экранируется

# bytes — префикс b
b1 = b"ascii bytes"
b2 = b"\xd0\x9f"               # сырые байты
b3 = "Привет".encode("utf-8")

bytes: операции

b = b"\x48\x65\x6c\x6c\x6f"     # b'Hello'

len(b)            # 5
b[0]              # 72 — целое (значение байта), не bytes!
b[0:3]            # b'Hel' — срез возвращает bytes
b.hex()           # '48656c6c6f'

bytes.fromhex("48656c6c6f")     # b'Hello'

# Из списка целых
bytes([72, 101, 108, 108, 111]) # b'Hello'

bytearray: изменяемые байты

ba = bytearray(b"hello")
ba[0] = ord("H")      # bytearray(b'Hello') — можно менять
ba.append(0x21)       # bytearray(b'Hello!')

Удобен для построения буферов, разбора сетевых пакетов.

memoryview: «окно» без копирования

Срезы bytes создают копию — для гигабайтных данных это дорого. memoryview даёт срезы и индексацию без копирования.

import time

# Замедленная версия — копирование на каждом срезе
data = b"x" * 1_000_000
start = time.time()
b = data
while b:
    b = b[1:]    # каждый раз — новый bytes длиной N-1
print("bytes:", time.time() - start)
# ~5 секунд

# Быстрая версия — memoryview
data = b"x" * 1_000_000
start = time.time()
b = memoryview(data)
while b:
    b = b[1:]   # «окно» сдвигается, копия не делается
print("memoryview:", time.time() - start)
# ~0.05 секунд

memoryview поддерживает протокол buffer — работает с bytes, bytearray, array.array, numpy.ndarray. Полезен для интероп с C-библиотеками.

Текст и байты в Go

В Go строка — это неизменяемая последовательность байт, обычно в кодировке UTF-8 (но язык это не гарантирует).

string и []byte

s := "Привет"
fmt.Println(len(s))   // 12 — БАЙТ, не символов
fmt.Println(s[0])     // 208 — байт, не код символа

// Преобразования
b := []byte(s)        // строка → байты (копия)
s2 := string(b)       // байты → строка (копия)

// Hex
fmt.Printf("%x\n", b)  // d09fd180d0b8d0b2d0b5d182

rune — Unicode code point

import "fmt"

s := "Привет"

// for range по строке — итерация по рунам
for i, r := range s {
    fmt.Printf("%d: %c (%U)\n", i, r, r)
}
// 0: П (U+041F)
// 2: р (U+0440)
// 4: и (U+0438)
// ...
// Индекс i — позиция в БАЙТАХ, не в символах.

// Преобразование в []rune
runes := []rune(s)
fmt.Println(len(runes))  // 6 — количество символов

rune — это просто алиас int32.

Подсчёт «символов» правильно

import (
    "fmt"
    "unicode/utf8"
)

s := "Привет"
fmt.Println(utf8.RuneCountInString(s))  // 6

Перекодирование

В стандартной библиотеке golang.org/x/text/encoding (расширенная):

import (
    "golang.org/x/text/encoding/charmap"
)

decoder := charmap.Windows1251.NewDecoder()
utf8Bytes, _ := decoder.Bytes([]byte{0xCF, 0xF0, 0xE8, 0xE2, 0xE5, 0xF2})
// "Привет" в UTF-8

Сравнение Python ↔ Go: текст и байты

Аспект Python Go
Тип строки str — Unicode-абстракция string — байты (обычно UTF-8)
Индексация по символам по байтам
Длина по символам по байтам
Перебор по символам по рунам через for range
Сырые байты bytes, bytearray []byte
Преобразование s.encode(enc) / b.decode(enc) []byte(s) / string(b) (только UTF-8)
Неизменяемый str, bytes string
Изменяемый bytearray []byte
Без копирования memoryview срезы (но обычно тоже копируют для безопасности)
Расширенные кодировки в стандартной библиотеке (codecs) golang.org/x/text/encoding

Подводные камни

  • «Длина строки» — почти всегда подразумевается «число символов», но len() в Go возвращает число байт. На кириллице это другое.
  • Файлы без указания кодировки — открытый «как есть» файл может оказаться в windows-1251. Всегда указывайте encoding="utf-8".
  • BOM в UTF-8 — Excel и некоторые Windows-программы добавляют EF BB BF в начало файла. Python open(... encoding="utf-8") это не обрабатывает — используйте encoding="utf-8-sig".
  • Сравнение float через == — почти всегда ошибка. Используйте math.isclose(a, b).
  • Деньги во float — никогда. Decimal.

Контрольные вопросы

  • Чем дополнительный код отрицательного числа отличается от прямого, и зачем он нужен?
  • Что произойдёт при int8(127) + 1 в Go? А в Python?
  • Почему 0.1 + 0.2 != 0.3, и какие есть способы избежать этой проблемы?
  • В чём разница между Unicode (как стандарт) и UTF-8 (как представление)?
  • Сколько байт занимает буква «П» в UTF-8? А в UTF-16?
  • Почему len("Привет") в Python даёт 6, а в Go — 12?
  • Что такое rune в Go и чем отличается от byte?
  • Когда стоит применять memoryview в Python?
  • Чем bytes отличается от bytearray?
  • Что такое BOM и почему он создаёт проблемы?