Функция append в Go Golang

19 февраля 2026
Автор

Олег Марков

Введение

Методология слайсов (slicing-methodology) в Go — это совокупность приемов, подходов и шаблонов, которые помогают работать со срезами осознанно и безопасно. Сами по себе срезы уже дают удобный интерфейс поверх массивов, но без понимания того, как они устроены внутри, легко столкнуться с лишними аллокациями, утечками памяти, неожиданным изменением данных и сложными для отладки эффектами.

Задача этой статьи — показать вам, как думать о слайсах как о фундаментальном строительном блоке Go-кода, и выстроить системный подход к их использованию:

  • как устроен слайс внутри;
  • как влияет длина и емкость на поведение;
  • как правильно нарезать (slice) данные;
  • как использовать функцию append и не попадать в ловушки;
  • как строить API и внутреннюю архитектуру с учетом особенностей слайсов;
  • как избегать типичных ошибок, связанных с памятью и производительностью.

Смотрите, я буду последовательно переходить от теории к практике и показывать примеры, чтобы вы могли сразу применять методологию слайсов в своем коде.

Что такое слайс в Go с точки зрения методологии

Внутреннее устройство среза

Чтобы применять slicing-methodology осознанно, нужно держать в голове внутреннюю модель среза: срез — это не массив, а «окно» в массив.

Упрощенно срез можно представить как структуру:

// Упрощенное представление среза внутри рантайма
type sliceHeader struct {
    Data uintptr // Указатель на первый элемент в массиве
    Len  int     // Текущая длина - сколько элементов видно через срез
    Cap  int     // Емкость - сколько элементов можно разместить без новой аллокации
}

Важно понимать две вещи:

  1. Слайс не владеет памятью, он лишь ссылается на подлежащий массив.
  2. Несколько слайсов могут ссылаться на один и тот же массив.

Давайте разберемся на простом примере.

package main

import "fmt"

func main() {
    base := []int{1, 2, 3, 4, 5} // Создаем срез, под ним скрыт массив из 5 элементов

    a := base[1:4]               // Берем "окно" с индексами 1,2,3
    b := base[2:]                // Берем "окно" с индексами 2,3,4

    // Меняем элемент через один срез
    a[1] = 20 // Меняем значение с индексом 2 исходного массива

    fmt.Println("base:", base) // base: [1 2 20 4 5]
    fmt.Println("a:", a)       // a: [2 20 4]
    fmt.Println("b:", b)       // b: [20 4 5]
}

// Здесь оба среза a и b смотрят в один и тот же массив // Изменение через a стало видно и в base и в b

Как видите, это поведение напрямую вытекает из того, что Data в sliceHeader указывает на один и тот же участок памяти. Именно отсюда вытекает множество рекомендаций и правил, которые мы будем обсуждать.

Ключевые параметры среза длина и емкость

У каждого среза есть:

  • длина (len) — сколько элементов реально видно;
  • емкость (cap) — длина подлежащего массива от первого элемента среза до его конца.

Давайте посмотрим это на примере:

package main

import "fmt"

func main() {
    base := []int{10, 20, 30, 40, 50}

    s1 := base[1:4] // Элементы с индексами 1,2,3
    fmt.Println("s1:", s1, "len:", len(s1), "cap:", cap(s1))
    // len(s1) = 3 - элементы [20, 30, 40]
    // cap(s1) = 4 - элементы от base[1] до base[4] включительно

    s2 := s1[:2]    // Сужаем окно
    fmt.Println("s2:", s2, "len:", len(s2), "cap:", cap(s2))
    // len(s2) = 2, cap(s2) по-прежнему 4, так как базовый массив тот же
}

Методология работы со слайсами предполагает, что вы всегда держите в голове:

  • с какой емкостью вы создаете срез;
  • откуда он берется (новый массив или часть существующего);
  • хотите ли вы, чтобы изменения «протекали» в другие части программы.

Нарезка (slicing) и управление видимостью данных

Базовый синтаксис нарезки

Смотрите, сначала разберем базовый синтаксис, а потом — методологические рекомендации.

s := []int{1, 2, 3, 4, 5, 6}

// s[a:b] - элементы с индекса a по b-1
middle := s[2:5] // [3, 4, 5]

// s[:b] - от начала до b-1
prefix := s[:3]  // [1, 2, 3]

// s[a:] - от a до конца
suffix := s[3:]  // [4, 5, 6]

// Здесь мы используем разные формы нарезки среза // Они не создают новый массив - только новое "окно" в существующий

Этот синтаксис понятен, но в методологии слайсов важную роль играет расширенная форма a:b:c, где c — верхняя граница емкости.

Трехиндексная нарезка защита от протеканий

Трехиндексная форма s[a:b:c] позволяет не только задать длину (b-a), но и ограничить емкость (c-a). Это мощный инструмент для изоляции срезов друг от друга.

Давайте посмотрим, что происходит.

package main

import "fmt"

func main() {
    base := []int{1, 2, 3, 4, 5}

    // Обычный срез - емкость "проваливается" до конца base
    s1 := base[1:3]      // [2, 3]
    fmt.Println("s1 len:", len(s1), "cap:", cap(s1))
    // len=2, cap=4 - можно записывать ещё 2 элемента без новой аллокации

    // Срез с ограниченной емкостью
    s2 := base[1:3:3]    // [2, 3]
    fmt.Println("s2 len:", len(s2), "cap:", cap(s2))
    // len=2, cap=2 - расширять без переноса нельзя
}

// В s1 cap больше len, поэтому append может использовать тот же массив // В s2 cap == len, поэтому любой append приведет к новой аллокации и изоляции

Зачем это нужно в реальном коде:

  • вы передаете срез функции и не хотите, чтобы она могла «залезть» дальше, чем вы ожидаете;
  • вы хотите гарантированно отделить рост нового среза от исходного массива;
  • вы защищаете «хвост» данных, который не должен быть случайно перезаписан.

Типичный пример — парсинг буфера.

func parseHeader(buf []byte) []byte {
    // Выделяем область только под заголовок
    header := buf[:10:10] // len=10, cap=10

    // Здесь мы можем делать append к другим срезам,
    // но сам header расширить уже не получится без новой аллокации
    return header
}

Обратите внимание: использование трехиндексной нарезки — это именно элемент методологии, а не «редкая конструкция языка». Она помогает явно выразить ваши намерения относительно владения и роста среза.

Методология использования append

Как работает append под капотом

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

  • Если у среза есть свободная емкость, новые элементы пишутся в тот же подлежащий массив.
  • Если емкости недостаточно, рантайм:
    • выделяет новый массив большего размера;
    • копирует в него старые элементы;
    • добавляет новые элементы;
    • возвращает новый срез, ссылающийся на новый массив.

Давайте посмотрим, как это влияет на поведение.

package main

import "fmt"

func main() {
    s := make([]int, 0, 3) // len=0, cap=3

    s = append(s, 1, 2, 3) // Помещаем 3 элемента - хватает cap
    fmt.Println(s, len(s), cap(s)) // [1 2 3] 3 3

    s2 := s                // Копируем заголовок среза
    s2[0] = 10             // Меняем первый элемент
    fmt.Println("s:", s)   // [10 2 3]
    fmt.Println("s2:", s2) // [10 2 3]

    // Добавляем четвертый элемент - cap не хватает
    s = append(s, 4)       // Выделяется новый массив
    s[1] = 20              // Меняем элемент во "вновь выросшем" срезе

    fmt.Println("После append:")
    fmt.Println("s:", s)   // [10 20 3 4] - новый массив
    fmt.Println("s2:", s2) // [10 2 3]    - старый массив
}

// До добавления четвертого элемента s и s2 делили один массив // После расширения s переехал на новый массив, а s2 остался на старом

Методологический вывод: всегда учитывайте, что append может:

  • оставить срез на старом массиве (если cap достаточно);
  • «отсоединить» срез от исходного массива (если произошла аллокация).

Правило передачи срезов в функции

При передаче среза в функцию копируется только заголовок (Data, Len, Cap), а не сами данные. Это дешево, но важно помнить про снова же про append.

Давайте посмотрим на два сценария.

Сценарий 1: функция модифицирует элементы

func fillOnes(s []int) {
    for i := range s {
        s[i] = 1 // Меняем сами элементы массива
    }
}

func main() {
    s := []int{0, 0, 0}
    fillOnes(s)
    fmt.Println(s) // [1 1 1]
}

// Здесь мы модифицируем подлежащий массив // Все изменения видны снаружи

Методология: если вы хотите позволить функции изменять данные, просто передавайте срез.

Сценарий 2: функция делает append

func addOne(s []int) {
    s = append(s, 1)    // Локальное изменение копии заголовка
    // Внешний срез об этом не узнает
}

func main() {
    s := []int{}
    addOne(s)
    fmt.Println(s)      // [] - изменения не видны
}

// В функции изменился только локальный s // Чтобы вернуть новый срез, нужно либо вернуть его из функции, либо передать *slice

Методология:

  • если функция делает append и вы хотите сохранить результат, всегда возвращайте срез из функции;
  • не рассчитывайте, что изменение длины среза внутри функции автоматически отразится снаружи.

Правильный вариант:

func addOne(s []int) []int {
    s = append(s, 1)
    return s
}

func main() {
    s := []int{}
    s = addOne(s) // Принимаем измененный срез
    fmt.Println(s) // [1]
}

Управление емкостью и стратегия аллокаций

Создание срезов с make

Часть методологии — сознательный выбор емкости при создании срезов. Это позволяет:

  • уменьшить количество аллокаций;
  • снизить фрагментацию памяти;
  • сделать поведение более предсказуемым.
func main() {
    // Если вы знаете, сколько элементов примерно будет
    users := make([]string, 0, 100) // len=0, cap=100

    // Если вы точно знаете длину - задайте сразу len и cap одинаковыми
    buf := make([]byte, 1024) // len=1024, cap=1024
    _ = users
    _ = buf
}

// Здесь мы заранее резервируем память под предполагаемый объем // Это избавит от лишних аллокаций при росте среза

Методология:

  • если вы накапливаете элементы в цикле, всегда оценивайте и задавайте емкость;
  • используйте профайлер (pprof), чтобы понять, где именно нужны такие оптимизации.

Стратегии роста среза

Встроенный append увеличивает емкость по своим правилам (обычно в 2 раза, но есть нюансы). Иногда вам нужно взять рост под контроль.

Например, вы пишете обертку, которая должна расти по определенному алгоритму.

func appendWithCapLimit(s []int, v int, maxCap int) []int {
    // Если cap уже достиг максимума, просто возвращаем
    if cap(s) >= maxCap && len(s) == cap(s) {
        return s
    }

    s = append(s, v)
    if cap(s) > maxCap {
        // Если Go увеличил cap слишком сильно, можно ограничить вручную
        tmp := make([]int, len(s))
        copy(tmp, s)
        s = tmp
    }
    return s
}

// Здесь мы контролируем, чтобы емкость не выходила за указанный предел // Это важно, если вы храните в памяти большие структуры и хотите ограничить рост

Это уже более продвинутый уровень методологии, но подход один: вы осознанно управляете емкостью, а не полагаетесь целиком на дефолтные стратегии рантайма.

Методология безопасного использования срезов

Избежание утечек памяти через «длинный хвост» среза

Одна из самых распространенных и неочевидных проблем — удержание в памяти большого массива через маленький срез. Смотрите, как это возникает.

type User struct {
    ID   int
    Name string
}

func findByID(users []User, id int) *User {
    for i := range users {
        if users[i].ID == id {
            return &users[i]
        }
    }
    return nil
}

Сам по себе этот код нормальный. Проблема начинается, когда вы берете маленький срез большого массива и сохраняете его надолго:

func getFirstUser(users []User) []User {
    // Возвращаем только первого пользователя
    return users[:1]
}

// Здесь мы возвращаем срез длиной 1 // Но cap остается таким же, как у исходного users - под ним может быть большой массив

Если исходный срез users содержал, например, миллион записей, а вы сохранили результат getFirstUser в глобальной переменной, в памяти останется висеть весь миллион элементов, даже если больше никто на них не ссылается.

Методология: если вы возвращаете или сохраняете срез, который должен жить долго, а исходные данные большие — делайте копию.

func getFirstUserSafe(users []User) []User {
    if len(users) == 0 {
        return nil
    }

    res := make([]User, 1) // Создаем новый массив длиной 1
    res[0] = users[0]      // Копируем нужный элемент
    return res
}

// Теперь в памяти будет только один User // Большой массив может быть собран GC, когда на него не останется ссылок

Точно так же обстоит дело со строками, если вы вырезаете подстроку из большого буфера и удерживаете ее очень долго. В новых версиях Go строки при слайсинге уже копируются иначе, но сама идея удержания большого буфера через небольшой объект важна.

Работа с срезами и конкурентность

Срезы не потокобезопасны сами по себе. Методология здесь простая:

  • никогда не изменяйте один и тот же срез одновременно из нескольких горутин без синхронизации;
  • чтение и запись в один и тот же срез одновременно — тоже опасно.

Покажу вам пример потенциально проблемного кода.

var data = []int{}

func write() {
    for i := 0; i < 1000; i++ {
        data = append(data, i) // Изменяем глобальный срез из нескольких горутин
    }
}

func main() {
    go write()
    go write()
    // ...
}

// Здесь оба потока одновременно делают append к одному и тому же срезу // Это может привести к гонкам данных и повреждению структуры среза

Методология:

  • если вам нужно накапливать данные из нескольких горутин, используйте каналы или мьютексы;
  • читайте срез без блокировок только если вы точно знаете, что никто его больше не изменяет.

Правильный подход с каналами:

func producer(ch chan<- int) {
    for i := 0; i < 1000; i++ {
        ch <- i // Отправляем данные в канал
    }
    close(ch)
}

func consumer(ch <-chan int) []int {
    res := make([]int, 0, 1000)
    for v := range ch {
        res = append(res, v) // Все append происходят в одной горутине
    }
    return res
}

// Здесь мы собираем срез только в одной горутине // Это безопасно - нет конкурирующих изменений среза

Методология проектирования API со срезами

Когда возвращать срез, а когда копию

Выбор между «возвращать сырой срез» и «возвращать копию» — это архитектурное решение. Смотрите, как можно к этому подойти.

Вопросы, которые стоит себе задать:

  • Должен ли вызывающий код иметь возможность изменять данные?
  • Могут ли эти данные быть общими между несколькими компонентами?
  • Есть ли риск удержания большого объема памяти?

Примеры подходов.

Возвращаем живой срез, который можно менять

Подходит для внутренних пакетов, где вы полностью контролируете использование.

func (b *Buffer) Bytes() []byte {
    return b.buf // Вызывающий может менять содержимое
}

// Этот подход быстрый и без копирований, но накладывает ответственность на вызывающего

Возвращаем копию, защищая инкапсуляцию

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

func (b *Buffer) BytesCopy() []byte {
    res := make([]byte, len(b.buf))
    copy(res, b.buf)
    return res
}

// Теперь вызывающий не может повлиять на внутренний буфер b // Любые изменения в полученном срезе не затронут исходные данные

Методологический совет: для публичных API чаще стоит возвращать защищенный вариант (копию), а оптимизированный, но более рискованный — оставлять как отдельный метод с явным именем (например, UnsafeBytes).

Прием: срез как «view» и срез как «владелец»

Хорошая практика — явно разделять два типа срезов:

  • view-срез: просто вид на чужие данные, не владеет ими;
  • owning-срез: считается «владельцем» памяти, отвечает за ее «жизненный цикл».

В Go нет встроенного механизма владения, но вы можете договориться об этом в своем коде и именах функций.

Например:

// View - просто ссылка на чужие данные
type View struct {
    data []byte
}

// Clone - создает владеющую копию
func (v View) Clone() []byte {
    res := make([]byte, len(v.data))
    copy(res, v.data)
    return res
}

// Здесь View сам по себе не гарантирует владение // Но Clone создает новый массив, который принадлежит вызывающему

Подобные соглашения помогают избегать путаницы с тем, кто за что отвечает.

Практические паттерны работы со слайсами

Паттерн «обрезка» (truncate) без аллокаций

Один из полезных паттернов — обрезать срез до нужной длины, не меняя емкость. Это быстро и не требует аллокаций.

func truncate[T any](s []T, n int) []T {
    if n < 0 {
        n = 0
    }
    if n > len(s) {
        n = len(s)
    }
    return s[:n]
}

// Мы просто меняем len в заголовке, cap остается прежним // Это очень дешевый способ "обрезки" данных

Применение:

  • обрезать буфер до реального количества считанных байтов;
  • удалять «хвост» временных данных.

Паттерн «обнуление перед освобождением»

Иногда вам нужно освободить большой массив для сборщика мусора, но у вас остался срез на него. Можно обнулить ссылки, чтобы GC мог освободить память.

func clearSlice[T any](s []T) {
    for i := range s {
        s[i] = *new(T) // Присваиваем нулевое значение типа
    }
}

// Этот подход особенно важен, когда T - крупные структуры или содержат указатели // Обнуление позволяет сборщику мусора освободить больше памяти

Часто достаточно просто «отвязать» срез:

big = nil // Устанавливаем срез в nil, чтобы убрать ссылку на массив

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

Паттерн «pooling» срезов

Для снижения давления на сборщик мусора используют пулы срезов, например через sync.Pool.

import "sync"

var bufPool = sync.Pool{
    New: func() any {
        // Создаем буфер разумного размера
        return make([]byte, 0, 4096)
    },
}

func getBuffer() []byte {
    return bufPool.Get().([]byte)[:0] // Берем срез и обнуляем длину
}

func putBuffer(buf []byte) {
    // Не кладем в пул слишком большие срезы, чтобы не раздувать память
    if cap(buf) > 8192 {
        return
    }
    bufPool.Put(buf[:0])
}

// Здесь мы переиспользуем уже выделенные массивы // Это снижает количество аллокаций и нагрузку на GC

Этот паттерн опирается как раз на понимание len и cap, а также на то, что «обрезка» среза не освобождает память.

Заключение

Методология слайсов в Go — это не один конкретный паттерн, а совокупность принципов:

  • всегда держать в голове разницу между длиной и емкостью;
  • помнить, что срез — это окно в массив, а не самостоятельный контейнер;
  • осознанно использовать трехиндексную нарезку, когда нужно ограничить емкость и изолировать рост;
  • контролировать поведение append и всегда возвращать срез из функций, которые его изменяют;
  • заботиться о памяти, избегая удержания больших массивов через маленькие срезы;
  • аккуратно использовать срезы в многопоточном коде;
  • проектировать API так, чтобы было ясно, кто владеет памятью и кто может менять данные.

Если вы будете смотреть на слайсы с этой методологической точки зрения, код станет предсказуемее, легче в сопровождении и производительнее. Дальше вы можете углубляться в профилирование, экспериментировать со стратегиями роста и применять слайсы как фундаментальный, но хорошо контролируемый инструмент.

Частозадаваемые технические вопросы

Как безопасно передать часть большого буфера в другую горутину, чтобы она не удерживала весь буфер

Используйте копирование. Давайте посмотрим, как это выглядит.

func sendChunk(buf []byte, out chan<- []byte) {
    chunk := make([]byte, len(buf))
    copy(chunk, buf) // Копируем ровно то, что нужно отправить
    out <- chunk
}

// Теперь горутина-получатель удерживает только свой небольшой chunk // Исходный большой buf может быть освобожден сборщиком мусора

Как превратить срез фиксированной длины в «окно» с ограниченной емкостью

Используйте трехиндексную нарезку.

func window(buf []byte, start, length int) []byte {
    end := start + length
    if end > len(buf) {
        end = len(buf)
    }
    return buf[start:end:end] // cap = len(window)
}

// Здесь возвращаемый срез не сможет расти в "хвост" исходного буфера // Любой append создаст новый массив

Как эффективно удалить элемент из середины среза без сохранения порядка

Можно перезаписать элемент последним и обрезать срез.

func removeUnordered[T any](s []T, i int) []T {
    s[i] = s[len(s)-1] // Переносим последний элемент на место удаляемого
    return s[:len(s)-1]
}

// Порядок нарушается, но операция O(1) по времени // Это полезно для множества, реализованного через срез

Как обрезать срез и освободить память, а не только уменьшить len

Нужно создать новый срез и скопировать данные.

func shrink[T any](s []T) []T {
    res := make([]T, len(s))
    copy(res, s)
    return res
}

// Теперь старый массив может быть освобожден GC, если на него больше нет ссылок // Это избавляет от "излишней" емкости

Как безопасно использовать срезы как ключи в map

Срезы нельзя использовать как ключи напрямую, но можно преобразовать их в строки или хеши.

func keyFromBytes(b []byte) string {
    // Создаем строку-копию, чтобы дальнейшие изменения среза не меняли ключ
    return string(append([]byte(nil), b...))
}

// Здесь мы явно копируем данные и создаем строку // Она может безопасно использоваться как ключ в map, не завися от дальнейших изменений среза

Public API - public-api - подробное руководство для разработчиковСтрелочка вправо

Все гайды по Feature-sliced_design

Открыть базу знаний

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