Слайсы: len, cap, append и шаринг массива

О лекции

Срез — это окно на массив: указатель, длина и ёмкость. От этого зависят append, общие подсрезы и то, что видно снаружи после вызова функции.

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

Срез в Go выглядит как список, который умеет расти. На самом деле это маленькая структура из трёх полей: указатель на массив, длина len и ёмкость cap. Сам срез при передаче копируется. Копируется заголовок, не элементы. Массив за указателем общий, пока append не выделит новый.

В бэкенде так устроены почти все списки: строки из БД, тело HTTP, очередь задач. Два частых бага из одной модели. Функция добавила элемент — снаружи его нет. Подсрез поменял исходный слайс — «испортили чужие данные».

Аналогия: массив — улица домов, срез — окно, через которое видно несколько домов подряд. Два окна могут смотреть на одну улицу. Покрасили дом через одно окно — через второе виден тот же цвет.

Как устроен срез

Поле ptr указывает на первый видимый элемент. len — сколько элементов можно читать и писать по индексу. cap — сколько ещё влезает в тот же массив без новой аллокации. Запись s[i] = x меняет ячейку массива. Это видно всем срезам с тем же ptr.

Пример. Слайс как окно на массив: a := []int{1, 2, 3, 4}, затем b := a[:2]. У b длина 2, ёмкость 4, указатель тот же. b[0] = 9 меняет и a[0].

type sliceHeader struct {
	ptr *byte
	len int
	cap int
}

func demo() {
	a := []int{1, 2, 3, 4} // len=4, cap=4
	b := a[:2]             // ptr тот же, len=2, cap=4
	b = append(b, 9)       // cap хватает: пишем в тот же массив
	// a теперь {1, 2, 9, 4} — общий массив
}

Заголовок среза копируется. Данные лежат в массиве за указателем.

append всегда возвращает новый заголовок. Если len < cap, он пишет в тот же массив и увеличивает len. Если len == cap, рантайм выделяет новый массив, копирует элементы и возвращает заголовок уже на него. Обычно новый массив берут с запасом, чтобы не копировать на каждый append. В среднем это дёшево: точные копии редки, зато иногда копируют все n элементов сразу.

Пример. Был слайс длины 3 и ёмкости 3. Сделали s = append(s, 4). Места нет — появился новый массив. Старый живёт, пока на него смотрит другой срез.

Передача в функцию

Функция получает копию заголовка. Запись s[i] = x идёт в общий массив — вызывающий увидит изменение. append внутри функции меняет только локальную копию заголовка. Снаружи len и ptr не обновятся, пока вы не вернёте срез и не присвоите его.

func set(s []int) {
	s[0] = 9 // видно вызывающему: тот же ptr
}

func appendLocal(s []int) {
	s = append(s, 4) // новый header уходит в мусор
}

func appendReturn(s []int) []int {
	return append(s, 4)
}

func main() {
	a := []int{1, 2, 3}
	set(a)              // a[0] == 9
	appendLocal(a)      // четвёртого элемента нет
	a = appendReturn(a) // так правильно
}

Индекс меняет массив на месте. append без возврата теряет новый заголовок.

Пример. Как с записной книгой: коллеге отдали ксерокопию обложки с адресом той же страницы. Он может дописать в клетку. Если страница кончилась и он купил новую тетрадь, ваша обложка всё ещё смотрит на старую.

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

Вызвать append внутри функции и не вернуть результат, думая «срез же ссылка». Снаружи останется старый len. После выделения нового массива снаружи останется и старый массив. Правило то же, что в одном блоке: всегда s = append(s, x).

Подсрезы

Выражение b := a[1:3] не копирует элементы. У b свой len. Ёмкость часто тянется до конца исходного массива. Запись в b[0] меняет a[1]. Если потом сделать append(b, x) при запасе cap, можно затереть хвост a, который «как будто не входил» в подсрез. Чтобы отвязаться, копируют: append([]T(nil), a...) или slices.Clone.

Пример. HTTP-хендлер отдал наружу buf[:n] из внутреннего буфера. Получатель дописал в этот подсрез — испортил буфер, который потом вернут в sync.Pool. Безопаснее скопировать то, что переживает текущий вызов.

range по срезу копирует элемент в переменную цикла. Менять v в for _, v := range s бесполезно для массива. Нужен индекс или указатель.

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

Нарисуйте ptr, len и cap. Копируется заголовок, не данные. Дальше развилка append: тот же массив или новый. Подсрез смотрит на тот же массив, пока его явно не клонировали. Функция, которая растит срез, должна его вернуть.