Строки, байты и руны

О лекции

Строка в Go — неизменяемая последовательность байт в UTF-8. len считает байты, range отдаёт руны, а нарезка по индексу может разрезать букву пополам.

Типы и память Go golang собеседование

Строка — это набор байт, который нельзя менять на месте. Обычно эти байты — UTF-8. Это не «массив букв как на экране» и не массив рун.

Текст в бэкенде приходит из JSON, заголовков HTTP, путей и БД. Путаница байт / буква / символ на экране даёт обрезанный JSON, сломанный substring и неверную длину «имени пользователя».

Аналогия: строка — запечатанный конверт с байтами. Можно сделать новый конверт. Нельзя переписать байт внутри старого. Кириллица часто занимает два байта, многие эмодзи — четыре и больше.

Байты, руны, индексы

len(s) — число байт, не букв. s[i] — один байт, не руна. Для многобайтового символа это кусок последовательности, не буква. range по строке идёт по рунам: это кодпоинты Unicode. Вместе с руной отдаётся байтовый индекс её начала. []rune(s) собирает кодпоинты в слайс: индекс уже «по рунам». Символ на экране (буква с ударением, эмодзи из нескольких частей) всё ещё может быть из нескольких рун.

s := "привет"
fmt.Println(len(s)) // 12 байт, не 6 букв
fmt.Println(utf8.RuneCountInString(s))

for i, r := range s {
	// i — байтовый индекс начала руны, r — rune
	_ = i
	_ = r
}

rs := []rune(s)
fmt.Println(len(rs)) // 6 кодпоинтов
fmt.Println(s[0])    // байт, не буква «п»

len — байты UTF-8. range идёт по рунам. s[i] не руна.

Пример. Нужно превью из 10 «символов» для кириллицы. s[:10] режет по байтам. Буква может разрезаться пополам. Безопаснее считать руны или идти через utf8.DecodeRuneInString.

Нарезка s[0:n] режет по байтам. Если n попал внутрь UTF-8 последовательности, получится невалидная строка. Дальше ломаются вывод и парсеры.

Сборка и []byte

На границе I/O удобнее []byte: сокет, файл, хеш. string держат в API, ключах map и там, где важна неизменяемость. Преобразование туда-сюда копирует байты. На узкие оптимизации компилятора в горячем цикле лучше не рассчитывать.

В цикле s += piece каждый раз копирует всё накопленное. Это квадратично по числу кусков. strings.Builder (или работа сразу в []byte) растёт как слайс. Для горячего пути логов и сериализации это заметно.

var b strings.Builder
for _, piece := range parts {
	b.WriteString(piece)
}
out := b.String()

Сборка в цикле через Builder. Конкатенация s += piece копирует всё заново.

Обычное []byte(s) копирует байты. Подстрока s[a:b] шарит тот же массив байт, что и исходная строка. Огромный буфер из запроса, от которого оставили 3 символа, может не отдаться сборщику, пока жива маленькая строка. Иногда копируют явно.

Пример. Прочитали 1 МБ тела запроса в строку raw. Взяли id := raw[0:8] и сохранили id в кэш. Пока жив id, жив и весь мегабайт. Лучше strings.Clone(raw[0:8]).

Типичная ошибка

Взять len(s) как «число букв» и резать s[:10] для превью кириллицы или эмодзи. Индексация и slice по строке — про байты. Вторая ловушка: собирать ответ конкатенацией в цикле вместо Builder.

На собеседовании

len и s[i] работают с байтами UTF-8, не с буквами. Для обхода букв — range или utf8.RuneCountInString. Для сборки в цикле — strings.Builder. Символ на экране — ещё один слой, его len не считает.