Олег Марков
Функция append в Go (Golang)
Введение
Функция append в Go — это основной инструмент для работы со срезами, когда вам нужно добавить новые элементы. Вы будете использовать ее почти в каждом проекте на Go, даже если не будете обращать на это внимание.
Смотрите, я покажу вам, как это обычно выглядит в коде:
nums := []int{1, 2, 3}
// Добавляем один элемент
nums = append(nums, 4)
// Добавляем несколько элементов
nums = append(nums, 5, 6, 7)Снаружи все выглядит очень просто: есть срез, вы вызываете append, и появляются новые элементы. Но под капотом происходит перераспределение памяти, изменение длины и емкости, копирование элементов и другие важные вещи.
В этой статье мы подробно разберем:
- как устроены срезы в Go и почему без этого понимания сложно правильно использовать append
- синтаксис append и разные варианты применения
- что происходит с длиной и емкостью среза при добавлении элементов
- как работает перераспределение памяти (reallocation) и когда оно происходит
- распространенные ошибки при использовании append и как их избежать
- приемы оптимизации, которые помогают сократить количество аллокаций памяти
Давайте двигаться по шагам — от базового понимания к более тонким деталям.
Срезы в Go и их связь с append
Чтобы уверенно пользоваться append, важно понимать, что такое срез (slice) в Go. Без этого легко попасть в ловушки с неожиданными изменениями данных и лишними аллокациями.
Что такое срез в Go
Срез — это не просто динамический массив. Это структура, которая содержит три поля:
- указатель на область памяти, где хранятся элементы
- длину (len) — сколько элементов сейчас доступно
- емкость (cap) — сколько элементов можно поместить, не выделяя новую память
В упрощенном виде внутреннее устройство среза можно представить так:
type sliceHeader struct {
Data uintptr // указатель на первый элемент массива
Len int // текущая длина среза
Cap int // емкость среза
}В обычном коде вы этого не видите, но понимание структуры помогает лучше понять поведение append.
Давайте разберемся на примере:
nums := make([]int, 2, 4)
// nums = [0 0], len = 2, cap = 4
nums[0] = 10
nums[1] = 20Комментарии к примеру:
// Срез nums содержит два элемента, но в памяти уже выделено место под четыре
// Это значит, что вы можете добавить еще два элемента без перераспределения памяти
Теперь, если вы вызовете append:
nums = append(nums, 30) // len = 3, cap = 4
nums = append(nums, 40) // len = 4, cap = 4До тех пор, пока длина не превысила емкость, новые элементы просто добавляются в уже выделенный массив.
Почему append возвращает новый срез
Важный момент, который часто вызывает вопросы у новичков: почему нужно присваивать результат append обратно в переменную?
nums := []int{1, 2}
append(nums, 3) // Так делать бессмысленно
// nums все еще []int{1, 2}Смотрите, что происходит:
- функция append может вернуть срез, который указывает на тот же массив, а может создать новый массив
- если не присвоить результат, вы потеряете ссылку на новый (возможно, расширенный) срез
Правильный вариант:
nums := []int{1, 2}
nums = append(nums, 3) // Теперь nums = [1 2 3]Здесь важно помнить: append всегда возвращает срез. Он может указывать на:
- тот же самый базовый массив (если емкости хватило)
- новый массив (если емкости не хватило, и память была выделена заново)
Поэтому всегда присваивайте результат append.
Синтаксис и базовое использование append
Теперь, когда у вас есть представление о срезах, давайте внимательно посмотрим на синтаксис функции append.
Общий вид функции append
Общий вид функции:
newSlice := append(oldSlice, elements...)Где:
// oldSlice — исходный срез
// elements — один или несколько добавляемых элементов
// newSlice — новый срез, который нужно использовать дальше
Простой пример добавления одного элемента:
names := []string{"Ann", "Bob"}
// Добавляем один элемент
names = append(names, "Carl")
// Теперь names = []string{"Ann", "Bob", "Carl"}Добавление нескольких элементов сразу:
nums := []int{1, 2}
// Добавляем сразу три числа
nums = append(nums, 3, 4, 5)
// nums = []int{1, 2, 3, 4, 5}Добавление одного среза в другой
Очень частый сценарий — добавить содержимое одного среза в конец другого. Здесь многие сначала делают так:
a := []int{1, 2}
b := []int{3, 4}
// Так КОМПИЛЯТОР не пропустит
// a = append(a, b)Здесь важно использовать оператор раскрытия среза:
a := []int{1, 2}
b := []int{3, 4}
// Здесь я размещаю пример, чтобы вам было проще понять.
// Оператор ... раскрывает срез b в набор аргументов.
a = append(a, b...)
// Теперь a = []int{1, 2, 3, 4}Комментарии:
// b... означает "передать элементы среза b как обычные аргументы функции"
// без троеточия вы бы попытались передать один аргумент типа []int вместо нескольких int
Добавление элементов к nil-срезу
В Go допустимо создавать nil-срез:
var nums []int // nil-срез, len = 0, cap = 0Если вы попробуете к нему применить append, все будет работать:
var nums []int
nums = append(nums, 10, 20, 30)
// Теперь nums = []int{10, 20, 30}, len = 3, cap >= 3Функция append сама выделит память, создаст базовый массив и вернет корректный срез. Это один из удобных способов пошаговой инициализации среза.
Добавление элементов в срез структур
Срезы со структурами часто используются для хранения сущностей:
type User struct {
ID int
Name string
}
func main() {
var users []User // nil-срез
// Добавляем нового пользователя
users = append(users, User{
ID: 1,
Name: "Alice",
})
// Добавляем сразу двух
users = append(users,
User{ID: 2, Name: "Bob"},
User{ID: 3, Name: "Charlie"},
)
}Комментарии:
// Функция append работает одинаково для любых типов элементов
// Нет разницы, добавляете ли вы int, string или структуру — логика та же
Как меняются длина и емкость при append
Теперь давайте посмотрим глубже на то, как функция append влияет на len и cap, и как это связано с производительностью.
Наблюдаем изменения len и cap
Приведу наглядный пример:
package main
import "fmt"
func main() {
nums := make([]int, 0, 2)
// len = 0, cap = 2
for i := 1; i <= 5; i++ {
nums = append(nums, i)
fmt.Printf("После append(%d): nums = %v, len = %d, cap = %d\n",
i, nums, len(nums), cap(nums))
}
}Комментарии к коду:
// Мы создаем срез с длиной 0 и емкостью 2
// В цикле добавляем 5 чисел подряд
// После каждого append выводим длину и емкость
Типичный вывод (точные значения зависят от реализации, но идея такая):
- после первых двух append емкость остается 2
- затем при третьем добавлении емкость увеличивается, чаще всего в 2 раза (до 4)
- при пятом добавлении емкость снова увеличивается (обычно до 8)
Важно понимать:
- если len < cap — новые элементы помещаются в существующий массив
- если len == cap — append вынужден выделить новый массив, скопировать туда старые элементы и добавить новые
Пример с перераспределением памяти
Давайте посмотрим, как перераспределение влияет на адрес базового массива.
package main
import "fmt"
func main() {
nums := make([]int, 0, 2)
fmt.Printf("Начальный адрес: %p\n", nums)
for i := 1; i <= 5; i++ {
nums = append(nums, i)
fmt.Printf("После append(%d) адрес: %p, len = %d, cap = %d\n",
i, nums, len(nums), cap(nums))
}
}Комментарии:
// %p в fmt.Printf покажет адрес первого элемента среза
// Если адрес изменился — значит, под капотом был создан новый массив
Вы увидите, что после превышения емкости адрес изменится. Это важный сигнал: старые копии среза могут указывать на другой массив, чем новый.
Работа с под-срезами и append: подводные камни
Одно из самых непростых мест — это сочетание под-срезов и append. Здесь чаще всего появляются скрытые баги.
Как создается под-срез
Когда вы создаете под-срез, он по умолчанию "делит" базовый массив с исходным срезом:
nums := []int{10, 20, 30, 40, 50}
sub := nums[1:3] // элементы с индексами 1 и 2: [20 30]
// sub и nums указывают на один и тот же базовый массивКомментарии:
// sub имеет len = 2 (элементы 20 и 30)
// но cap у sub может быть больше, чем 2, потому что базовый массив длиннее
// часто cap(sub) == len(nums) - startIndex
Теперь, если вы будете изменять элементы sub, это изменит и nums:
sub[0] = 200
// Теперь nums = [10 200 30 40 50]Это логично. Но ситуация становится интереснее с append.
Опасность: append к под-срезу меняет исходный срез
Посмотрите на такой пример:
package main
import "fmt"
func main() {
nums := []int{10, 20, 30, 40}
sub := nums[1:3] // [20 30]
fmt.Printf("До: nums = %v, sub = %v\n", nums, sub)
// Добавляем элемент к под-срезу
sub = append(sub, 99)
fmt.Printf("После: nums = %v, sub = %v\n", nums, sub)
}Комментарий:
// У sub была емкость, позволяющая добавить хотя бы один элемент без перераспределения
// Поэтому append записал 99 в базовый массив, который общий с nums
Результат может быть таким:
- nums = [10 20 30 99]
- sub = [20 30 99]
Как видите, этот код выполняет неочевидное изменение исходного среза nums: последний элемент стал 99. Если вы не учитывали общую емкость и общий базовый массив, это может стать неожиданностью.
Как избежать неожиданных изменений
Если вам нужен новый независимый срез, лучше явно скопировать элементы:
nums := []int{10, 20, 30, 40}
sub := nums[1:3] // [20 30]
// Создаем новый срез с такой же длиной
copySub := make([]int, len(sub))
// Копируем элементы sub в новый срез
copy(copySub, sub)
// Теперь copySub не разделяет базовый массив с nums
copySub = append(copySub, 99)Комментарии:
// Теперь append к copySub не повлияет на nums
// Это безопаснее, если вы не хотите менять исходные данные
Еще один прием — ограничить емкость под-среза с помощью "трехаргументного" среза:
nums := []int{10, 20, 30, 40}
sub := nums[1:3:3] // [20 30], len = 2, cap = 2
// Здесь cap(sub) = 2, то есть нет свободного места в базовом массиве
sub = append(sub, 99)
// Теперь будет создан новый массив, и sub станет независимымКомментарии:
// nums[low:high:max] — синтаксис с ограничением емкости
// max задает верхнюю границу емкости под-среза
// это хороший способ защититься от случайного изменения исходного среза через append
Механика перераспределения памяти и рост capacity
Теперь давайте чуть глубже разберемся, как растет емкость срезов и почему это важно для производительности.
Когда выделяется новый массив
Новый базовый массив выделяется, когда вы вызываете append и длина среза после добавления должна превысить текущую емкость. То есть:
- если len + количество добавляемых элементов <= cap — новый массив не нужен
- если больше — будет аллокация и копирование
Покажу вам, как это выглядит в общих чертах:
func AppendInt(slice []int, v int) []int {
if len(slice) < cap(slice) {
// Места достаточно — просто расширяем длину
slice = slice[:len(slice)+1]
slice[len(slice)-1] = v
return slice
}
// Места нет — нужно выделить новый массив
newCap := calcNewCap(cap(slice)) // псевдо-функция рассчета новой емкости
newSlice := make([]int, len(slice)+1, newCap)
// Копируем элементы старого среза
copy(newSlice, slice)
// Добавляем новый элемент
newSlice[len(slice)] = v
return newSlice
}Комментарии:
// Это не реальный код стандартной библиотеки, а упрощенная иллюстрация
// Главное — показать, что иногда создается новый массив и выполняется копирование
Как обычно растет емкость
Точный алгоритм роста емкости зависит от реализации рантайма Go и может меняться между версиями. В общих чертах:
- для маленьких срезов емкость часто удваивается
- для более крупных рост может становиться менее агрессивным (например, увеличение на 25 процентов и т.п.)
Практические выводы:
- многократный append по одному элементу к срезу без заранее заданной емкости приводит к множеству аллокаций и копирований
- если вы заранее знаете (или можете оценить) количество элементов — лучше сразу задать подходящую емкость
Оптимизация работы с append
Теперь давайте посмотрим, как можно сделать работу с append более предсказуемой и эффективной.
Использование make с емкостью
Самый простой способ оптимизировать append — сразу резервировать нужный объем памяти:
// Мы знаем, что нам нужно примерно 100 элементов
nums := make([]int, 0, 100)
// В цикле добавляем элементы
for i := 0; i < 100; i++ {
nums = append(nums, i)
}Комментарии:
// len = 0 — срез изначально пустой
// cap = 100 — выделено место под 100 элементов
// В процессе цикла не будет дополнительных аллокаций памяти
Это особенно полезно:
- при чтении данных из файла или сети, когда вы знаете примерный размер
- при обработке массивов фиксированного размера
- при сборе результатов вычислений заранее известной длины
append vs ручное управление длиной
Иногда можно обойтись без append и управлять длиной среза вручную:
nums := make([]int, 100) // len = 100, cap = 100
for i := 0; i < 100; i++ {
nums[i] = i
}Комментарии:
// Здесь мы создали срез сразу нужной длины
// Не было ни одного вызова append, но по сути результат тот же
// Такой подход полезен, когда длина точно известна заранее
Но чаще удобнее использовать append, потому что он:
- автоматически увеличивает длину
- может работать с частично заполненными срезами
- удобен, когда длина не известна точно
Предупреждение о лишних аллокациях
Частая ошибка — многократно увеличивать срез без оценки емкости:
var data []int // nil-срез
for i := 0; i < 1_000_000; i++ {
data = append(data, i)
}Такой код будет работать, но создаст много промежуточных массивов. Лучше так:
// Мы предполагаем, что элементов будет около миллиона
data := make([]int, 0, 1_000_000)
for i := 0; i < 1_000_000; i++ {
data = append(data, i)
}Разница в производительности и количестве аллокаций может быть существенной.
Частые ошибки при использовании append
Теперь давайте соберем в одном месте типичные ошибки и неудобные ситуации, с которыми часто сталкиваются разработчики.
Ошибка: забыли присвоить результат append
Классический пример:
nums := []int{1, 2, 3}
append(nums, 4)
// nums все еще [1 2 3]Правильно:
nums := []int{1, 2, 3}
nums = append(nums, 4)Комментарии:
// Помните, что append возвращает новый (или модифицированный) срез
// Если не использовать результат — изменения не сохранятся в вашей переменной
Ошибка: ожидание независимости под-среза
Пример:
nums := []int{1, 2, 3, 4}
sub := nums[:2] // [1 2]
// Разработчик думает, что sub — независимая копия
sub = append(sub, 99)Чего ожидали:
// nums: [1 2 3 4]
// sub: [1 2 99]
Что реально может получиться:
// nums: [1 2 99 4]
// sub: [1 2 99]
Комментарии:
// Проблема в том, что sub использует тот же базовый массив
// При append изменяется часть исходного массива
Решение — использовать копирование или трехаргументный срез, как мы разбирали выше.
Ошибка: изменение одного среза неожиданно влияет на другой
Еще один пример, который часто вызывает вопросы:
a := []int{1, 2, 3}
b := a // b указывает на тот же срез
b = append(b, 4) // что произойдет с a?
fmt.Println("a:", a)
fmt.Println("b:", b)Здесь возможны два сценария (в зависимости от емкости):
- Если емкости a хватило, чтобы вместить добавленный элемент:
// a и b указывают на один массив
// оба станут [1 2 3 4]
- Если емкости не хватило:
// для b будет создан новый массив
// a останется [1 2 3]
// b станет [1 2 3 4]
То есть результат зависит от cap. Это делает код неочевидным.
Безопасный подход:
- не полагаться на неявное совместное использование базового массива
- при необходимости независимости явно копировать данные
a := []int{1, 2, 3}
b := make([]int, len(a))
copy(b, a) // теперь b независим от a
b = append(b, 4) // не влияет на aОшибка: забыли про раскрытие среза (b...)
Еще одна типичная ситуация:
a := []int{1, 2}
b := []int{3, 4}
// Попытка добавить b в a:
a = append(a, b) // ошибка компиляцииКомпилятор скажет, что типы не совпадают: он ожидал элементы типа int, а получил один аргумент типа []int.
Правильно:
a := []int{1, 2}
b := []int{3, 4}
a = append(a, b...) // раскрываем b в набор аргументовКомментарии:
// a = append(a, b...) — добавит 3 и 4 как отдельные элементы
// a = append(a, b) — пыталось бы добавить сам срез b как один элемент, что невозможно
Продвинутые приемы с append
Теперь давайте посмотрим на несколько более практических паттернов, где append используется чуть интереснее, чем просто "добавить в конец".
Построение среза шаг за шагом
Частый сценарий — когда вы проходите по данным и по определенному условию добавляете элементы:
func FilterEven(nums []int) []int {
// Создаем результирующий срез с нулевой длиной
// Можно оценить емкость как len(nums) / 2, если ожидаете примерно половину четных
result := make([]int, 0, len(nums))
for _, n := range nums {
if n%2 == 0 {
result = append(result, n)
}
}
return result
}Комментарии:
// append здесь удобно сочетать с логикой фильтрации
// make с заранее оцененной емкостью уменьшит количество аллокаций
Объединение нескольких срезов
Если у вас есть несколько срезов одного типа, их можно объединить в один:
func Concat[T any](slices ...[]T) []T {
// Сначала оценим общую длину, чтобы сразу выделить нужную емкость
totalLen := 0
for _, s := range slices {
totalLen += len(s)
}
result := make([]T, 0, totalLen)
for _, s := range slices {
result = append(result, s...)
}
return result
}Комментарии:
// Здесь я показываю пример с дженериками, чтобы вам было проще адаптировать его
// Мы сначала считаем общую длину, затем делаем один большой срез и постепенно добавляем в него данные
// Это экономит память и ускоряет выполнение
Использование append для "копирования" среза
Интересный прием — использовать append для создания копии среза:
original := []int{1, 2, 3}
// Создаем новый срез и добавляем в него элементы original
copySlice := append([]int(nil), original...)
// или
copySlice2 := append([]int{}, original...)Комментарии:
// append([]int(nil), original...) создает новый срез, независимый от original
// оба варианта создают новый базовый массив и копируют туда элементы
// это удобный способ быстро скопировать срез
"Удаление" элементов через append
В Go нет встроенного удаления элемента из середины среза, но с помощью срезов и append можно сделать это довольно просто.
Удаление элемента по индексу:
func RemoveIndex(nums []int, idx int) []int {
if idx < 0 || idx >= len(nums) {
// Индекс вне диапазона — возвращаем исходный срез
return nums
}
// Давайте посмотрим, что происходит в этом выражении.
// nums[:idx] — элементы до удаляемого
// nums[idx+1:] — элементы после удаляемого
return append(nums[:idx], nums[idx+1:]...)
}Комментарии:
// Мы создаем новый срез, который "перепрыгивает" через элемент с индексом idx
// Внутри это может изменить базовый массив, поэтому будьте аккуратны, если где-то еще храните ссылки на nums
Удаление диапазона элементов:
func RemoveRange(nums []int, from, to int) []int {
if from < 0 {
from = 0
}
if to > len(nums) {
to = len(nums)
}
if from >= to {
return nums
}
// Оставляем элементы до from и после to
return append(nums[:from], nums[to:]...)
}Комментарии:
// Здесь мы вырезаем целый диапазон элементов
// Это стандартный прием в Go при работе со срезами
Заключение
Функция append в Go — это фундаментальный инструмент для работы со срезами. На уровне синтаксиса она выглядит очень просто, но под капотом скрывает важные особенности, которые напрямую влияют на корректность программы и ее производительность.
Кратко зафиксируем ключевые моменты, которые стоит держать в голове:
- append всегда возвращает срез, поэтому результат нужно присваивать
- срез — это "обертка" над массивом, содержащая указатель, длину и емкость
- при превышении емкости создается новый массив, и элементы копируются в него
- под-срезы по умолчанию разделяют базовый массив с исходным срезом, что может приводить к неожиданным изменениям
- оператор ... (раскрытие среза) необходим при добавлении одного среза в другой
- заранее задавая емкость с помощью make, вы можете сильно сократить количество аллокаций и ускорить код
- для независимости данных используйте копирование (copy или append к пустому срезу) и при необходимости трехаргументные срезы
Теперь, когда вы понимаете, как работает append, вы можете осознанно применять его в повседневном коде и избегать типичных ловушек, связанных с срезами и динамическим ростом.
Частозадаваемые технические вопросы
Как безопасно передать срез в функцию, которая использует append, чтобы она не меняла исходный массив?
Если вы передадите срез в функцию и внутри нее будет append, то:
- при достаточной емкости изменения могут затронуть исходный базовый массив
- при перераспределении памяти изменения останутся только внутри функции
Чтобы гарантировать, что исходный массив не будет затронут, сделайте копию среза до передачи или в начале функции:
func Process(data []int) []int {
// Делаем копию входных данных
copyData := append([]int(nil), data...)
// Работаем только с копией
copyData = append(copyData, 100)
return copyData
}Так вы гарантируете, что исходный срез data не изменится.
Можно ли использовать append с срезами, размещенными в статических массивах?
Да, если вы создаете срез поверх массива, то append будет работать, пока не будет превышена емкость массива. Например:
var arr [5]int
s := arr[:0] // len = 0, cap = 5
s = append(s, 1, 2, 3)
// Пока len <= 5, элементы помещаются в arrЕсли вы превысите емкость:
s = append(s, 4, 5, 6) // здесь потребуется новый массивПосле этого s перестанет указывать на arr, потому что будет выделен новый базовый массив. Сам arr не изменится для новых элементов.
Как понять, что append привел к аллокации памяти, в реальном проекте?
Для анализа аллокаций удобно использовать флаг go test -run=NONE -bench=. -benchmem или профилировщик pprof. Но есть и простой прием для локальных экспериментов: вы можете печатать адрес первого элемента среза до и после append.
fmt.Printf("%p\n", slice) // адрес до
slice = append(slice, x)
fmt.Printf("%p\n", slice) // адрес послеЕсли адрес изменился — была аллокация и создание нового массива. Это удобно при оптимизации "узких мест" кода.
Как с помощью append реализовать стек (push/pop) без лишних аллокаций?
Для стека на срезах удобно использовать append и операцию "обрезки" среза:
type Stack []int
func (s *Stack) Push(v int) {
*s = append(*s, v)
}
func (s *Stack) Pop() (int, bool) {
if len(*s) == 0 {
return 0, false
}
last := (*s)[len(*s)-1]
*s = (*s)[:len(*s)-1]
return last, true
}Чтобы снизить количество аллокаций, можно изначально задать емкость:
st := make(Stack, 0, 1000)Так стек выдержит до 1000 push без увеличения емкости.
Можно ли "освободить" память, уменьшив срез после большого append?
Если вы просто уменьшите длину среза:
nums = nums[:10]емкость при этом останется прежней, и большой массив продолжит занимать память. Чтобы позволить сборщику мусора освободить лишнее, можно "отсечь" емкость, создав новый срез:
tmp := make([]int, len(nums))
copy(tmp, nums)
nums = tmp // теперь cap(nums) == len(nums)Или использовать трехаргументный срез:
nums = nums[:10:10] // len = 10, cap = 10Теперь старый большой массив станет кандидатом на освобождение, если на него больше нет ссылок.
Постройте личный план изучения Html до уровня Middle — бесплатно!
Html — часть карты развития Frontend
100+ шагов развития
30 бесплатных лекций
300 бонусных рублей на счет
Все гайды по Html
Лучшие курсы по теме

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