7. Rune, Byte и Unicode в Go

Если вы пришли в Go из мира C или C++, ваша интуиция подсказывает, что любой текстовый символ — это скромный char, занимающий ровно 1 байт. Если ваш бэкграунд связан с Java или C#, то char для вас — это 2 байта (наследие эпохи кодировки UTF-16 и ранней иллюзии, будто 65 536 значений хватит человечеству на все письменности мира).

В Go типа char нет вовсе. И это не прихоть, а следствие инженерного триумфа. Двое из трёх создателей языка — Роб Пайк (Rob Pike) и Кен Томпсон (Ken Thompson); третий — Роберт Гризмер (Robert Griesemer) — это те самые инженеры Bell Labs, которые однажды вечером в сентябре 1992 года, за ужином в нью-джерсийском дайнере, набросали на бумажной подложке под тарелку кодировку UTF-8 (в ту же ночь Томпсон написал код упаковки и распаковки). Они создали стандарт, завоевавший всю глобальную сеть.

Поэтому поддержка UTF-8 вшита в саму ДНК языка Go. Однако чтобы свободно говорить на этом языке и не совершать фатальных ошибок в высоконагруженных сетевых сервисах, бэкенд-разработчик обязан четко разграничивать два фундаментальных понятия: сырое физическое хранение последовательности байт в памяти (byte) и логическое восприятие символа человеком (rune).


Истинное лицо: byte и rune

В отличие от языков со сложной иерархией типов, в Go byte и rune не являются скрытыми или тяжелыми структурами данных. Это просто встроенные синтаксические псевдонимы (type aliases) для базовых целочисленных примитивов:

Go
type byte = uint8   // Занимает 1 байт (диапазон значений от 0 до 255)
type rune = int32   // Занимает 4 байта (диапазон значений от -2^31 до 2^31-1)

Почему rune — это int32?

Современный стандарт Unicode объединяет все письменности планеты, математические символы, древние иероглифы и пиктограммы — всего свыше 140 000 символов. Каждому графическому элементу присвоен уникальный числовой идентификатор — Code Point (кодовая точка):

  • Латинская буква A — это кодовая точка U+0041.
  • Кириллическая буква П — это кодовая точка U+041F.
  • Земной шар 🌍 — это кодовая точка U+1F30D.

Максимально возможное значение кодовой точки в стандарте Unicode ограничено 0x10FFFF, что требует ровно 21 бита памяти. Поскольку процессоры не оперируют 21-битными регистрами, ближайшим стандартным типом, куда гарантированно и без потерь помещается любая кодовая точка человечества, является 32-битное целое число со знаком. Именно поэтому rune (руна) объявлена как синоним int32.


Строка — это массив байт, а не рун

Главный постулат рантайма Go звучит так: строка (string) — это неизменяемая последовательность байт (byte), а не рун (rune). В отличие от среза, у строки нет ёмкости cap: только указатель и длина.

Строка не содержит метаданных о кодировке. На физическом уровне строка — это структура из двух машинных слов (указатель на массив байтов и число байтов). Она абсолютно индифферентна к тому, что в ней лежит. Однако компилятор Go и соглашения экосистемы предполагают, что исходный текст программ и строковые литералы представлены в кодировке UTF-8.

В стандарте UTF-8 кодирование является переменным по длине (variable-length) — символ занимает от 1 до 4 байт:

  • 1 байт: Набор ASCII (английские буквы, цифры, знаки препинания) — коды 0x00..0x7F.
  • 2 байта: Кириллица, латынь с диакритикой, греческий, арабский, иврит.
  • 3 байта: Большинство азиатских иероглифов (Китай, Япония, Корея).
  • 4 байта: Эмодзи, редкие исторические алфавиты и специальные графические символы.

Ловушка длины строки: len()

Из-за динамической природы длины символов встроенная функция len() в Go работает не так, как привыкли программисты на Python (len()) или JavaScript (length): она возвращает количество физических байт в памяти, а вовсе не количество видимых символов!

Go
package main

import "fmt"

func main() {
    s1 := "Hello"
    fmt.Println(len(s1)) // Выведет: 5 (5 букв * 1 байт)

    s2 := "Привет"
    fmt.Println(len(s2)) // Выведет: 12! (6 букв * 2 байта)

    s3 := "Go🌍"
    fmt.Println(len(s3)) // Выведет: 6 (2 ASCII байта + 4 байта эмодзи)
}

Итерация по строке: for vs range

Поскольку строка состоит из байтов, способ ее обхода фундаментально определяет результат вычислений.

Подход 1: Обход по байтам (классический for)

Если применить классический цикл со счетчиком и индексацией s[i], программа будет читать сырые байты из памяти один за другим:

Go
s := "Код"
for i := 0; i < len(s); i++ {
    fmt.Printf("%x ", s[i]) 
}
// Выведет: d0 9a d0 be d0 b4 (6 байт, представляющих 3 кириллические буквы)

Каждая кириллическая буква распалась на два независимых байта. Попытка распечатать s[i] через спецификатор %c выведет бессмыслицу: отдельный байт трактуется как кодовая точка, и 0xD0 напечатается как Ð (U+00D0).

Подход 2: Декодирование на лету (for ... range)

Когда вы используете синтаксис for ... range для строки, компилятор и рантайм Go генерируют код, который на лету декодирует последовательности UTF-8 в полноценные руны (rune):

Go
s := "Go🌍"
for i, r := range s {
    fmt.Printf("Индекс: %d, Руна: %c\n", i, r)
}

Mechanical Sympathy: Как работает декодирование UTF-8

Каким образом Go понимает, сколько байтов нужно поглотить для декодирования текущей руны? Декодирование программное (utf8.DecodeRune, в цикле range — runtime.decoderune) с быстрым путём для ASCII. Секрет заключается в изяществе битовой структуры стандарта UTF-8, придуманной Кеном Томпсоном:

flowchart LR
    classDef process  fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
    classDef success  fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
    classDef error    fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
    classDef warning  fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
    classDef entry    fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
    classDef aux      fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
    classDef system   fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
    classDef data     fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
    classDef network  fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
    classDef accent   fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
    Byte1["Байт в памяти"]:::warning --> Check{"Анализ префикса"}
    Check -->|"Начинается с 0"| ASCII["1-байтный ASCII<br>0xxxxxxx"]
    Check -->|"Начинается с 110"| Cyrillic["2-байтная руна<br>110xxxxx 10xxxxxx"]
    Check -->|"Начинается с 1110"| Asian["3-байтная руна<br>1110xxxx 10xxxxxx..."]
    Check -->|"Начинается с 11110"| Emoji["4-байтная руна<br>11110xxx 10xxxxxx..."]
  

Декодер с помощью битовых масок проверяет старшие биты первого байта:

  1. Если старший бит равен 0 (маска 0b10000000 == 0), то перед нами классический 7-битный ASCII символ. Длина — 1 байт.
  2. Если старшие биты 110, то это двухбайтовая последовательность. За ней следует ровно один байт продолжения, начинающийся с 10.
  3. Префикс 1110 сигнализирует о трехбайтовой руне.
  4. Префикс 11110 задает четырехбайтовую руну.

Если в памяти встречается поврежденная последовательность или недопустимый префикс (например, байт продолжения 10xxxxxx без начального байта), декодер Go не паникует. Он подставляет специальный символ замены Unicode \uFFFD (символ — Replacement Character, занимающий 3 байта в UTF-8), продвигает счетчик на 1 байт и мужественно продолжает разбор. Невалидными считаются также байты 0xC0, 0xC1, 0xF5–0xFF, избыточно длинные (overlong) кодировки и суррогаты U+D800–U+DFFF: для любой такой последовательности декодер возвращает utf8.RuneError (U+FFFD) и сдвигается на 1 байт.


Срезы (Slicing) строк ломают UTF-8

Классический подводный камень для новичков — попытка взять подстроку с помощью стандартного оператора среза s[start:end].

Оператор среза оперирует исключительно сырыми байтами, не зная ничего о границах символов UTF-8:

Go
s := "Привет"
fmt.Println(s[0:2]) // Ожидаем "Пр", получаем "П" (т.к. "П" занимает 2 байта)
fmt.Println(s[0:1]) // Ожидаем "П", получаем одиночный байт 0xD0 (терминал покажет � или мусор)

Разрезав двухбайтовый символ кириллицы ровно посередине (s[0:1]), мы получили одиночный байт 0xD0. При попытке распечатать его в терминале вывод исказится, либо терминал отобразит знак вопроса.

Как безопасно отрезать символы?

1. Наивный путь (аллокация в куче):

Go
// Приведение строки к слайсу рун.
// Внимание: проходит всю строку (O(N)) и, если результат убегает или велик,
// выделяет память в куче под массив int32!
runes := []rune("Привет") 
res := string(runes[0:3]) // "При"

Такой подход прост и безопасен для тривиальных скриптов, но в высоконагруженном бэкенде приводит к массовым аллокациям памяти ([]rune создает совершенно новый массив 4-байтовых целых чисел; компилятор оставит его на стеке, только если результат мал и не убегает из функции).

2. Инженерный подход (Zero-Allocation через пакет utf8):

Go
s := "Привет"
// Руками находим байтовое смещение нужной руны, чтобы отрезать безопасно.
// Пакет utf8 предоставляет инструменты для работы без выделения памяти.
n := 3 // сколько символов оставить
offset := 0
for i := 0; i < n && offset < len(s); i++ {
    _, size := utf8.DecodeRuneInString(s[offset:])
    offset += size
}
res := s[:offset] // "При": подстрока ссылается на байты s, новой памяти нет

С помощью функций пакета unicode/utf8 (таких как utf8.DecodeRuneInString) вы последовательно находите байтовую позицию $K$-го символа и берете байтовый срез строки s[:offset], не выделяя ни единого лишнего байта в куче!

(Подробные алгоритмы высокопроизводительного парсинга строк и Zero-Allocation манипуляций мы разберем в отдельной главе 20. Строки в Go. Immutable string и работа с Unicode).


Итог

  1. byte — это синоним uint8 (1 байт). Служит для манипуляции сырыми бинарными потоками данных (сетевые сокеты, файлы, криптографические хэши).
  2. rune — это синоним int32 (4 байта). Представляет отдельную кодовую точку (Code Point) в стандарте Unicode.
  3. Строка string — это неизменяемая последовательность байт в кодировке UTF-8 (указатель и длина, без cap), а не массив символов.
  4. Функция len(s) и оператор среза s[a:b] работают строго с байтами. Срез посреди многобайтового символа разорвет руну и приведет к невалидному UTF-8.
  5. Итерация for i, r := range s автоматически декодирует байты в руны на лету, но индекс i обозначает байтовое смещение в памяти, перепрыгивая через байты длинных символов.
  6. Для подсчета количества кодовых точек в тексте используйте функцию utf8.RuneCountInString() (помня, что это кодовые точки, а не визуальные графемы).

Освоив анатомию байтов и рун, мы заложили прочный базис для работы со всеми типами данных в Go. В следующей главе — 8. Операторы и выражения — мы изучим операторы языка, логику выражений и познакомимся с тем, как Go на корню исключает старые ловушки неопределенного поведения и неоднозначности приоритетов из C.