Основы Computer Science Представление данных: целые, дробные (IEEE 754), текст (Unicode), цвет, звук
0%

Представление данных: целые, дробные (IEEE 754), текст (Unicode), цвет, звук

Представление данных: целые, дробные (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². Число хранится тремя полями: знак, порядок (показатель степени двойки) и мантисса (значащие цифры). Плавает именно точка — отсюда название.

Раскладка бит IEEE 754 single для числа −6.25

Для 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 решил проблему радикально, разведя два понятия, которые кодовые страницы смешивали:

  1. Код-поинт (code point) — абстрактный номер символа в едином всемирном реестре, например U+0041 = A, U+044F = я, U+1F600 = 😀. Всего пространство — свыше миллиона позиций.
  2. Кодировка (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. Звук: как непрерывную волну упаковать в биты

Звук физически — непрерывная волна давления воздуха. Компьютер же оперирует дискретными числами. Значит, волну нужно оцифровать — заменить конечным набором замеров. Это делается в два независимых шага.

Оцифровка звука: дискретизация по времени и квантование по амплитуде

  1. Дискретизация (sampling) — измеряем амплитуду волны через равные промежутки времени. Сколько раз в секунду — это частота дискретизации (sample rate). Аудио-CD использует 44 100 Гц: 44 100 замеров в секунду.
  2. Квантование (quantization) — каждый замер округляем до ближайшего из фиксированного набора уровней. Сколько уровней — это битность (bit depth): 16 бит дают 65 536 уровней громкости.

Результат — просто поток целых чисел, называемый PCM (Pulse-Code Modulation). Дальше его можно сжать.

Насколько точно цифра передаёт оригинал? Ответ даёт теорема Котельникова—Найквиста: чтобы без потерь восстановить сигнал, частота дискретизации должна быть минимум вдвое выше самой высокой частоты в звуке. Человек слышит примерно до 20 кГц — отсюда и берётся 44.1 кГц у CD (с запасом на фильтры). Возьмёте частоту ниже нужной — высокие частоты не просто пропадут, а «завернутся» в ложные низкие: это искажение называют алиасингом.

Прикинуть объём легко: 44100 замеров × 2 байта × 2 канала (стерео) ≈ 176 КБ в секунду, то есть больше 10 МБ на минуту несжатого звука. Поэтому и здесь работают форматы: FLAC сжимает без потерь, MP3 и AAC — с потерями, выбрасывая то, что маскируется для слуха (психоакустика). Битность и частота — это те самые протечки: слишком грубое квантование слышно как шипение (шум квантования), а недостаточная частота — как «песок» алиасинга.


6. Общий вывод: данные = байты + договорённость

Пройдя все слои, легко увидеть единый узор. Что целое число, что буква, что оттенок синего, что миллисекунда музыки — всё это байты плюс договорённость, как их читать. Отличается только словарь: для чисел — «дополнительный код» и «IEEE 754», для текста — «UTF-8», для цвета — «RGBA», для звука — «16-bit PCM».

Отсюда — практический способ думать о любых данных и о любом связанном с ними баге:

Байты нейтральны. Смысл возникает только в момент интерпретации, и почти каждая «загадочная порча данных» — это несовпадение договорённостей записи и чтения. Отсюда золотое правило инженера: храните и передавайте данные вместе с явным описанием их типа и кодировки — будь то заголовок charset, схема БД, сигнатура файла (magic bytes) или тип столбца.

Мини-итог

  • Биты не несут смысла; смысл задаёт тип/кодировка — договорённость об интерпретации.
  • Целые ограничены шириной → переполнение; отрицательные хранят в дополнительном коде.
  • IEEE 754 (float) торгует точностью на диапазон: 0.1 + 0.2 ≠ 0.3 — не баг, а природа формата. Деньги — в целых или Decimal.
  • Текст: код-поинт (Unicode) и его байты (UTF-8) — разные уровни; нормализуйте и всегда знайте кодировку.
  • Цвет — RGB(A)-числа на пиксель; звук — PCM после дискретизации и квантования.
  • Каждая абстракция протекает — знать, где именно, важнее, чем знать её наизусть.

За тем, как эти представления эффективно хранят в структурах данных, — трек структур данных; за строгой стороной чисел — математика.


Источники


Что дальше

Мы разобрались, как данные превращаются в биты. Следующий вопрос — чем компьютер эти биты обрабатывает. Оказывается, всё вычисление собирается из горстки простейших логических операций над нулями и единицами.

Булева логика и логические вентили: из чего собран процессор — как из транзисторов рождаются вентили AND, OR, NOT, как из них собирают сумматор, который складывает те самые целые числа, и почему вся цифровая техника стоит на алгебре Буля.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов