Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Middle ~18 мин 10 вопросов

Строки и руны в Go: UTF-8 изнутри — вопросы собеседования с ответами

Проблема строк в Go в том, что в других языках строка — это массив символов, а здесь это последовательность байт в UTF-8. Половина вопросов на собеседованиях растёт из этого расхождения: len() считает байты, s[i] отдаёт байт, а range — руны, причём с разными индексами.

Ниже — вопросы с ответами: от len("привет") и s[i] до обрезки по символам, конверсий string и []byte, конкатенации в цикле, strings.Builder и работы с битым UTF-8. Порядок ответа прежний: сначала проблема, потом механизм, потом граница и пример.

Две вещи стоит держать в голове постоянно. Первая: строка неизменяема, поэтому любое «изменение» создаёт новую строку — и это же объясняет, почему append к срезу байтов и конкатенация ведут себя по-разному. Вторая: счёт байтов и счёт символов — разные числа, и путаница между ними даёт баги в лимитах: символ в лимит не влезет, а байт влезет. Третья тонкость: []byte и []rune — это разные данные, и []byte о границах символов не знает ничего.

База — в уроке про строки и руны базового курса.

Как отвечать: определение → как работает внутри → где ловушка → пример. Отвечайте вслух до чтения ответа — собеседование это устный жанр, и «понимаю про себя» не равно «объясняю словами».

1. Что лежит внутри строки Go? Что вернёт len("привет")?

Строка — неизменяемая последовательность байт в кодировке UTF-8 (заголовок: указатель на данные + длина). len возвращает число БАЙТ: len("привет") = 12, потому что каждая кириллическая буква кодируется двумя байтами. «Символ» в Unicode — кодовая точка (руна, int32); латиница занимает 1 байт, кириллица — 2, эмодзи — 4. Вопрос-ловушка рядом: изменить байт в строке нельзя (s[0] = 'x' не компилируется) — строки неизменяемы, любые «изменения» создают новую строку.

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

2. Что вернёт s[i] и что вернёт range по строке?

s[i] — один БАЙТ (тип byte, uint8), не символ: s[0] для «привет» даст 0xD0 — половину UTF-8-последовательности. range по строке декодирует РУНЫ: во второй переменной — кодовая точка (rune), в первой — байтовый индекс НАЧАЛА руны (поэтому индексы «скачут» на кириллице). Если в строке битый UTF-8, range вернёт utf8.RuneError (U+FFFD) на каждом битом байте. Для посимвольной обработки по индексам без range — utf8.DecodeRuneInString(s[i:]).

for i, r := range "привет" {
    fmt.Println(i, r) // 0 П, 2 р, 4 и... индексы скачут по 2
}
fmt.Println(s[0]) // 208 — байт, не буква

3. Как безопасно обрезать строку по символам, а не по байтам?

Срез s[:5] режет по байтам и может разрезать букву посередине — получится невалидный UTF-8, при печати отобразятся символы замены. Правильный путь: конвертировать в []rune и резать по рунам — string([]rune(s)[:5]); для длинных строк это копирование всей строки в руны (дорого), поэтому для префикса лучше идти по range и собрать руны до лимита, или использовать utf8.DecodeRuneInString в цикле. Ещё нюанс: разрезанная строка при json-сериализации и в базах может вызвать ошибки валидации UTF-8.

runes := []rune(s)
head := string(runes[:5]) // корректная обрезка по рунам
// для длинных строк — без копирования всей строки:
var b strings.Builder
count := 0
for _, r := range s {
    if count >= 5 { break }
    b.WriteRune(r)
    count++
}

4. Сколько стоит конверсия string ↔ []byte и когда она происходит незаметно?

string → []byte — копирование всех байт (O(n), новая аллокация); []byte → string — тоже копирование. Незаметные конверсии: передача строки в функции, ожидающие []byte (и наоборот), каждое сравнение/хеширование map-ключа — нет, строки хранятся как есть, но запись в map[string] строку из []byte конвертирует с копией. Go 1.22+ в некоторых случаях компилятор убирает конверсию (если байтовый срез не мутируется), полагаться на это нельзя. Большие конверсии в горячем пути — типовая находка pprof; лечение: API, принимающие []byte, или unsafe-трюки (только с полным пониманием иммутабельности строк).

5. Почему конкатенация строк в цикле — O(n²) и что использовать вместо?

Строки неизменяемы: каждый s += x выделяет новую строку и копирует всё содержимое — для n кусков суммарно O(n²) копирований и аллокаций, что видно в pprof как нагрузку на GC. Правильный инструмент — strings.Builder: накапливает байты во внутреннем буфере с амортизированным ростом, WriteString без промежуточных копий; для чисел — strconv перед Builder'ом. fmt.Sprintf в цикле тоже аллоцирует-heavy: для простого склейки — Builder, для форматирования — один Sprintf со всеми аргументами вместо десяти.

var b strings.Builder
for _, part := range parts {
    b.WriteString(part)
}
result := b.String() // одна финальная строка

6. Что вернёт range по строке с битым UTF-8? Как валидировать строку?

На невалидной последовательности range возвращает специальную руну utf8.RuneError (U+FFFD, «символ замены») с шириной 1 байт и продолжает. Проверка валидности — utf8.ValidString(s) (и utf8.Valid для байт): true, если последовательности корректны. Откуда берётся битый UTF-8: срез посередине многобайтовой руны, ручная сборка байт, данные из внешних систем. Практика: валидировать пользовательский ввод на границе (если строка уйдёт в БД с UTF-8-валидацией, она отклонится там с менее понятной ошибкой).

broken := "хо€хо" // € — битый байт
fmt.Println(utf8.ValidString(broken)) // false
for i, r := range broken { fmt.Println(i, r, string(r)) } // RuneError на месте битого

7. Есть ли в Go интернирование строк? Что это значит для сравнения и map?

Нет, интернирования (глобального пула строк, встречающихся один раз) в Go нет: два одинаковых строки — два независимых куска памяти, и == сравнивает их ПО СОДЕРЖИМОМУ (не по указателю). Следствия: сравнение длинных несовпадающих строк — O(n) с ранним выходом; ключи map[string]… хешируются при каждом обращении (O(len)); «экономия памяти» через одинаковые строки не работает сама — но компилятор объединяет идентичные строковые литералы одного пакета, а strings есть intern-подобные трюки через map[string]string. Для hot path с частыми сравнениями больших строк — хешировать и сравнивать хеши, или использовать индексы/ID.

8. strings.Builder — что под капотом и почему нельзя копировать значение?

Builder — обёртка над []byte с флагом «копирование запрещено»: методы WriteString/WriteByte растят буфер амортизированно, String() отдаёт строку одной финальной копией. Копирование Builder запрещено на рантайме (паника «illegal use of non-zero Builder copied by value») — потому что копия привязана к тому же буферу, и два «Builder» будут писать в одну память. Подсказки производительности: b.Grow(n) перед циклом известных размеров, WriteString вместо Write([]byte(s)) для строк (без конверсии), Len() для контроля. Антипаттерн: Builder внутри рекурсии без сброса — буфер растёт навсегда.

9. Как правильно сравнивать строки и искать подстроку: ==, strings.Contains, bytes.Equal?

В Go есть три разных вопроса, которые часто путают. Первый — равенство: оператор == сравнивает строки по содержимому, а не по указателю, и работает с UTF-8 без нормализации: «é» в NFC и в NFD — разные строки с разным числом байт, но выглядят одинаково. Проверка на равенство без учёта регистра — strings.EqualFold, он сравнивает по правилам Unicode.

Второй — поиск подстроки: strings.Contains использует алгоритм Рабина — Карпа или Бойера — Мура и работает с байтами, поэтому ищет корректно и в UTF-8. strings.Index отдаёт позицию в байтах, strings.IndexRune — в рунах. Для поиска с учётом регистра есть strings.Contains(strings.ToLower(s), ...), но это создаёт копию строки — в горячем пути лучше сначала привести обе стороны к одному регистру один раз.

Третий — сравнение больших данных без конверсий: bytes.Equal, bytes.Contains и bytes.Compare работают с []byte и не копируют строку в срез. Если данные пришли как []byte (из файла или сети), сравнивать их с bytes.Equal дешевле, чем конвертировать в строку.

Практическое правило выбора: == для равенства, strings.Contains для поиска, bytes-варианты — когда данные уже в виде байтов. А вот для сравнения с учётом регистра наивный ToLower не годится: он не учитывает разные регистры кириллицы и не приводит к общей форме. Если сравнение идёт по нормализованным значениям (почта, имя), приводите обе стороны к одному виду заранее.

s := "Привет"

s == "Привет"             // true
strings.EqualFold(s, "привет") // true — сравнение по правилам Unicode
strings.Contains(s, "ив")    // true
strings.Index(s, "ив")        // 2 — позиция в БАЙТАХ

b := []byte(s)
bytes.Equal(b, []byte("Привет")) // true — без конверсии в строку

10. Как правильно нормализовать строку и сравнивать без учёта регистра: почему ToLower не подходит?

Проблема в том, что у букв несколько форм. Кириллица «Ё» и «ё» — разные руны, а в Unicode есть ещё варианты с диакритикой и специальные случаи вроде немецкого «ß». Функция strings.ToLower приводит строку к нижнему регистру по простым правилам Unicode, но не делает нормализацию: не объединяет комбинированные диакритики, не приводит «é» к одной форме.

Из этого следует практический вывод: две строки, которые выглядят одинаково для пользователя, могут быть разными байтами. Сравнение строк, пришедших из разных источников — JSON, форма, файл, — обязано идти по нормализованному виду, иначе получите дубликаты в базе и «не находит» в поиске.

Что делать. Для большинства задач хватает strings.EqualFold и strings.ToLower плюс TrimSpace — это закрывает регистр и лишние пробелы. Для серьёзной работы с именами и поиском нужна библиотека нормализации Unicode (например, golang.org/x/text/unicode/norm) — она приводит строку к канонической форме NFC, после чего побайтовое сравнение становится корректным.

Отдельно про то, что не надо делать: не сравнивайте строки через ToLower в горячем пути без нужды — каждая конверсия выделяет память. Если строки короткие, это незаметно; если их тысячи в секунду на каждом запросе, появляется лишняя нагрузка на GC.

И последнее: нормализация не решает проблему визуально похожих, но разных символов — кириллическая «а» и латинская «a» выглядят одинаково, но это разные руны. Если это важно для продукта, проверяйте такие случаи отдельно.

// Простой вариант: регистр и пробелы
strings.EqualFold(strings.TrimSpace(a), strings.TrimSpace(b))

// Каноническая форма Unicode для серьёзного сравнения
import "golang.org/x/text/unicode/norm"

norm.NFC.String(a) == norm.NFC.String(b)

Прокачать тему в самоучителе

Читается быстро, запоминается практикой. Эти курсы закрывают тему с запуском кода прямо на странице: