Олег Марков
Функция append в Go (Golang)
Введение
Функция append в Go — один из самых часто используемых инструментов при работе со срезами. С её помощью вы добавляете элементы в конец среза, не задумываясь каждый раз о выделении памяти, копировании данных и изменении длины.
На первый взгляд может показаться, что append — очень простая функция. Вы просто "добавляете" значения к срезу, и всё работает. Но под капотом происходит достаточно интересная работа с памятью, вместимостью (capacity) и копированием. Если понимать эти детали, становится проще писать эффективный и предсказуемый код, избегать лишних аллокаций и неожиданных побочных эффектов.
В этой статье мы разберем:
- базовый синтаксис и поведение append;
- как работает расширение среза;
- чем длина отличается от вместимости и почему это важно;
- как append ведет себя с nil-срезами;
- особенности добавления нескольких элементов и других срезов;
- типичные ошибки и подводные камни;
- практические приемы оптимизации.
Смотрите, я покажу вам на простых примерах, как устроена работа append, а затем мы постепенно углубимся в детали.
Что такое срезы и почему для них нужен append
Прежде чем разбирать append, важно зафиксировать, что такое срез (slice) в Go.
Срез — это не просто динамический массив. Это структура, которая содержит:
- указатель на участок памяти с элементами;
- длину (len) — количество "активных" элементов;
- вместимость (cap) — сколько элементов можно разместить в этой памяти, не перевыделяя ее.
Внутренне срез можно представить примерно так (упрощенно):
type sliceHeader struct {
Data uintptr // указатель на первый элемент массива
Len int // длина среза
Cap int // вместимость среза
}Когда вы создаете срез, вы можете:
- либо "отрезать" его от существующего массива;
- либо выделить новый массив через make.
Добавление новых элементов "вручную" означало бы необходимость постоянно отслеживать, хватает ли памяти, и если нет — выделять новый массив и копировать данные. Это неудобно и легко ошибиться. Функция append решает эту задачу автоматически.
Базовый синтаксис функции append
Функция append имеет следующий общий вид:
result := append(slice, values...)slice— исходный срез, в который вы хотите добавить элементы;values...— один или несколько элементов того же типа, что и элементы среза (или другой срез того же типа, "распакованный" через...);result— новый срез (или тот же самый, если вместимости хватило).
Давайте разберемся на самом простом примере.
package main
import "fmt"
func main() {
// Создаем срез с тремя элементами
numbers := []int{1, 2, 3}
// Добавляем один элемент в конец
numbers = append(numbers, 4) // важно присвоить результат обратно
// Выводим результат
fmt.Println(numbers) // [1 2 3 4]
}Комментарии к примеру:
- Здесь мы создаем срез
numbersс тремя элементами. - Вызываем append, чтобы добавить число 4.
- Обратите внимание — мы присваиваем результат обратно
numbers. Это важно: append может вернуть новый срез, который указывает на другой участок памяти.
Если забыть про присваивание, поведение будет неожиданным:
// Плохо - результат append игнорируется
numbers := []int{1, 2, 3}
append(numbers, 4) // результат никуда не сохранен
fmt.Println(numbers) // [1 2 3] - 4 не добавилсяКак append работает с длиной и вместимостью
Теперь давайте аккуратно разберем, что происходит внутри.
Длина (len) и вместимость (cap)
Длина среза — количество элементов, которое "видит" ваш код:
len(slice) // сколько элементов фактически в срезеВместимость — сколько элементов можно еще разместить в той же памяти:
cap(slice) // максимальное количество элементов, которое помещается без аллокацийAppend ведет себя так:
- Сначала проверяет, достаточно ли вместимости для новых элементов.
- Если cap позволяет — просто записывает элементы "в хвост" существующего массива и увеличивает len.
- Если вместимости не хватает — выделяет новый массив большего размера, копирует элементы из старого массива в новый, добавляет новые элементы и возвращает срез, указывающий на новый массив.
Посмотрим на пример.
package main
import "fmt"
func main() {
// Создаем срез длиной 0, но вместимостью 3
numbers := make([]int, 0, 3)
fmt.Println(len(numbers), cap(numbers)) // 0 3
numbers = append(numbers, 1)
fmt.Println(numbers, len(numbers), cap(numbers)) // [1] 1 3
numbers = append(numbers, 2, 3)
fmt.Println(numbers, len(numbers), cap(numbers)) // [1 2 3] 3 3
// Теперь вместимость исчерпана, следующий append приведет к аллокации
numbers = append(numbers, 4)
fmt.Println(numbers, len(numbers), cap(numbers)) // [1 2 3 4] 4 ? (обычно 6 или 8)
}Комментарии:
- В начале длина 0, вместимость 3 — памяти хватает на 3 элемента.
- Первые три append не вызывают выделения новой памяти: элементы записываются в уже существующий массив.
- Когда мы добавляем четвертый элемент, вместимости не хватает — Go выделяет новый массив большего размера. Точная стратегия роста может меняться от версии к версии, но общая идея — вместимость увеличивается как минимум в 2 раза, а иногда чуть сложнее в зависимости от размера.
Важный момент — срез может "переселиться" в новый массив
Именно поэтому так важно всегда присваивать результат append обратно переменной. Теперь давайте посмотрим, как это может повлиять на несколько переменных, указывающих на один и тот же массив.
Совместное использование памяти и побочные эффекты append
Когда вы создаете новый срез через "срезание" (slice[low:high]), он может разделять один и тот же базовый массив с исходным срезом. Если затем вы вызываете append на одном из таких срезов, то при достаточной вместимости вы будете изменять общую память — и это отразится на другом срезе.
Пример совместного массива
package main
import "fmt"
func main() {
src := []int{1, 2, 3, 4, 5}
// Берем срез из первых трех элементов
a := src[:3] // [1 2 3]
// Берем срез из последних двух элементов
b := src[3:] // [4 5]
fmt.Println("a:", a, "len:", len(a), "cap:", cap(a))
fmt.Println("b:", b, "len:", len(b), "cap:", cap(b))
// Добавляем элемент в a
a = append(a, 10) // если cap(a) >= 4, то запись идет в тот же массив
fmt.Println("После append:")
fmt.Println("src:", src)
fmt.Println("a:", a)
fmt.Println("b:", b)
}Здесь важно следующее:
- a и b разделяют один и тот же базовый массив
src. - Если вместимость
aпозволяет вписать еще один элемент без аллокации, тоappend(a, 10)просто запишет новый элемент в общую память. - Это может изменить данные, которые видит
bилиsrc.
Чтобы избежать таких побочных эффектов, часто создают копию среза перед дальнейшей модификацией. Покажу, как это делается.
Избежание общих побочных эффектов через копирование
package main
import "fmt"
func main() {
src := []int{1, 2, 3, 4, 5}
// Создаем срез
a := src[:3] // [1 2 3]
// Делаем "безопасную" копию a с нулевой длиной, но тем же содержимым
safe := append([]int(nil), a...) // копируем данные в новый массив
// Теперь модифицируем safe, не затрагивая src
safe = append(safe, 10)
fmt.Println("src:", src) // [1 2 3 4 5]
fmt.Println("a:", a) // [1 2 3]
fmt.Println("safe:", safe) // [1 2 3 10]
}Комментарий:
- Здесь мы создаем новый срез
safe, используя append с nil-срезом. - Такой прием распространен:
append([]T(nil), slice...)создает копию срезаsliceв новой памяти. - Все последующие append к
safeбольше не затрагивают исходныйsrc.
Работа append с nil-срезами
Nil-срез — это срез, который не указывает ни на какой массив. Его значение по умолчанию — nil.
var s []int // s == nil
fmt.Println(s == nil) // true
fmt.Println(len(s)) // 0
fmt.Println(cap(s)) // 0Интересная особенность Go: append корректно работает с nil-срезами. Это значит, вы можете спокойно начинать наполнять срез с "нуля" без предварительного вызова make.
package main
import "fmt"
func main() {
var s []int // nil-срез
// Добавляем элементы через append
s = append(s, 1)
s = append(s, 2, 3)
fmt.Println(s) // [1 2 3]
fmt.Println(len(s)) // 3
fmt.Println(cap(s) > 0) // true - вместимость уже больше нуля
}Отсюда следует практический вывод: вам не обязательно вызывать make для среза, если вы просто собираетесь постепенно наполнять его append-ами. Nil-срез в этом плане ведет себя как "пустой, но готовый к использованию" контейнер.
Но есть нюанс: если вы знаете, сколько элементов примерно будет в срезе, выгоднее сразу выделить нужную вместимость через make — это снизит количество аллокаций.
Добавление нескольких значений и других срезов
Функция append поддерживает добавление:
- одного элемента;
- нескольких значений;
- другого среза (через
...).
Добавление нескольких значений
package main
import "fmt"
func main() {
s := []string{"a"}
// Добавляем сразу несколько строк
s = append(s, "b", "c", "d")
fmt.Println(s) // [a b c d]
}Здесь я размещаю пример, чтобы вам было проще увидеть, что список добавляемых элементов может быть произвольной длины.
Добавление одного среза в другой
Чтобы добавить содержимое одного среза в другой, используется "распаковка" через троеточие.
package main
import "fmt"
func main() {
a := []int{1, 2, 3}
b := []int{4, 5, 6}
// Добавляем все элементы b в конец a
a = append(a, b...) // троеточие "раскрывает" срез b в список аргументов
fmt.Println(a) // [1 2 3 4 5 6]
}Если забыть троеточие, код даже не скомпилируется, потому что append ожидает элементы типа int, а не срез int.
// Неправильно - так не скомпилируется
// a = append(a, b) // ошибка типовРасширение и предварительное резервирование вместимости
Теперь давайте перейдем к вопросу, который часто волнует тех, кто беспокоится о производительности: как сделать так, чтобы append не вызывал слишком много аллокаций и копирований.
Почему это важно
Когда cap среза исчерпывается, append:
- выделяет новый массив большего размера;
- копирует в него все существующие элементы;
- добавляет новые элементы;
- возвращает новый срез.
Если вы делаете это в цикле много раз при росте данных, может получиться лишняя нагрузка на память и GC (сборщик мусора).
Резервирование вместимости через make
Если вы примерно знаете, сколько элементов будет в срезе, можно заранее зарезервировать нужную вместимость.
package main
import "fmt"
func main() {
n := 1000
// Создаем срез длиной 0, но вместимостью n
result := make([]int, 0, n)
for i := 0; i < n; i++ {
result = append(result, i) // здесь, скорее всего, не будет аллокаций
}
fmt.Println(len(result), cap(result)) // 1000 1000
}Комментарий:
- Здесь мы создаем срез, который сразу имеет достаточную вместимость для 1000 элементов.
- Append работает только с увеличением длины и не вынужден копировать массив заново.
Частный прием — "grow" паттерн
Иногда вы не знаете точный размер, но можете постепенно растить вместимость. В Go это за вас делает сам append, но вы можете иногда подсматривать len и cap, чтобы понимать, происходят ли аллокации.
package main
import "fmt"
func main() {
s := make([]int, 0, 1)
for i := 0; i < 10; i++ {
oldCap := cap(s)
s = append(s, i)
if cap(s) != oldCap {
// Здесь мы видим, что произошла аллокация и рост вместимости
fmt.Println("grow cap from", oldCap, "to", cap(s))
}
}
fmt.Println("final slice:", s)
}Этот пример помогает вам увидеть, как часто расширяется срез и насколько растет вместимость.
Поведение append при передаче среза в функцию
Срез в Go — это значение, в котором лежит "заголовок" (указатель, длина, вместимость). Когда вы передаете его в функцию, он копируется по значению, но оба экземпляра "смотрят" на один и тот же базовый массив.
Это важно для понимания того, когда изменения длины (через append) будут видны снаружи, а когда нет.
Пример увеличения среза внутри функции
package main
import "fmt"
// Добавляет элемент к срезу, полученному по значению
func addElement(s []int, v int) {
s = append(s, v) // изменение длины среза относится к локальной копии
// Здесь s - новый или модифицированный заголовок, но внешний срез не меняется
}
func main() {
s := []int{1, 2, 3}
addElement(s, 4)
fmt.Println(s) // [1 2 3] - 4 "пропал"
}Как видите, этот код выполняет append, но внешний срез не увеличивается, потому что функция оперирует копией "заголовка" среза. При этом, если вместимость была достаточной и append не сделал новую аллокацию, внутренние элементы могли измениться (но длина снаружи не изменилась).
Чтобы изменение длины было видно снаружи, есть два распространенных подхода.
Подход 1 — вернуть срез из функции
package main
import "fmt"
// Функция возвращает новый срез
func addElement(s []int, v int) []int {
s = append(s, v)
return s
}
func main() {
s := []int{1, 2, 3}
s = addElement(s, 4) // обязательно присваиваем результат
fmt.Println(s) // [1 2 3 4]
}Это самый прозрачный и часто используемый способ.
Подход 2 — использовать указатель на срез
package main
import "fmt"
// Функция принимает указатель на срез и изменяет его "на месте"
func addElementPtr(s *[]int, v int) {
*s = append(*s, v)
}
func main() {
s := []int{1, 2, 3}
addElementPtr(&s, 4) // передаем адрес среза
fmt.Println(s) // [1 2 3 4]
}Такой стиль иногда используют в библиотеках, где важно явно показывать, что функция модифицирует срез.
Особые случаи и "хитрые" приемы с append
Давайте посмотрим на несколько распространенных задач, которые решаются через append.
Удаление элемента из среза
В Go нет встроенной функции удаления элемента по индексу, но это легко реализовать с помощью append.
Предположим, у нас есть срез, и нужно удалить элемент по индексу i (без сохранения порядка можно сделать иначе, но здесь сохраним порядок):
package main
import "fmt"
func main() {
s := []int{1, 2, 3, 4, 5}
i := 2 // удаляем элемент с индексом 2 (значение 3)
// "Склеиваем" часть до i и часть после i
s = append(s[:i], s[i+1:]...)
fmt.Println(s) // [1 2 4 5]
}Комментарии:
s[:i]— все элементы до удаляемого (не включая его).s[i+1:]— все элементы после удаляемого.- Через append мы объединяем эти две части в один срез.
- Обратите внимание, что в реальности в памяти элемент может остаться "живым" до тех пор, пока есть ссылки на базовый массив, но логически он из среза исчез.
Очистка среза
Чтобы "очистить" срез (но оставить ту же вместимость), можно просто обрезать его до длины 0 через срезание, без append:
s = s[:0] // длина 0, вместимость прежняяНо иногда удобно использовать append для мягкой очистки через nil:
s = nil // полноценный nil-срезAppend одинаково хорошо работает и с пустыми, и с nil-срезами.
Конкатенация нескольких срезов
Допустим, есть несколько срезов, которые нужно "склеить" в один.
package main
import "fmt"
func concat(slices ...[]int) []int {
// Считаем общую длину, чтобы сразу выделить нужную вместимость
totalLen := 0
for _, s := range slices {
totalLen += len(s)
}
// Создаем результирующий срез
result := make([]int, 0, totalLen)
// Последовательно добавляем все срезы
for _, s := range slices {
result = append(result, s...)
}
return result
}
func main() {
a := []int{1, 2}
b := []int{3, 4}
c := []int{5}
res := concat(a, b, c)
fmt.Println(res) // [1 2 3 4 5]
}Здесь вы видите комбинацию идеи предварительного резервирования вместимости и использования append с ....
Типичные ошибки при использовании append
Теперь давайте посмотрим, какие проблемы чаще всего встречаются у разработчиков при работе с append.
1. Забыли присвоить результат append
Мы уже касались этого, но повторю, потому что ошибка очень распространенная.
s := []int{1, 2, 3}
append(s, 4) // результат игнорируется
fmt.Println(s) // [1 2 3]Решение: всегда присваивайте результат обратно (или в новую переменную):
s = append(s, 4)2. Ожидание, что append внутри функции изменит внешний срез
Когда вы передаете срез в функцию по значению и там вызываете append, изменение длины не "видно" снаружи, если вы не возвращаете результат или не работаете через указатель. Мы уже разобрали примеры с функциями addElement и addElementPtr.
Решение:
- либо возвращать срез из функции и присваивать результат;
- либо передавать указатель на срез.
3. Неожиданные изменения из-за общих базовых массивов
Это бывает, когда вы создаете несколько срезов из одного массива или среза, а потом вызываете append. Вы уже видели пример с src, a и b.
Решение:
- если вы не хотите делить память, создавайте независимую копию через append к nil-срезу или через copy;
- контролируйте len и cap, когда режете срезы.
4. Утечки памяти через "хвост" среза
Когда вы берете подмножество элементов большого среза, новый срез продолжает держать ссылку на весь базовый массив. Если это большой массив, а новый срез маленький, вы можете "держать в памяти" лишние данные.
Пример:
package main
import "fmt"
func main() {
// Представим, что это большой срез
big := make([]byte, 1_000_000)
// Нам нужно только первые 10 байт
small := big[:10]
// Теперь small "держит" всю память big, хотя мы используем только 10 байт
fmt.Println(len(small), cap(small)) // 10 1000000
}Чтобы "отпустить" лишнюю память, можно скопировать нужную часть в новый срез:
smallCopy := append([]byte(nil), small...) // или make+copyТеперь smallCopy держит только свой небольшой массив.
Как append работает с разными типами
Функция append — это обобщенная (generic-подобная) встроенная функция компилятора. Она работает с любыми срезами: чисел, строк, структур, интерфейсов и так далее. Главное условие — тип добавляемых элементов должен совпадать с типом элементов среза.
Пример с пользовательским типом
package main
import "fmt"
type Point struct {
X, Y int
}
func main() {
points := []Point{
{X: 1, Y: 2},
}
// Добавляем новые точки
points = append(points,
Point{X: 3, Y: 4},
Point{X: 5, Y: 6},
)
fmt.Println(points)
// [{1 2} {3 4} {5 6}]
}Так же это работает и со срезами интерфейсов:
package main
import "fmt"
func main() {
var any []interface{} // nil-срез интерфейсов
// Добавляем значения разных типов
any = append(any, 10, "hello", 3.14)
fmt.Println(any)
}Здесь все элементы приводятся к типу interface{} при добавлении.
Под капотом: что компилятор делает с append
Давайте вкратце, без излишней сложности, посмотрим, что делает компилятор Go, когда видит вызов append.
Когда вы пишете:
s = append(s, x)Компилятор генерирует код, который:
- Проверяет текущую длину и вместимость среза s.
- Если cap(s) > len(s), то:
- записывает x по индексу len(s) в базовый массив;
- увеличивает len(s) на 1;
- возвращает срез с обновленной длиной (но тем же указателем и вместимостью).
- Если вместимости нет:
- вычисляет новую вместимость (обычно примерно в 2 раза больше, иногда чуть иначе для больших массивов);
- выделяет новую память нужного размера;
- копирует старые элементы в новый массив;
- записывает x в конец;
- возвращает срез с новым указателем, длиной и вместимостью.
Важно понимать: все эти детали — ответственность рантайма и компилятора. Вам не нужно реализовывать это вручную, но понимание процесса помогает объяснить:
- почему важно присваивать результат;
- почему иногда меняется адрес базового массива;
- откуда берутся аллокации и копирование памяти.
Заключение
Вы увидели, что функция append в Go — не просто "добавить элемент в конец", а полноценный механизм динамического роста срезов с автоматическим управлением памятью.
Кратко зафиксируем основные моменты:
- append всегда возвращает срез и может (но не обязан) выделять новую память;
- результат append нужно присваивать обратно (или в новую переменную);
- len — это текущий размер среза, cap — сколько элементов он может вместить без новых аллокаций;
- срезы могут разделять один и тот же базовый массив, и append при достаточной вместимости будет изменять общую память;
- nil-срез — нормальное начальное состояние, с ним можно смело использовать append;
- если вы знаете ожидаемый размер, имеет смысл заранее зарезервировать вместимость через make;
- при передаче среза в функцию append не меняет внешний срез, если вы не возвращаете его или не работаете через указатель;
- через append удобно реализовывать такие операции, как конкатенация срезов и удаление элемента с сохранением порядка.
Если вы держите в голове эти принципы, работа с append становится предсказуемой, код — более эффективным, а неожиданных побочных эффектов — заметно меньше.
Частозадаваемые технические вопросы по теме и ответы
Как удалить элемент из среза без сохранения порядка быстрее всего
Если порядок элементов не важен, можно просто заменить удаляемый элемент последним и укоротить срез:
// s - срез, i - индекс удаляемого элемента
s[i] = s[len(s)-1] // переносим последний элемент на место удаляемого
s = s[:len(s)-1] // уменьшаем длину на 1Так вы избегаете копирования "хвоста" среза, что быстрее при больших объемах данных.
Как безопасно использовать append при параллельной записи в один и тот же срез
Срезы не потокобезопасны. Если несколько горутин одновременно вызывают append на одном и том же срезе, возможны гонки данных и повреждение памяти. Решения:
- защищать доступ к срезу через sync.Mutex;
- или организовать "владение" срезом одной горутиной и передавать данные через каналы;
- либо каждый поток пишет в свой срез, а затем вы объединяете их в одном потоке.
Как понять, происходит ли аллокация памяти при вызове append
Можно использовать флаг -gcflags="-m" при компиляции или запуске go build / go run, чтобы посмотреть escape-анализ и подсказки компилятора. Или профилирование (pprof) с включенной метрикой аллокаций памяти. Также помогает отладочный вывод len и cap до и после append.
Как задать точную вместимость без начальной длины и сразу заполнить элементы по индексам
Создайте срез длиной 0 и нужной вместимостью, затем используйте append или увеличьте длину вручную через срезание:
s := make([]int, 0, n) // len 0, cap n
s = s[:n] // теперь len n, cap n
// Теперь можете присваивать по индексам s[i] = ...Но помните, что при s = s[:n] элементы инициализируются нулевыми значениями.
Можно ли использовать append для копирования данных между срезами вместо copy
Можно, но copy обычно понятнее по смыслу и иногда чуть эффективнее:
dst := make([]int, len(src))
copy(dst, src) // явное копирование
// через append - тоже копирование
dst := append([]int(nil), src...)Если вы именно копируете один срез в другой, лучше использовать copy. Если одновременно что-то добавляете и комбинируете, удобнее append.
Постройте личный план изучения Html до уровня Middle — бесплатно!
Html — часть карты развития Frontend
100+ шагов развития
30 бесплатных лекций
300 бонусных рублей на счет
Все гайды по Html
Лучшие курсы по теме

HTML и CSS
Антон Ларичев
TypeScript с нуля
Антон Ларичев