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) для базовых целочисленных примитивов:
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): она возвращает количество физических байт в памяти, а вовсе не количество видимых символов!
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], программа будет читать сырые байты из памяти один за другим:
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):
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..."]
Декодер с помощью битовых масок проверяет старшие биты первого байта:
- Если старший бит равен
0(маска0b10000000 == 0), то перед нами классический 7-битный ASCII символ. Длина — 1 байт. - Если старшие биты
110, то это двухбайтовая последовательность. За ней следует ровно один байт продолжения, начинающийся с10. - Префикс
1110сигнализирует о трехбайтовой руне. - Префикс
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:
s := "Привет"
fmt.Println(s[0:2]) // Ожидаем "Пр", получаем "П" (т.к. "П" занимает 2 байта)
fmt.Println(s[0:1]) // Ожидаем "П", получаем одиночный байт 0xD0 (терминал покажет � или мусор)
Разрезав двухбайтовый символ кириллицы ровно посередине (s[0:1]), мы получили одиночный байт 0xD0. При попытке распечатать его в терминале вывод исказится, либо терминал отобразит знак вопроса.
Как безопасно отрезать символы?
1. Наивный путь (аллокация в куче):
// Приведение строки к слайсу рун.
// Внимание: проходит всю строку (O(N)) и, если результат убегает или велик,
// выделяет память в куче под массив int32!
runes := []rune("Привет")
res := string(runes[0:3]) // "При"
Такой подход прост и безопасен для тривиальных скриптов, но в высоконагруженном бэкенде приводит к массовым аллокациям памяти ([]rune создает совершенно новый массив 4-байтовых целых чисел; компилятор оставит его на стеке, только если результат мал и не убегает из функции).
2. Инженерный подход (Zero-Allocation через пакет utf8):
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).
Итог
byte— это синонимuint8(1 байт). Служит для манипуляции сырыми бинарными потоками данных (сетевые сокеты, файлы, криптографические хэши).rune— это синонимint32(4 байта). Представляет отдельную кодовую точку (Code Point) в стандарте Unicode.- Строка
string— это неизменяемая последовательность байт в кодировке UTF-8 (указатель и длина, безcap), а не массив символов. - Функция
len(s)и оператор срезаs[a:b]работают строго с байтами. Срез посреди многобайтового символа разорвет руну и приведет к невалидному UTF-8. - Итерация
for i, r := range sавтоматически декодирует байты в руны на лету, но индексiобозначает байтовое смещение в памяти, перепрыгивая через байты длинных символов. - Для подсчета количества кодовых точек в тексте используйте функцию
utf8.RuneCountInString()(помня, что это кодовые точки, а не визуальные графемы).
Освоив анатомию байтов и рун, мы заложили прочный базис для работы со всеми типами данных в Go. В следующей главе — 8. Операторы и выражения — мы изучим операторы языка, логику выражений и познакомимся с тем, как Go на корню исключает старые ловушки неопределенного поведения и неоднозначности приоритетов из C.