Как отвечать: определение → как работает внутри → где ловушка → пример. Отвечайте вслух до чтения ответа — собеседование это устный жанр, и «понимаю про себя» не равно «объясняю словами».
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)
Прокачать тему в самоучителе
Читается быстро, запоминается практикой. Эти курсы закрывают тему с запуском кода прямо на странице:
- Язык Go с нуля: базовый синтаксис и конструкции — Интерактивный курс базового синтаксиса Go для начинающих: переменные, циклы, срезы, функции, структуры. 25 уроков с кодом в браузере, тестами и первым приложением.