Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 18 из 25 ~22 мин +50 XP

Срезы: append, copy и подводные камни

Функция append — главный инструмент роста среза, и запросы append go и copy срез go стабильно всплывают у всех, кто добрался до коллекций, — не зря: половина найденных ответов пугает «шарингом памяти». Здесь разберём оба глагола по шагам: append к nil-срезу, рост среза и его цену, подводные камни общей памяти, идиому s = append(s, ...) для функций, copy с правилом минимальной длины, удаление элементов и матрицы. Всё на примерах, которые запускаются прямо в уроке.

Содержание урока · 12 разделов

append в go: дописать и присвоить обратно#

В уроке 17 мы оставили срез на пороге роста: запись за границей срез не растягивает, len и cap у свежего среза равны, а фраза «запас под будущий append» так и осталась обещанием. Время выполнить его. append — встроенная функция, которая дописывает элементы в конец среза и возвращает обновлённый срез. Синтаксис один для любых типов: s = append(s, 5), names = append(names, "ada"), nums = append(nums, 18, 21, 19) — три числа одним вызовом. Есть и вторая форма, append(s, other...), которая дописывает сразу все элементы другого среза — она пригодится при склейке и удалении.

Две детали, без которых append не понять. Первая: append не меняет срез на месте — он возвращает новый заголовок, и его обязательно присваивать обратно той же переменной: s = append(s, v). Голый вызов append(s, v) без присваивания даже скомпилируется, но результат улетит в никуда: сколько элементов было, столько и осталось. Вторая: дописывать можно в любой срез, включая nil-срез из урока 17 и пустой литерал — язык сам выделит память. Массив из урока 16 прирастать не умеет вовсе: [3]int навсегда [3]int. Вся динамика go-коллекций — срезы, и почти вся динамика — это append.

Песочница: append к nil-срезу

Запустите: nil-срез из урока 17 оказался не тупиком, а стартовой точкой — append дописал первый элемент, и срез ожил. make не понадобился вовсе, память выделилась сама. Три опыта. Первый: допишите после второй печати s = append(s, 8, 9, 10) — append берёт сколько угодно элементов за раз. Второй: замените старт var s []int на s := []int{} и запустите — вывод не изменится: для append нет разницы между nil-срезом и пустым литералом. Третий: поставьте печать fmt.Println(s) ДО первого append — основной движок напечатает [], резервный — слово <nil>; поэтому в ранимых примерах nil-срез печатается только после того, как в нём что-то появилось.

Вывод

Запустите код, чтобы увидеть вывод.

Рост среза: len ползёт, cap скачет#

Что происходит с памятью, когда append дописывает за пределы cap? Срез устраивает переезд: выделяется новый массив побольше, старые элементы копируются туда, дописанный встаёт следом, а заголовок s начинает смотреть на новый дом. len при этом растёт на единицу, а cap — скачками: язык не добавляет по одной ячейке, а резервирует с запасом, чтобы следующие append обошлись без переезда. В настоящем Go запас растёт примерно удвоением: у пустого среза после первого append cap 1, потом 2, 4, 8 — на сотнях элементов шаг становится щедрее, но идея та же.

Из этой схемы следует важное про скорость. Каждый отдельный append чаще всего просто пишет в готовую ячейку, а переезды случаются всё реже: если сложить работу по всем дописываниям, каждый элемент в сумме переписывается ограниченное число раз. Значит, рост среза через append стоит в среднем O(1) на элемент — амортизированно, как говорят программисты. Прикладной вывод: дописывать по одному в цикле не страшно, это не квадратичная ловушка, как могло показаться.

А вот cap после каждого append в уроке мы печатать не будем — на этом месте движки песочницы расходятся: резервный интерпретатор всегда рисует cap равным len и никакого удвоения не знает. Поэтому следующий блок без кнопки: рост ёмкости один раз стоит увидеть в настоящем Go.

Рост на экране: len и cap по шагам

GO
package main

import "fmt"

func main() {
        s := []int{}
        for i := 0; i < 8; i++ {
                s = append(s, i)
                fmt.Println("len:", len(s), "cap:", cap(s))
        }
}

Кнопки нет: вывод движков расходится. Основной движок песочницы — настоящий Go — печатает len: 1 2 3 4 5 6 7 8 и рядом cap: 1 2 4 4 8 8 8 8: ёмкость прыгает удвоениями, между прыжками стоит на месте. Резервный TS-интерпретатор всегда рисует cap равным len: 1 2 3 4 5 6 7 8 — запаса у него нет. Запустите этот код в локальном Go или в основном движке, чтобы увидеть удвоение; len при этом совпадает в обоих движках, и ранимая версия с одной только len в уроке есть — она в runner-е выше.

Проверьте себя: рост среза

Вы дописали в пустой срез восемь элементов по одному через append. Что вернёт cap(s) в конце в настоящем Go?

Подводные камни срезов: append пишет в чужую память#

Вот главный подводный камень срезов, ради которого урок и назван «подводные камни». Срез — окно: указатель, len, cap. Если два среза смотрят на один массив и у окна есть запас ёмкости (len меньше cap), append НЕ устраивает переезд — он дописывает элемент прямо в общий массив, в ячейку за пределами len окна. А та ячейка может принадлежать другому срезу — и тот увидит в себе чужую правку. Такой шаринг памяти — не баг языка, а прямое следствие устройства среза из урока 17.

Сценарий классической ошибки: вырезали голову s[:2], дописали в неё элемент — и в оригинале s тихо испортился третий элемент. Никакой ошибки в консоли: программа работает, числа слегка неправильные. Такие баги живут в коде неделями, потому что падать нечему.

Разберём, когда append безопасен, а когда опасен. Безопасно, если у среза нет запаса — len == cap: append выделит новый массив и чужую память не заденет; именно поэтому присваивание t := s от свежего литерала в уроке 17 вело себя прилично. Опасно, если окно вырезано операцией среза s[i:j] из более длинного среза — за его len обычно лежат живые элементы оригинала. И тонкость песочницы: резервный движок копирует данные при срезании, поэтому в нём ошибку воспроизвести нельзя — она просто не проявится. Проверяйте пример ниже в основном движке или локальном Go.

Классическая ошибка: append через окно

GO
package main

import "fmt"

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

Кнопки нет, и вот почему. Основной движок — настоящий Go: у head len 2 и cap 5, запас есть, и append пишет 99 прямо в общий массив. Срезы печатают [1 2 99 4 5] и [1 2 99]: третий элемент s затёрт, хотя мы «только дописали в голову». Резервный движок копирует данные при срезании, поэтому он печатает s целым — [1 2 3 4 5] — и head как [1 2 99]. Выводы разъехались, урок один: append от вырезки с запасом cap в настоящем Go опасен, а резервный режим эту ошибку прячет. Лекарство — полный срез s[low:high:max] ниже по уроку или явный copy.

Что выведет код: append и сосед

Прочитайте код и выберите, что он выведет:

package main

import "fmt"

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

Идиома s = append(s, ...): срез в функции#

Срез — заголовок из трёх слов, и в функцию приезжает копия заголовка, а не копия данных. Из этого следуют два разных поведения. Правка элементов внутри функции видна снаружи: nums[0] = 99 меняет общий массив — мы прогоняли это ранимым примером в уроке 17. А вот append внутри функции снаружи не заметен: функция дописала элемент и переприсвоила свой ЛОКАЛЬНЫЙ заголовок — на этом всё кончилось. Снаружи len остался старым; новый элемент живёт где-то в памяти, но через чужой заголовок не виден.

Отсюда железное правило идиомы: функция, которая растит срез, обязана вернуть его — func grow(s []int) []int — а место вызова обязано присвоить результат обратно: s = grow(s). Так устроен сам append — принимает срез, возвращает срез — и так пишется весь прикладной Go. Правило страхует дважды: вы не потеряете элементы, дописанные в функции, и не удивитесь, почему их «нет».

Пара нюансов напоследок. Передача среза в функцию дешёвая всегда: три машинных слова, данные не копируются, хоть за окном миллион элементов. А функция, которая только читает, тоже берёт []int: nil-срез и пустой срез читаются безопасно — range сделает ноль оборотов, len вернёт 0, это мы проверяли в уроке 17.

Песочница: append внутри функции

Запустите: badAdd дописал 99 в свою локальную копию заголовка — снаружи len прежний и элемента не видно. grow возвращает срез, и присваивание s = grow(s) делает дописывание видимым: два вызова — и в срезе [1 2 3 42 42]. Опыты. Первый: уберите присваивание (оставьте голый вызов grow(s)) — элемент снова исчезнет: возврат без присваивания ничего не меняет. Второй: добавьте в badAdd печать fmt.Println("внутри:", s, len(s)) после append — внутри длина 4, снаружи 3: заголовки уже разные, данные одни. Третий: замените тело grow на правку s[0] = 42 с возвратом s и уберите присваивания — правка элемента видна снаружи и без присваивания: массив общий, заголовок тут ни при чём.

Вывод

Запустите код, чтобы увидеть вывод.

Копирование среза: функция copy#

Вторая половина пары — copy: встроенная функция копирует элементы из одного среза в другой и возвращает число скопированных элементов. Вызов copy(dst, src) копирует ИЗ src В dst: приёмник первый, источник второй — легко перепутать, держите в голове «скопировать В». Правило минимальной длины: копируется min(len(dst), len(src)) элементов, лишнее молча отбрасывается. Если источник короче приёмника, dst не обрезается: скопированная часть ложится в начало, хвост остаётся как был — у свежего make это нули.

Типичные задачи пары. Независимая копия: c := make([]int, len(s)); copy(c, s) — теперь правки c не задевают s, в отличие от t := s, которое делит данные. Склейка двух срезов: t := make([]int, 0, len(a)+len(b)); t = append(t, a...); t = append(t, b...) — форма с запасом, про которую честный callout ниже.

Ручной цикл range делает то же самое: for i, v := range src { dst[i] = v } — но только пока dst не короче src, иначе запись за границей. copy короче и безопаснее на разных длинах: сам отрезает по минимуму. В цикле ещё и помни, что v — копия, и писать надо строго по индексу; copy об этом думает за вас. На маленьких срезах разницы в скорости нет — выбирайте читаемость.

copy на практике: минимум длин

GO
package main

import "fmt"

func main() {
        src := []int{7, 8, 9, 10, 11}
        dst := make([]int, 3)
        n := copy(dst, src)
        fmt.Println(dst, n)

        tail := make([]int, 4)
        m := copy(tail, []int{1, 2})
        fmt.Println(tail, m)
}

Блок ранимый — оба движка дают одинаковый вывод: [7 8 9] 3 и [1 2 0 0] 2. В первом вызове источник длиннее: скопированы первые три элемента, десятка и одиннадцать отброшены, copy вернул 3. Во втором приёмник длиннее: легли два элемента, хвост остался нулями, вернулось 2. Печать среза и числа рядом — полезная привычка: по ней сразу видно, кто кого ограничил, приёмник или источник.

Полный срез s[low:high:max]: поводок для append#

У операции среза есть полная форма с третьим индексом: guard := s[low:high:max]. Первые два — знакомый полуинтервал, третий задаёт ёмкость результата: cap = max - low. Смысл — поводок. Обычная вырезка head := s[:2] от литерала из пяти элементов получает cap 5 и может дописывать в чужие ячейки — та самая классическая ошибка выше. Вырезка с максимумом head := s[:2:2] получает cap 2 — ровно по len, запаса нет, и первый же append уведёт её в новый массив, не тронув s. Это встроенный предохранитель от шаринга памяти: ставьте max = high, и append от вырезки безопасен по построению.

В настоящем Go форма работает как описано: full := s[1:3:5] даёт len 2 и cap 4 (5-1), а вот head := s[:2:2] при append-е переезжает целиком. Проверить это в песочнице целиком не выйдет: резервный интерпретатор третий индекс молча игнорирует — квирк, известный с урока 17, — и рисует cap по своим правилам, так что формула cap = max - low у него не выдерживает проверки. Поэтому раздел концептуальный, без кнопки: полный срез полезен в основном движке и локальном Go, а в ранимых примерах урока не появляется.

Проверьте себя: полный срез

s := []string{"a", "b", "c", "d", "e"}, затем head := s[:2:2]. Что сделает append(head, "x") в настоящем Go?

Удаление элемента: append-форма и копия с конца#

Отдельной операции «удалить» в Go нет — её собирают из имеющихся кирпичей. Каноническая форма с сохранением порядка: s = append(s[:i], s[i+1:]...). Читается: возьми всё до i, допиши к нему всё после i, результат присвой обратно. Порядок элементов сохраняется, len уменьшается на один, а в последнем слоте массива остаётся дубль — на печать он не влияет, но помнить про него стоит. Так удаляются и первый (i = 0), и последний (i = len(s)-1) элементы: частных случаев и отдельных форм нет.

Вариант без append — копирование с конца: copy(s[i:], s[i+1:]); s = s[:len(s)-1]. Элементы после i сдвигаются на один влево прямо на месте, срез укорачивается операцией среза. Он чуть экономнее на больших срезах и не собирает промежуточный заголовок, но читается хуже; выбирайте append-форму, пока скорость не доказана профилем.

Честная нота про песочницу: append-форма удаления печатает одинаковый результат в обоих движках — поэтому ранимый пример ниже есть. А copy-вариант на месте в резервном движке НЕ работает: при срезании он копирует данные, сдвига не происходит, и срез остаётся прежним. Проверяйте его в основном движке или локальном Go; в ранимых примерах урока — только append-форма.

Песочница: удалить элемент

Запустите: из языков удалился rust — порядок сохранился, go и python стоят на своих местах, len стал 2. Дальше два частных случая той же формы над числами: i = 0 срезает голову (10 исчез), затем i = len-1 срезает хвост (40 исчез). Опыты. Первый: удалите из langs ещё и python — индекс после первого удаления сместился, нужно i = len(langs)-1. Второй: опустошите nums циклом for len(nums) > 0 { nums = append(nums[:0], nums[1:]...) } и напечатайте len — срез честно опустеет до нуля. Третий: попробуйте i = 10 на исходных четырёх числах — вырезка за границей: настоящий Go паникует slice bounds, резервный молча зажмёт; правило 0 <= i <= len(s) из урока 17 спасает.

Вывод

Запустите код, чтобы увидеть вывод.

Вставьте пропуски: дописать и скопировать

Допишите два вызова: срез растёт через append с присваиванием обратно, данные копируются через copy. Собранная программа печатает «[1 2 3] 2».

s := []int{1, 2} s = (s, 3) buf := make([]int, 2) n := (buf, s) fmt.Println(s, n)

Соберите программу: удаление с сохранением порядка

Соберите программу: срез строк, индекс и append-форма удаления. Собранный вывод — «[альфа гамма]».

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Срезы срезов: матрица, которую можно растить#

Двумерные срезы из урока 17 — «рваные» полки разной длины — собирались литералом. Когда размер известен в числах, матрицу собирает make: matrix := make([][]int, rows) даёт rows строк, каждая — пока nil-срез; циклом for i := range matrix строкам выдаётся ширина: matrix[i] = make([]int, cols). Получается плотная сетка rows × cols, готовая под запись по индексам. В nil-строки можно и append-ить — матрица останется рваной, если так задумано.

Приём масштабируется на всё, что «таблица, но растущая»: журнал координат, заказы по дням, сетка температур по неделям. Строки-срезы независимы: matrix[0][1] = 5 заденет только первую строку, а append к matrix добавляет целую строку — она сама срез, со своими len и cap. Перебор — range снаружи, range внутри, как проговаривали в уроке 15 и 17: добавился только make-скелет.

И это последнее место, где срезам нужна помощь. Следующая коллекция курса — карты map из урока 19: там элементы лежат не по порядку и ищутся по ключу, а не по индексу, и «удалить» там — встроенная операция delete, а не собранная из append-ов.

Песочница: матрица через make

Запустите: make дал два ряда-заготовки, цикл выдал каждому ширину 3, две правки легли в сетку — печать [[0 5 0] [0 0 9]], строк 2, колонок в первой строке 3. Вторая половина: к литералной сетке append добавил целую третью строку — она срез, как и все. Опыты. Первый: поднимите размер make([][]int, 2) до 3 — третья строка после цикла заполнится нулями сама. Второй: поставьте правку matrix[2][0] = 1 ПЕРЕД циклом заполнения — оба движка оборвут программу: строк ещё нет, а запись в несуществующую строку незаконна. Третий: сделайте матрицу рваной — во второй итерации цикла замените make([]int, 3) на make([]int, 2) и уберите правку matrix[1][2], иначе программа оборвётся: у второй строки всего две ячейки.

Вывод

Запустите код, чтобы увидеть вывод.

Шпаргалка: append, copy и ловушки#

ЗаписьЧто делаетВ песочнице
s = append(s, v)дописать элемент и присвоить обратноработает, вывод совпадает в обоих движках
var s []int; s = append(s, v)append к nil-срезу — идиома стартаработает; печать nil-среза ДО append — квирк резерва (<nil>)
append(s, a, b, c)несколько элементов за один вызовработает
append(s, t...)дописать все элементы другого срезаработает
len(s) по шагам ростарост по одному элементу, амортизированно O(1)совпадает в обоих движках
cap(s) по шагам ростазапас под будущие appendGo удваивает (1 2 4 8), резервный рисует cap = len — неранимо
t := s; t = append(t, 9)дописывание при полном capновый массив, сосед цел — совпадает
t := s[:2]; t = append(t, 99)дописывание при запасе capGo пишет в общий массив, резервный копирует — неранимо
copy(dst, src)копирование min(len) элементовработает, включая возвращённое число
s = append(s[:i], s[i+1:]...)удаление с сохранением порядкаработает
copy(s[i:], s[i+1:]); s = s[:len(s)-1]удаление копированием на местеGo сдвигает элементы, резервный нет — неранимо
s[:2:2] / s[low:high:max]полный срез: cap = max - lowрезервный игнорирует третий индекс — неранимо
make([]T, 0, n)пустой срез с запасомрезервный cap 0 — неранимо
func f(a int, b int)функция с несколькими параметрамирезервный парсер не берёт; групповая f(a, b int) работает в обоих

Карточки: append, copy и ловушки

Термин

Что возвращает append и почему результат обязательно присваивать?

нажмите, чтобы перевернуть
Определение

Новый заголовок среза: append не меняет срез на месте. Без s = append(s, v) дописанные элементы остаются за пределами len старого заголовка и пропадают. Отсюда же сигнатура растящих функций: func grow(s []int) []int, на месте вызова — присваивание.

1 / 6

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.

Вывод

Запустите код, чтобы увидеть вывод.

Коротко: что запомнить

  • append дописывает элементы в конец среза и возвращает новый заголовок: s = append(s, v) — присваивание обязательно, иначе дописанное пропадает.
  • append к nil-срезу законен и идиоматичен: var s []int; s = append(s, ...) — make не нужен, старт с нуля — норма.
  • len растёт по единице, cap — скачками примерно удвоением в настоящем Go (1, 2, 4, 8); амортизированная цена append — O(1) на элемент.
  • Подводный камень срезов: append от вырезки с запасом cap пишет в общий массив и портит оригинал; при полном cap append переезжает и сосед цел.
  • Функция правит элементы снаружи, но рост виден только через возврат: идиома s = append(s, ...) — функция возвращает срез, вызов присваивает.
  • copy(dst, src) копирует min(len) элементов и возвращает их число; независимая копия — пара make + copy, а не t := s.
  • Удаление с сохранением порядка — s = append(s[:i], s[i+1:]...); полный срез s[low:high:max] ставит cap-поводок против шаринга; матрицы растут из make([][]int, rows).

Домашнее задание

Соберите журнал покупок: начните с var log []string и допишите append-ом одним вызовом четыре покупки — хлеб, молоко, сыр, кофе. Удалите «молоко» append-формой с сохранением порядка — должно напечататься [хлеб сыр кофе]. Затем сделайте независимый отчёт: report := make([]string, len(log)) и copy(report, log), поменяйте report[0] на «багет» и убедитесь, что log не дрогнул — в отличие от t := log. Финал: матрица цен 2×2 через make([][]int, 2) с заполнением циклом и append третьей строки с двумя числами. Все приёмы — из этого урока; [оглавление курса](/courses/go-basics/) под рукой.

Личные заметкиПусто
Хранятся только в вашем браузере (Go Base · Срезы: append, copy и подводные камни)

Вопросы и ответы по уроку

Почему нельзя просто append(s, v) без присваивания?

append возвращает новый заголовок среза и не меняет старый: без присваивания результат теряется, len остаётся прежним. Компилятор такой вызов пропускает — вызов функции законен, поэтому идиома s = append(s, ...) — это правило, а не синтаксис. Отсюда и сигнатуры растящих функций: func grow(s []int) []int.

Чем copy лучше ручного цикла при копировании среза?

copy сам ограничивает по минимуму длин и не падает, если источник длиннее приёмника или наоборот; цикл требует следить за границами и писать строго по индексу. На малых срезах разницы в скорости нет — выбирайте читаемость. Важнее другое: независимую копию даёт пара make(len(s)) + copy, а t := s делит данные с оригиналом.

Почему append иногда меняет исходный срез, а иногда нет?

Всё решает запас ёмкости. Если len == cap, append выделяет новый массив — оригинал цел. Если запас есть (после вырезки s[:2] или make с тремя аргументами), элемент вписывается в общий массив — оригинал меняется без ошибки. Предохранитель — полный срез s[low:high:max] с max = high: append в этом случае всегда переезжает.

Термины курса

Понятия, на которых строится этот курс, — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылок на практику.

Следующая ступень учебного пути

Go Pro25 уроков~14 чпрактика25 уроков уже доступны

Продвинутые возможности Go: от замыканий до рефлексии

Продвинутый курс Go: замыкания, defer, горутины, каналы, контекст, интерфейсы, дженерики, рефлексия, тесты и JSON. 25 уроков с практикой в браузере.

  • Писать и читать конкурентный код: go-функции, каналы, select
  • Управлять жизненным циклом операций через context
  • Проектировать API на интерфейсах
  • Использовать дженерики без злоупотребления