JavaRush /Курсы /Go SELF /Рост слайса и реаллокация: append

Рост слайса и реаллокация: append

Go SELF
11 уровень , 4 лекция
Открыта

1. Знакомство с append

До этого мы работали со слайсами так, будто это “полка с ячейками”: есть s[0], s[1], можно пройтись циклом. Но в какой-то момент обязательно появляется задача другого типа: “считай числа до нуля”, “собери все слова из ввода”, “прочитай неизвестное количество значений”. И тут выясняется неприятная вещь: вы заранее не знаете длину списка.

В языках со “списками” чаще всего это выглядит как магия: добавляешь элемент — и список растёт сам. В Go магии нет, но есть очень ясный контракт. Слайс — это не контейнер “сам по себе”, а маленькое описание: сколько элементов сейчас есть (len) и сколько места выделено на будущее (cap). Поэтому “дописать следующий элемент” — это не просто присваивание по индексу. Иногда места хватает, иногда нет, и тогда нужен аккуратный “переезд” в память побольше.

Функция append — это как команда: “добавь в конец, а если места не хватает — расширь хранилище и верни мне обновлённый слайс”. Именно поэтому первая важная привычка этой лекции звучит почти как правило дорожного движения: результат append всегда нужно сохранять. Не потому что Go вредный, а потому что после добавления у вашего списка может измениться длина, а иногда и “адрес, где лежат элементы”.

Если запомнить эту идею, то всё остальное в append станет спокойным и предсказуемым: len отвечает за то, что “существует”, cap — за “запас места”, а append — за безопасный рост.

2. Контракт append и почему результат нужно сохранять

Представьте, что слайс — это не “резиновый список”, а ярлык на коробке: на ярлыке написано, сколько элементов сейчас лежит внутри (len) и сколько места ещё есть в коробке (cap). Когда вы делаете append, вы просите Go “добавь ещё один элемент в конец”. Иногда Go действительно кладёт его в ту же коробку. А иногда коробка оказывается маловата, и тогда Go берёт коробку побольше, перекладывает туда всё содержимое и кладёт новый элемент уже туда.

Отсюда и рождается главный закон: append всегда возвращает обновлённый слайс. Даже если элементы “на вид” остались там же, после добавления точно изменился len. А если был “переезд”, то изменилась ещё и ссылка на данные. Поэтому запись вида append(s, x) без присваивания — почти всегда ошибка мышления: вы как будто попросили “добавь”, получили новый ярлык… и выбросили его в мусорку.

Самый правильный базовый шаблон выглядит так: “добавил — сохранил результат”.

package main

import "fmt"

func main() {
	s := []int{1, 2}
	s = append(s, 3)
	fmt.Println(s) // [1 2 3]
}

Исторический факт: до появления append людям приходилось вручную писать код, который проверяет cap, выделяет новый массив, копирует данные и только потом добавляет элемент. Это было настолько частой болью, что append появился как встроенная функция и сделал жизнь заметно проще.

Главное правило append: он возвращает новый слайс

append выглядит как обычная функция, но это встроенная функция языка (built-in). И ключевое: результат append — это слайс, который надо использовать дальше. Даже если вам кажется, что «он же добавил в конец», на самом деле он мог:

  1. дописать в существующий backing array (если cap хватило);
  2. выделить новый backing array, скопировать туда элементы и уже туда дописать (если cap не хватило).

И в обоих случаях append возвращает обновлённый slice header. В этом есть железная логика: если append изменил длину, он обязан отдать вам новый len. Если append сделал реаллокацию, он обязан отдать вам новую ссылку на данные. Именно поэтому в Go много функций, которые меняют «форму» слайса, возвращают слайс обратно (и это нельзя игнорировать).

В дальнейшем считайте это рефлексом: s = append(s, ...).

Обратите внимание: это не «занудство Go», это буквально контракт функции.

Почему нельзя писать append(s, x) и надеяться, что «и так сойдёт»

Когда вы пишете append(s, x) и не сохраняете результат, вы выкидываете на пол информацию о новом len (и возможно новой памяти). С точки зрения программы, переменная s остаётся прежней: у неё тот же len, тот же cap и та же ссылка на backing array.

Пример «как сделать вид, что вы добавили элемент, но на самом деле нет»:

package main

import "fmt"

func main() {
	s := []int{1, 2}

	append(s, 3)                 // результат проигнорирован
	fmt.Println(s, len(s))       // [1 2] 2
	fmt.Println(append(s, 3))    // [1 2 3]  <-- но это временное значение
}

Здесь последняя строка печатает слайс, который существует, но он не присвоен в s. То есть вы как будто купили хлеб, положили его в пакет… и оставили пакет на кассе.

Чуть более опасная версия — когда вы игнорируете результат внутри функции:

package main

import "fmt"

func addOne(s []int) {
	append(s, 999) // игнорируем результат
}

func main() {
	s := []int{1, 2}
	addOne(s)

	fmt.Println(s) // [1 2]
}

Слайс в параметре s — это копия slice header (копия описания). Если внутри функции вы не вернули новый слайс наружу, вызывающий код никак не узнает, что длина должна была измениться.

Правильный вариант — вернуть слайс:

package main

import "fmt"

func addOne(s []int) []int {
	s = append(s, 999)
	return s
}

func main() {
	s := []int{1, 2}
	s = addOne(s)

	fmt.Println(s) // [1 2 999]
}

3. Рост слайса и реаллокация

len растёт всегда, cap — иногда (и скачками)

Когда вы добавляете элементы, len растёт строго предсказуемо: добавили один — длина +1. Добавили три — длина +3. А вот cap — штука более хитрая: Go старается выделять память так, чтобы будущие append были дешевле, поэтому ёмкость часто растёт «рывками». Как именно — деталь реализации, на неё нельзя завязывать логику. Но наблюдать за этим полезно, чтобы мозг перестал ожидать магии.

Давайте напишем маленький «датчик роста»:

package main

import "fmt"

func main() {
	s := make([]int, 0) // пока просто пустой слайс

	for i := 1; i <= 8; i++ {
		s = append(s, i)
		fmt.Printf("i=%d len=%d cap=%d s=%v\n", i, len(s), cap(s), s)
	}
}

Вывод будет примерно в таком стиле (точные cap могут отличаться, и это нормально):

i=1 len=1 cap=1 s=[1]
i=2 len=2 cap=2 s=[1 2]
i=3 len=3 cap=4 s=[1 2 3]
...

Здесь важно уловить две мысли. Первая: len — это «сколько элементов реально есть» и именно он отвечает за индексацию. Вторая: cap — это «сколько места выделено», и он может меняться, когда Go решает, что пора расширяться.

Реаллокация backing array: что происходит при «переезде»

Слово «реаллокация» звучит как что-то из бухгалтерии, но смысл простой: если места не хватает, Go выделяет новый массив побольше и копирует туда ваши элементы. Аналогия максимально бытовая: вы живёте в квартире (backing array). Пока вещей мало — нормально. Когда вещей стало больше, чем шкафов (cap), вы переезжаете в квартиру побольше. Старый адрес остаётся, но вы уже не там.

Внутри append логика примерно такая (упрощённо, не как спецификация, а как модель для головы):

flowchart TD
    A["Есть слайс s (len, cap)"] --> B{"cap хватает для добавления?"}
    B -->|да| C["Пишем новые элементы в тот же backing array, len увеличивается"]
    B -->|нет| D["Выделяем новый backing array побольше"]
    D --> E["Копируем старые элементы"]
    E --> F["Добавляем новые элементы"]
    C --> G["Возвращаем новый slice header"]
    F --> G

Теперь важное последствие: если у вас где-то «лежит» другой слайс, который ссылался на старый backing array, после реаллокации он может стать «ручкой к старому месту».

Покажем это на примере. Мы специально создадим слайс с маленьким cap, чтобы гарантированно заставить Go расширяться:

package main

import "fmt"

func main() {
	s := make([]int, 0, 1) // len=0, cap=1

	s = append(s, 1)
	t := s // t смотрит туда же, что и s

	fmt.Printf("before: s=%v t=%v\n", s, t) // before: s=[1] t=[1]

	s = append(s, 2) // cap был 1, теперь нужен 2 => будет реаллокация
	t[0] = 99        // меняем "старое место"

	fmt.Printf("after:  s=%v t=%v\n", s, t) // after:  s=[1 2] t=[99]
}

Смысл демонстрации такой: после расширения s мог переехать на новый backing array, а t остался указывать на старый. Поэтому изменение t[0] не обязано отражаться в s[0].

Это не «баг Go», это следствие того, что слайс — маленькая структура-описание. Мы уже обсуждали, что присваивание t := s копирует slice header. Так вот, после реаллокации эти два header начинают смотреть на разные массивы.

append и nil‑слайс: это нормальная пара

Иногда новичок думает: «Если слайс nil, значит, к нему нельзя ничего добавлять». На практике в Go nil‑слайс — это корректное начальное состояние для накопления данных. append умеет стартовать с nil: он просто выделит backing array сам.

package main

import "fmt"

func main() {
	var s []int // nil

	s = append(s, 10)
	s = append(s, 20)

	fmt.Println(s, s == nil) // [10 20] false
}

Это очень удобный стиль: «пока элементов нет — просто nil, а дальше растём». Плюс range по nil‑слайсу безопасен, так что вы не обязаны немедленно что-то инициализировать ради спокойствия.

Микро-диагностика: адрес первого элемента

Иногда, чтобы поверить в «переезд», хочется увидеть это глазами. Мы можем аккуратно напечатать адрес первого элемента через "%p". Важно: брать &s[0] можно только когда len(s) > 0, иначе будет panic (потому что элемента нет).

package main

import "fmt"

func main() {
	s := make([]int, 0, 1)

	s = append(s, 1)
	fmt.Printf("len=%d cap=%d addr=%p\n", len(s), cap(s), &s[0]) // addr=0x...

	s = append(s, 2) // реаллокация почти гарантирована
	fmt.Printf("len=%d cap=%d addr=%p\n", len(s), cap(s), &s[0]) // addr=0x... (скорее всего другой)
}

Если адрес поменялся — значит, backing array точно переехал. Если вдруг в каком-то окружении адрес совпал (редко, но теоретически вы можете столкнуться с неожиданностями из-за оптимизаций/аллокатора), не делайте из этого “научный вывод”: логика приложения не должна зависеть от адресов, нам важна модель и контракт append, а не мистическое «где именно в памяти лежит массив».

4. Практика: мини-приложение со списком задач

До этого в курсе мы много работали с отдельными переменными и строками. Теперь у нас появляется новая суперспособность: хранить много однотипных значений. Давайте начнём собирать простейший список задач (пока без файлов, без структур, без статусов — просто строки в слайсе). Это приложение будет нарочно простым, зато отлично закрепит идею «append возвращает обновлённый слайс».

Сначала создадим «память» под задачи и добавим пару штук:

package main

import "fmt"

func main() {
	var tasks []string // nil-слайс, нормальный старт

	tasks = append(tasks, "Купить молоко")
	tasks = append(tasks, "Выучить append (и не забыть сохранить результат)")

	fmt.Println(tasks) // [Купить молоко Выучить append (и не забыть сохранить результат)]
}

Теперь добавим функцию, которая добавляет задачу правильно. Обратите внимание: функция возвращает новый tasks.

package main

import "fmt"

func addTask(tasks []string, title string) []string {
	tasks = append(tasks, title)
	return tasks
}

func main() {
	var tasks []string

	tasks = addTask(tasks, "Сделать зарядку")
	tasks = addTask(tasks, "Погладить кота (кот сам себя не погладит)")

	fmt.Println(tasks) // [Сделать зарядку Погладить кота (кот сам себя не погладит)]
}

А теперь специально покажем типичную ошибку — функция, которая «что-то делает», но результат не отдаёт наружу:

package main

import "fmt"

func addTaskWrong(tasks []string, title string) {
	append(tasks, title) // результат выкинули
}

func main() {
	var tasks []string

	addTaskWrong(tasks, "Эта задача потеряется")
	fmt.Println(tasks) // []
}

Такая ошибка особенно коварна тем, что код компилируется и выглядит логично, но приложение ведёт себя как будто «иногда забывает».

5. Типичные ошибки при работе с append

Ошибка №1: игнорировать возвращаемое значение append.
Самая частая проблема: написать append(s, x) и ожидать, что s «сам обновится». В Go append возвращает новый слайс, потому что мог измениться len, cap и даже ссылка на backing array. Привычка “всегда присваивать результат” — это не стиль, это корректность.

Ошибка №2: случайно использовать := вместо = и “потерять” обновлённый слайс.
Когда переменная уже существует, но вы внутри блока пишете s := append(s, x), вы можете создать новую переменную s, которая живёт только в этом блоке. Снаружи останется старая. Это выглядит почти одинаково, а эффект — как у фокуса: «добавил, но ничего не добавилось».

Ошибка №3: думать, что два слайса после t := s навсегда разделяют одни и те же элементы.
До первого серьёзного роста это часто правда, поэтому баг проявляется “не всегда”. Но при реаллокации один слайс может переехать, а другой останется на старом backing array. В итоге изменения через один перестают быть видны в другом — и это нормальное поведение.

Ошибка №4: пытаться индексировать “до cap”, а не “до len”.
Иногда человек видит cap=10 и думает: «Ну значит, у меня есть 10 ячеек». Место-то есть, но элементов логически нет: индексация разрешена только в диапазоне 0len-1. Если len=0, то s[0] — это panic, даже если cap огромный.

Ошибка №5: смешивать стиль “заполняю по индексам” и стиль “наращиваю через append” без понимания длины.
Например, создать make([]int, 5) (то есть len=5, уже пять нулей), а потом в цикле ещё делать append. В результате вы получите «пять нулей, а потом полезные элементы» — и будете долго думать, кто вам подложил эти нули. Проблема не в append, а в том, что вы стартовали не с тем len.

1
Задача
Go SELF, 11 уровень, 4 лекция
Недоступна
Лента датчиков
Лента датчиков
1
Задача
Go SELF, 11 уровень, 4 лекция
Недоступна
Пакетное обновление
Пакетное обновление
1
Задача
Go SELF, 11 уровень, 4 лекция
Недоступна
Потерянный append
Потерянный append
1
Задача
Go SELF, 11 уровень, 4 лекция
Недоступна
Разъехавшиеся слайсы
Разъехавшиеся слайсы
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ