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 — это слайс, который надо использовать дальше. Даже если вам кажется, что «он же добавил в конец», на самом деле он мог:
- дописать в существующий backing array (если cap хватило);
- выделить новый 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 ячеек». Место-то есть, но элементов логически нет: индексация разрешена только в диапазоне 0…len-1. Если len=0, то s[0] — это panic, даже если cap огромный.
Ошибка №5: смешивать стиль “заполняю по индексам” и стиль “наращиваю через append” без понимания длины.
Например, создать make([]int, 5) (то есть len=5, уже пять нулей), а потом в цикле ещё делать append. В результате вы получите «пять нулей, а потом полезные элементы» — и будете долго думать, кто вам подложил эти нули. Проблема не в append, а в том, что вы стартовали не с тем len.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ