Представление данных: целые, дробные (IEEE 754), текст (Unicode), цвет, звук
В предыдущей статье трека — Двоичная система, биты и байты — мы выяснили, что внутри компьютера нет ничего, кроме двух состояний: есть заряд или нет, включено или выключено. Всё, что вы видите на экране — этот текст, фотография кота, песня, банковский баланс — это одна и та же субстанция: последовательности бит.
Возникает честный вопрос: если внутри всё одинаково, откуда компьютер знает, что одни биты — это число, а другие — буква «Ж»?
Ответ, который стоит запомнить на весь курс: сам по себе — не знает. Биты не несут смысла. Смысл живёт в договорённости о том, как их читать. Эту договорённость называют типом, кодировкой или форматом. Представление данных — это и есть набор таких договорённостей: правил, по которым кусок памяти превращается в число, символ или пиксель, и обратно.
Посмотрите на одни и те же 32 бита 0x41424344, прочитанные по разным договорённостям:
Те же 4 байта 41 42 43 44 |
Договорённость | Значение |
|---|---|---|
| как беззнаковое 32-битное целое | uint32 |
1094861636 |
| как знаковое 32-битное целое | int32 |
1094861636 |
| как число с плавающей точкой | float32 (IEEE 754) |
12.1414... |
| как четыре ASCII-символа | текст | "ABCD" |
Ни одно из прочтений не «правильнее» другого. Правильное — то, которое имел в виду тот, кто эти байты записал. Отсюда весь класс багов «испорченных данных»: приложение записало байты по одной договорённости, а прочитало по другой.
Дальше мы пройдёмся по главным семействам данных снизу вверх — от целых чисел до звука — и для каждого разберём договорённость и её протечки.
1. Целые числа: позиционная запись и её пределы
С беззнаковыми (неотрицательными) целыми всё просто — это прямая позиционная запись в двоичной системе, та же логика, что и в десятичной, только основание 2:
1 0 1 1 0 1 0 0 (двоичное)
128 64 32 16 8 4 2 1 (веса разрядов)
= 128 + 32 + 16 + 4 = 180
Восемь бит — один байт — вмещают числа от 0 до 255 (это 2^8 − 1). Отсюда фиксированные размеры типов, которые вы встречаете везде:
| Ширина | Беззнаковый диапазон | Знаковый диапазон |
|---|---|---|
8 бит (uint8/int8) |
0 … 255 | −128 … 127 |
| 16 бит | 0 … 65 535 | −32 768 … 32 767 |
| 32 бита | 0 … ≈4.29 млрд | −2.15 млрд … 2.15 млрд |
| 64 бита | 0 … ≈1.8 × 10¹⁹ | ≈ ±9.2 × 10¹⁸ |
Число не помещается в свою ширину — старшие биты просто отбрасываются. Это переполнение (overflow), и это первая крупная протечка абстракции «целое число». Математическое целое бесконечно; машинное — живёт по модулю 2^n. Знаменитый пример: индикатор пройденного расстояния в старой Nintendo или «−32 768 очков» после 32 767 — это выход за границу 16-битного знакового типа.
Как хранят отрицательные числа: дополнительный код
Наивная идея — отвести один бит под знак («0 = плюс, 1 = минус») — работает, но плоха: получаются два нуля (+0 и −0), и сложение перестаёт быть единообразным. Поэтому почти все процессоры используют дополнительный код (two’s complement).
Правило: чтобы получить отрицание числа, инвертируй все биты и прибавь 1.
6 = 0000 0110
инверсия 1111 1001
+ 1 = 1111 1010 → это −6 в 8-битном дополнительном коде (0xFA)
Красота дополнительного кода в том, что вычитание становится сложением, а схема сумматора работает одинаково для знаковых и беззнаковых чисел — процессору не нужен отдельный блок для отрицательных. Старший бит при этом заодно играет роль знака (1 → число отрицательное). Проверить в Python:
# Как -6 выглядит в 8 битах дополнительного кода
print(format(-6 & 0xFF, "08b")) # '11111010'
Из-за несимметрии (один лишний код уходит под ноль) отрицательных значений на одно больше, чем положительных: у int8 есть −128, но нет +128. Отсюда классическая ловушка: abs(-128) в 8-битной арифметике снова даёт −128.
Порядок байтов
Число шире одного байта нужно как-то разложить по последовательным адресам памяти, и тут архитектуры расходятся: x86 и ARM пишут младший байт первым (little-endian), сетевые протоколы — старший (big-endian). Это отдельная тема, подробно разобранная в статье про биты и байты; здесь достаточно помнить, что байты одного и того же числа могут лежать в памяти «задом наперёд», и при обмене данными между системами об этом приходится договариваться явно.
За строгими доказательствами свойств позиционных систем и модульной арифметики — в трек по математике.
2. Дробные числа: IEEE 754 и почему 0.1 + 0.2 ≠ 0.3
Целые кончаются там, где начинается 1.5 или 3.14. Как записать дробь в двоичном виде?
Простейший подход — фиксированная точка (fixed-point): договориться, что, скажем, последние 8 бит — это дробная часть. Быстро и точно, но диапазон жёстко зажат: одними и теми же битами не выразить и 0.0001, и 1 000 000.0. Поэтому для универсальных вычислений победил другой формат — плавающая точка, стандартизованный как IEEE 754 (стандарт 1985 года, действующая редакция — 2019).
Идея — та же, что у научной записи −6.25 = −1.5625 × 2². Число хранится тремя полями: знак, порядок (показатель степени двойки) и мантисса (значащие цифры). Плавает именно точка — отсюда название.
Для 32-битного float (single precision) это 1 + 8 + 23 бита. Значение восстанавливается по формуле:
$$v = (-1)^s \times (1 + f) \times 2^{e - 127}$$
где s — бит знака, f — мантисса как дробь, e — сохранённый порядок, а 127 — смещение (bias), позволяющее хранить и отрицательные степени (для double смещение — 1023). Ведущая единица 1 + не хранится — она всегда подразумевается, что даёт лишний бит точности бесплатно.
Проверим на нашем −6.25:
import struct
# Упаковать float в 4 байта (big-endian) и посмотреть на биты
print(struct.pack(">f", -6.25).hex()) # 'c0c80000'
# c0c80000 = 1 10000001 10010000000000000000000
# знак=− порядок=129−127=2 мантисса=1.5625
# (−1) * 1.5625 * 2**2 = -6.25
Главная протечка: не все дроби представимы
Вот тот самый пример, который в первый раз шокирует каждого:
>>> 0.1 + 0.2
0.30000000000000004
>>> 0.1 + 0.2 == 0.3
False
Это не баг Python и не ошибка процессора. Причина фундаментальна: 0.1 в двоичной системе — бесконечная периодическая дробь, ровно как 1/3 = 0.333... в десятичной. В конечное число бит она не влезает, её приходится округлять, и накопленная ошибка вылезает в последнем разряде. Любой язык на IEEE 754 (а это практически все) ведёт себя так же.
Практические выводы, которые экономят часы отладки:
- Никогда не сравнивайте float на точное равенство. Сравнивайте с допуском:
abs(a - b) < epsилиmath.isclose(a, b). - Деньги не храните во float.
0.1 + 0.2в бухгалтерии недопустимо. Используйте целые (сумма в копейках/центах) или десятичный тип (decimal.Decimal,NUMERICв СУБД). - Порядок операций влияет на результат. Сложение float не ассоциативно:
(a + b) + cможет отличаться отa + (b + c). При суммировании большого массива это накапливается.
import math
math.isclose(0.1 + 0.2, 0.3) # True — вот так сравнивать правильно
Специальные значения
IEEE 754 резервирует комбинации бит под особые случаи, и знать о них полезно:
+Inf/−Inf— результат переполнения или деления на ноль (1.0 / 0.0).NaN(Not a Number) — результат неопределённых операций (0.0 / 0.0,sqrt(-1)). Коварство:NaN != NaNвозвращаетTrue, то естьNaNне равен даже самому себе. Проверка «значение испортилось» — этоx != x.−0.0— да, у нуля есть знак;+0.0 == −0.0, но1/+0.0даёт+Inf, а1/−0.0—−Inf.
Практическая шпаргалка: точность float32 — около 7 значащих десятичных цифр, float64 (double) — около 15–16. Как только вам нужно больше или требуется абсолютная точность (криптография, финансы, идентификаторы) — плавающая точка не ваш инструмент. Обязательный к прочтению текст — «What Every Computer Scientist Should Know About Floating-Point Arithmetic» (Goldberg, 1991).
3. Текст: от ASCII до Unicode
Число превратить в биты естественно. А букву? Только договорённостью: «пусть код 65 означает A». Такую таблицу «символ ↔ число» называют кодировкой.
ASCII (1963) отвёл 7 бит под 128 символов: латиница, цифры, знаки препинания, управляющие коды. Для английского хватало. Но 128 значений — это ни кириллицы, ни китайского, ни даже французских акцентов.
Ответом 1980-х стали кодовые страницы: восьмой бит добавил ещё 128 позиций, и каждый язык набил их своими символами. CP1251 и KOI8-R для кириллицы, Latin-1 для западной Европы. Беда: один и тот же байт 0xE9 в одной странице — é, в другой — й. Открыли файл не в той кодировке — и вместо текста «крокозябры» (это явление называют mojibake). Каждый, кто застал ту эпоху, помню письма из нечитаемых символов.
Unicode: разделить символ и его биты
Unicode решил проблему радикально, разведя два понятия, которые кодовые страницы смешивали:
- Код-поинт (code point) — абстрактный номер символа в едином всемирном реестре, например
U+0041=A,U+044F=я,U+1F600= 😀. Всего пространство — свыше миллиона позиций. - Кодировка (encoding) — правило, как этот номер разложить в байты. Unicode-реестр и его байтовое представление — разные вещи.
Самая распространённая кодировка — UTF-8 (RFC 3629). Она переменной длины: символ занимает от 1 до 4 байт, и число байт закодировано в старших битах первого байта. Гениальность в обратной совместимости: первые 128 код-поинтов кодируются одним байтом, идентичным ASCII, поэтому любой ASCII-файл уже является валидным UTF-8.
На практике важно не путать три уровня — байты, код-поинты и то, что человек считает «символом»:
s = "café"
len(s) # 4 — столько код-поинтов
s.encode("utf-8") # b'caf\xc3\xa9' — 5 байт (é занимает 2 байта)
len(s.encode("utf-8")) # 5
Есть и UTF-16 (по 2 или 4 байта на символ) — им пользуются внутри Windows, Java и JavaScript. Отсюда, кстати, знаменитый факт, что в JS "😀".length === 2: эмодзи не влезает в 16 бит и кодируется «суррогатной парой». А ещё UTF-16 многобайтен и потому чувствителен к порядку байтов — файлы иногда начинаются со специального маркера BOM.
Протечки текста, о которых надо знать
- Длина строки неоднозначна. «Длина» в байтах, в код-поинтах и в видимых символах — три разных числа. Флаг 🇷🇺 — это два код-поинта (
len("🇷🇺") == 2в Python), а на экране — один значок. Единица «то, что человек воспринимает как символ» называется графемным кластером, и для него нужны отдельные библиотеки. - Один и тот же текст — разные байты. Символ
éбывает записан как один код-поинтU+00E9или какe+ комбинирующий акцентU+0301. Выглядят одинаково, а не равны:
import unicodedata
a = "\u00e9" # é одним код-поинтом U+00E9
b = "e\u0301" # 'e' + комбинирующий акцент U+0301
a == b # False!
unicodedata.normalize("NFC", a) == unicodedata.normalize("NFC", b) # True
Поэтому перед сравнением, поиском или хранением текст нормализуют (формы NFC/NFD). Забыть об этом — значит получить баг «пользователь не может залогиниться, хотя пароль верный».
- Кодировку надо знать всегда. Байты
\xc3\xa9— этоéв UTF-8 иéв Latin-1. Универсальное правило: считываете байты — узнайте их кодировку; отдаёте текст — объявите её (Content-Type: text/html; charset=utf-8). Обязательная классика на эту тему — «The Absolute Minimum Every Software Developer Must Know About Unicode» (Joel Spolsky, 2003).
4. Цвет: пиксель — это несколько чисел
Изображение — это сетка пикселей, а цвет каждого пикселя — снова числа. Доминирующая договорённость — RGB: любой цвет раскладывается на интенсивность красного, зелёного и синего каналов. Обычно по 8 бит на канал, то есть 0…255 на цвет:
# HEX-цвет из вёрстки — это просто три байта RGB
def hex_to_rgb(h):
h = h.lstrip("#")
return tuple(int(h[i:i+2], 16) for i in (0, 2, 4))
hex_to_rgb("#4f8fbf") # (79, 143, 191) — R=79, G=143, B=191
Три канала по 8 бит — это 24 бита на пиксель и 256³ ≈ 16.7 млн цветов («truecolor»). Часто добавляют четвёртый канал — альфа (прозрачность), получается RGBA, 32 бита на пиксель. Байты пикселей идут подряд: картинка 1920×1080 в RGBA — это 1920 × 1080 × 4 ≈ 8.3 МБ без сжатия. Именно поэтому существуют форматы: PNG сжимает без потерь, JPEG — с потерями, отбрасывая детали, которые глаз почти не замечает.
Протечки, которые всплывают в реальной работе:
- Гамма и линейность. Значение
128в байте — это не «половина яркости» физически. Мониторы отображают цвет нелинейно (гамма-коррекция), поэтому наивное усреднение или размытие в «сыром» пространстве даёт визуально неверный результат — корректно это делать в линейном пространстве. - RGB — не единственная модель. Для печати используют CMYK (вычитание красок), для подбора цвета удобнее HSL/HSV (тон-насыщенность-яркость). Это разные договорённости об одних и тех же пикселях, и перевод между ними не всегда точен.
- Порядок каналов не универсален. Одни библиотеки ждут RGB, другие (например, OpenCV) — BGR. Перепутали — и синее небо становится оранжевым.
5. Звук: как непрерывную волну упаковать в биты
Звук физически — непрерывная волна давления воздуха. Компьютер же оперирует дискретными числами. Значит, волну нужно оцифровать — заменить конечным набором замеров. Это делается в два независимых шага.
- Дискретизация (sampling) — измеряем амплитуду волны через равные промежутки времени. Сколько раз в секунду — это частота дискретизации (sample rate). Аудио-CD использует 44 100 Гц: 44 100 замеров в секунду.
- Квантование (quantization) — каждый замер округляем до ближайшего из фиксированного набора уровней. Сколько уровней — это битность (bit depth): 16 бит дают 65 536 уровней громкости.
Результат — просто поток целых чисел, называемый PCM (Pulse-Code Modulation). Дальше его можно сжать.
непрерывная волна"] --> B["Дискретизация
N замеров в секунду"] B --> C["Квантование
округление до уровня"] C --> D["PCM
поток целых чисел"] D --> E["Сжатие
MP3 · AAC · FLAC"]
Насколько точно цифра передаёт оригинал? Ответ даёт теорема Котельникова—Найквиста: чтобы без потерь восстановить сигнал, частота дискретизации должна быть минимум вдвое выше самой высокой частоты в звуке. Человек слышит примерно до 20 кГц — отсюда и берётся 44.1 кГц у CD (с запасом на фильтры). Возьмёте частоту ниже нужной — высокие частоты не просто пропадут, а «завернутся» в ложные низкие: это искажение называют алиасингом.
Прикинуть объём легко: 44100 замеров × 2 байта × 2 канала (стерео) ≈ 176 КБ в секунду, то есть больше 10 МБ на минуту несжатого звука. Поэтому и здесь работают форматы: FLAC сжимает без потерь, MP3 и AAC — с потерями, выбрасывая то, что маскируется для слуха (психоакустика). Битность и частота — это те самые протечки: слишком грубое квантование слышно как шипение (шум квантования), а недостаточная частота — как «песок» алиасинга.
6. Общий вывод: данные = байты + договорённость
Пройдя все слои, легко увидеть единый узор. Что целое число, что буква, что оттенок синего, что миллисекунда музыки — всё это байты плюс договорённость, как их читать. Отличается только словарь: для чисел — «дополнительный код» и «IEEE 754», для текста — «UTF-8», для цвета — «RGBA», для звука — «16-bit PCM».
Отсюда — практический способ думать о любых данных и о любом связанном с ними баге:
по договорённости A"] --> S["Байты в памяти
или файле — без метки типа"] S --> R["Кто-то читает байты
по договорённости B"] R --> Q{"A == B?"} Q -->|"да"| OK["Данные корректны"] Q -->|"нет"| BUG["Порча: mojibake,
переполнение, битый float"]
Байты нейтральны. Смысл возникает только в момент интерпретации, и почти каждая «загадочная порча данных» — это несовпадение договорённостей записи и чтения. Отсюда золотое правило инженера: храните и передавайте данные вместе с явным описанием их типа и кодировки — будь то заголовок charset, схема БД, сигнатура файла (magic bytes) или тип столбца.
Мини-итог
- Биты не несут смысла; смысл задаёт тип/кодировка — договорённость об интерпретации.
- Целые ограничены шириной → переполнение; отрицательные хранят в дополнительном коде.
- IEEE 754 (float) торгует точностью на диапазон:
0.1 + 0.2 ≠ 0.3— не баг, а природа формата. Деньги — в целых илиDecimal. - Текст: код-поинт (Unicode) и его байты (UTF-8) — разные уровни; нормализуйте и всегда знайте кодировку.
- Цвет — RGB(A)-числа на пиксель; звук — PCM после дискретизации и квантования.
- Каждая абстракция протекает — знать, где именно, важнее, чем знать её наизусть.
За тем, как эти представления эффективно хранят в структурах данных, — трек структур данных; за строгой стороной чисел — математика.
Источники
- David Goldberg — What Every Computer Scientist Should Know About Floating-Point Arithmetic (1991).
- IEEE 754 — Wikipedia и стандарт IEEE 754-2019.
- Joel Spolsky — The Absolute Minimum Every Software Developer Must Know About Unicode (2003).
- The Unicode Standard и RFC 3629 — UTF-8.
- Two’s complement — Wikipedia.
- Nyquist–Shannon sampling theorem — Wikipedia.
Что дальше
Мы разобрались, как данные превращаются в биты. Следующий вопрос — чем компьютер эти биты обрабатывает. Оказывается, всё вычисление собирается из горстки простейших логических операций над нулями и единицами.
Булева логика и логические вентили: из чего собран процессор — как из транзисторов рождаются вентили AND, OR, NOT, как из них собирают сумматор, который складывает те самые целые числа, и почему вся цифровая техника стоит на алгебре Буля.