иконка discount

Скидка 15% по промокоду

кибер понедельник до 01.12иконка discount
CYBER2025
логотип PurpleSchool
логотип PurpleSchool

Функция 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 ведет себя так:

  1. Сначала проверяет, достаточно ли вместимости для новых элементов.
  2. Если cap позволяет — просто записывает элементы "в хвост" существующего массива и увеличивает len.
  3. Если вместимости не хватает — выделяет новый массив большего размера, копирует элементы из старого массива в новый, добавляет новые элементы и возвращает срез, указывающий на новый массив.

Посмотрим на пример.

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:

  1. выделяет новый массив большего размера;
  2. копирует в него все существующие элементы;
  3. добавляет новые элементы;
  4. возвращает новый срез.

Если вы делаете это в цикле много раз при росте данных, может получиться лишняя нагрузка на память и 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)

Компилятор генерирует код, который:

  1. Проверяет текущую длину и вместимость среза s.
  2. Если cap(s) > len(s), то:
    • записывает x по индексу len(s) в базовый массив;
    • увеличивает len(s) на 1;
    • возвращает срез с обновленной длиной (но тем же указателем и вместимостью).
  3. Если вместимости нет:
    • вычисляет новую вместимость (обычно примерно в 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.

Функция append в Go GolangСтрелочка вправо

Постройте личный план изучения Html до уровня Middle — бесплатно!

Html — часть карты развития Frontend

  • step100+ шагов развития
  • lessons30 бесплатных лекций
  • lessons300 бонусных рублей на счет

Все гайды по Html

Тег section в HTML - семантическая разметка структуры страницыТег nav в HTML - полное руководство по семантической навигацииТег main в HTML - подробное руководство по использованиюТег header в HTML - полное практическое руководствоТег figure в HTML - как правильно оформлять иллюстрации и подписиТег footer в HTML - назначение семантика и практические примерыТег figcaption в HTML - подробное руководство с примерамиТег aside в HTML - назначение правильная семантика и примеры
Текстовая область HTML textarea - практическое руководствоВыпадающий список HTML select - полное руководство для разработчиковОпция списка HTML option - как работает и как правильно использоватьАтрибут method в HTML - как правильно отправлять данные формыЗаголовок группы HTML legend - как правильно использовать и оформлятьТег input в HTML - типы атрибуты валидация и примерыТег формы form в HTMLГруппа полей HTML fieldsetАтрибут action в HTML - как правильно задавать адрес отправки формы
Открыть базу знаний

Лучшие курсы по теме

изображение курса

HTML и CSS

Антон Ларичев
AI-тренажеры
Практика в студии
Гарантия
Бонусы
иконка звёздочки рейтинга4.9
3 999 ₽ 6 990 ₽
Подробнее
изображение курса

TypeScript с нуля

Антон Ларичев
AI-тренажеры
Практика в студии
Гарантия
Бонусы
иконка звёздочки рейтинга4.8
3 999 ₽ 6 990 ₽
Подробнее
изображение курса

Next.js - с нуля

Антон Ларичев
AI-тренажеры
Практика в студии
Гарантия
Бонусы
иконка звёздочки рейтинга4.7
3 999 ₽ 6 990 ₽
Подробнее

Отправить комментарий