Раздел 22 · Go

Внутреннее устройство string, slice, map, channel

middle-senior~45 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Внутреннее устройство 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.Builder1, 2 аллокациисборка в цикле, неизвестный заранее результат
bytes.Buffer1, 2 аллокацииесли на выходе нужны байты, не строка
strings.Join1 аллокацияуже есть готовый []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-коуча.

Оплата пока не подключена: премиум и курсы открываются промокодом.