Внутреннее устройство string, slice, map, channel
Урок про то, что спрашивают на собеседованиях на Go. Не “напиши hello world”, а “что внутри у string и почему конкатенация в цикле, это O(n^2)”. На позицию $10k+/месяц без понимания этих вещей не берут.
Канал-источник, по которому собрана выборка частых вопросов: АйТи Красавчик. Текущая версия Go в уроке, 1.24, релиз февраля 2026 ещё не выходил, так что фичи Go 1.25 помечены как “ожидаемые”.
1. string, immutable view над байтами
В Go string, это не массив символов, как в Java, и не linked list, как в Haskell. Это очень тонкая обёртка над участком памяти:
type stringHeader struct {
Data uintptr // указатель на байты
Len int // длина в байтах
}
Размер заголовка на 64-bit системах, 16 байт (8 + 8). Сами данные лежат отдельно, header только показывает, где они и сколько их.
Ключевые свойства:
- immutable, после создания строку поменять нельзя.
s[0] = 'a', ошибка компиляции. - UTF-8 по умолчанию, исходники Go в UTF-8, литералы тоже.
len(s), длина в байтах, не в символах.len("ёж"), это 4, не 2.- Нарезка
s[i:j], не копирует данные, новая строка ссылается на тот же backing-массив.
Конкатенация и почему она кусает
При операторе + строка не “расширяется”, это невозможно (immutable). Создаётся новая строка с длиной len(a)+len(b) и копируются байты.
s := ""
for i := 0; i < 100_000; i++ {
s += "x" // 100000 аллокаций, 100000 копирований растущего префикса
}
Это O(n^2) по времени и памяти. На 100k итерациях, секунды или десятки секунд CPU и пик в гигабайт.
Идиоматичное решение, strings.Builder:
var sb strings.Builder
sb.Grow(100_000) // если знаем размер заранее, одна аллокация
for i := 0; i < 100_000; i++ {
sb.WriteByte('x')
}
result := sb.String()
Builder под капотом держит []byte и переиспользует его, удваивая ёмкость по необходимости. На выходе делает один zero-copy преобразование в string через unsafe.
| Метод | Аллокации | Когда применять |
|---|---|---|
+ оператор | O(n) аллокаций | две, три строки в одной точке кода |
strings.Builder | 1, 2 аллокации | сборка в цикле, неизвестный заранее результат |
bytes.Buffer | 1, 2 аллокации | если на выходе нужны байты, не строка |
strings.Join | 1 аллокация | уже есть готовый []string |
fmt.Sprintf | много | только для редких форматных строк, не для горячего пути |
Что часто спрашивают
- Чем отличается
[]byte(s)отs[i:j]? Первое копирует, второе нет. - Что происходит при
string(b)гдеb []byte? Копирование, чтобы string остался immutable. - Почему
for i, r := range sитерирует по runes, аs[i]это byte? Потому что range декодирует UTF-8, а индексирование сырых байт.
2. slice, тройка из ptr, len, cap
type sliceHeader struct {
Data uintptr // указатель на backing array
Len int // текущая длина
Cap int // ёмкость
}
Header на 64-bit, 24 байта. Сам массив, отдельная аллокация.
Создание
s := make([]int, 5, 10) // len=5, cap=10
s := []int{1, 2, 3} // len=3, cap=3
var s []int // nil slice, len=0, cap=0, но работает в append/range
nil-слайс и пустой слайс ведут себя одинаково в append и range, разница видна только при сравнении с nil и при сериализации в JSON (null против []).
Append и стратегия роста
В Go 1.21+ growth-стратегия:
cap < 256, удвоение,cap >= 256, по формулеcap = oldCap + (oldCap + 3*256) / 4, что грубо равно1.25 * oldCap,- после этого ёмкость округляется вверх по размерам аллокатора (16, 32, 48, 64 и т.д.).
Точные значения смотри в runtime/slice.go, формула менялась несколько раз.
Главные ловушки
Sub-slice держит весь backing array. Если ты нарезал b := big[0:10] от мегабайтного big, и большой массив больше нигде не используется, GC всё равно его держит, потому что b ссылается на ту же память.
Решение:
b := append([]int(nil), big[0:10]...) // явная копия
// или slices.Clone(big[0:10])
Shared backing array. При b := a[1:3] оба слайса смотрят в одну память:
a := []int{1, 2, 3, 4, 5}
b := a[1:3]
b[0] = 100 // a[1] тоже стало 100
Append может вернуть новый или тот же массив. Если хватает cap, тот же. Если нет, создаётся новый массив, скопированный, расширенный. Поэтому возвращаемое значение append обязательно надо присваивать:
s = append(s, 1) // правильно
append(s, 1) // молчаливо ничего не делает, если cap хватало
Race на append из двух горутин, классика. Два append одновременно прочтут одну ёмкость и затрут друг друга. Слайс не потокобезопасен.
Что часто спрашивают
- Чем
make([]int, 0, 10)лучше[]int{}, если потом будет append? Меньше переаллокаций. - Что вернёт
len([]int(nil))? Ноль, без panic. - Когда
append(s, x...)может изменить переданныйs, а когда выделит новый массив? Еслиcapуже есть, изменит. Иначе выделит новый.
премиум-урок
Продолжение в премиуме: 1 200 ₽ в месяц или 10 000 ₽ в год. Вместе с ним серверные проверки задач и школьные токены AI-коуча.
Оплата пока не подключена: премиум и курсы открываются промокодом.