Все статьи

Слайсы в Go: как реально работают slices в Golang и почему на них валятся даже мидлы

  • Golang
  • Middle
  • 7 мая 2026 г.
  • 29 мин чтения

Что такое slice в Go простыми словами

Что такое slice в Go простыми словами

slice в Go, или слайс, это гибкая обёртка над массивом. Массив в Go имеет фиксированный размер, а слайс позволяет работать с последовательностью элементов удобнее: добавлять данные, передавать части массива, сортировать, копировать, фильтровать и собирать новые наборы.

Через slices в Go обычно:

  • хранят данные
  • собирают результаты запросов
  • работают с JSON
  • обрабатывают файлы
  • делают очереди
  • фильтруют элементы
  • сортируют данные
  • передают наборы значений между функциями

Тип слайса выглядит так:

[]T

Где T — тип элементов.

Например:

[]int
[]string
[]User
[]byte
[]map[string]any

Обычно в Go используют именно слайсы, а не массивы. Массивы нужны реже: когда размер строго фиксирован и является частью типа. В повседневной разработке почти всегда нужен список переменной длины, поэтому ты будешь видеть []int, []string, []User, а не [10]int.

Создание простого slice:

nums := []int{1, 2, 3}

Добавление элементов:

nums = append(nums, 4)

Результат:

[1 2 3 4]

На первый взгляд всё выглядит как обычный динамический массив, но внутри slices работают сильно интереснее. В Go slice — это не самостоятельный контейнер с данными. Он хранит:

  • указатель на массив
  • длину
  • ёмкость

И именно из-за этого появляются почти все странные эффекты, которые получают новички:

  • append неожиданно меняет другой slice
  • данные внезапно общие между функциями
  • маленький subslice удерживает огромный кусок памяти
  • nil slice и пустой slice ведут себя по-разному в JSON
  • конкурентный append приводит к race condition

Поэтому срезы в Go бесполезно просто учить. Если нормально не понять, как они устроены, и не попрактиковаться, потом начинаются странные баги в API, worker pool, очередях, SQL batch insert, protobuf и конкурентном коде.

  • чем slice отличается от массива
  • как работает append
  • что такое len и cap
  • как устроен slice внутри runtime
  • как правильно копировать slices
  • как удалять элементы
  • как избежать лишних аллокаций
  • почему бывают утечки памяти
  • как slices ведут себя в goroutine

Плюс по пути посмотрим реальные production-кейсы и популярные вопросы с Go-собеседований.

Slice в Go — это не динамический массив

Главная ошибка почти всех новичков — воспринимать slice как самостоятельную структуру данных. На самом деле slice — это просто оболочка над массивом. Он не хранит элементы внутри себя, он хранит:

  • указатель на массив
  • длину
  • ёмкость

И именно из-за этого появляются почти все неожиданные эффекты, которые разработчики ловят в Go.

s1 := []int{1, 2, 3}
s2 := s1[:2]
s2[0] = 100
fmt.Println(s1) // [100 2 3]

Многие ожидают, что s2 будет независимой копией, но копии нет. Оба slices смотрят на один и тот же массив.

Именно это делает slices одновременно:

  • очень быстрыми;
  • очень удобными;
  • и иногда очень опасными.

Чем slice отличается от array в Go

Array vs Slice в Golang

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

var a [3]int
var b [4]int

[3]int и [4]int это разные типы. Их нельзя просто так присвоить друг другу.

var a [3]int
var b [4]int
// a = b // compile error

Слайс длину в типе не хранит:

var s []int

Сегодня в нём может быть 3 элемента, завтра 300, потом снова 0. Поэтому слайсы удобнее для большинства задач.

Коротко:

  • массив [N]T хранит ровно N элементов, длина массива является частью типа и при передаче в функцию копируется массив целиком
  • слайс []T хранит ссылку на участок массива и при передаче слайс копируется как маленький дескриптор, но смотрит на те же данные

Пример с массивом:

func changeArray(a [3]int) {
    a[0] = 100
}

func main() {
    arr := [3]int{1, 2, 3}
    changeArray(arr)
    fmt.Println(arr) // [1 2 3]
}

Массив скопировался, исходные данные не изменились.

Пример со слайсом:

func changeSlice(s []int) {
s[0] = 100
}
func main() {
nums := []int{1, 2, 3}
changeSlice(nums)
fmt.Println(nums) // [100 2 3]
}

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

Как устроен slice под капотом

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

Слайс внутри можно представить как небольшую структуру из трёх частей:

type sliceHeader struct {
ptr *T
len int
cap int
}

В реальном runtime всё устроено сложнее, но для понимания хватает этой модели.

У слайса есть:

  • указатель на первый доступный элемент базового массива
  • длина len, сколько элементов сейчас видно через слайс
  • ёмкость cap, сколько элементов можно использовать от текущей позиции до конца базового массива

Пример:

s := make([]int, 3, 6)
fmt.Println(len(s)) // 3
fmt.Println(cap(s)) // 6
fmt.Println(s) // [0 0 0]

Мы создали слайс длиной 3 и ёмкостью 6. Это значит, что доступны только первые 3 элемента, но в базовом массиве зарезервировано место под 6 элементов.

len = 3
cap = 6
[0 0 0 _ _ _]
^ доступная часть

К элементам внутри длины можно обращаться:

s[1] = 10
fmt.Println(s) // [0 10 0]

А вот обращение за пределы len, даже если внутри cap место есть, вызовет panic:

s[4] = 100 // panic: index out of range

Ёмкость не даёт права индексировать элементы. Она нужна для роста через append.

words := []string{"go", "map", "go", "slice", "map", "go"}
counts := make(map[string]int)

for _, word := range words {
    counts[word]++
}

fmt.Println(counts) // map[go:3 map:2 slice:1]

Здесь работает важная особенность Go map: если ключа ещё нет, чтение вернёт zero value для типа значения. Для int это 0, поэтому counts[word]++ работает даже для нового слова.

Важно

len отвечает за доступные элементы. cap показывает запас базового массива. Индексировать можно только в пределах len, а не cap.

Как создать слайс в Go

Есть несколько нормальных способов создать слайс в Golang. Под разные задачи подходят разные варианты.

Через литерал

Когда данные известны сразу:

nums := []int{1, 2, 3}
words := []string{"go", "slice", "append"}

Это простой и читаемый вариант.

Через make slice в Go с длиной

Когда нужна конкретная длина:

s := make([]int, 5)
fmt.Println(s) // [0 0 0 0 0]
fmt.Println(len(s)) // 5
fmt.Println(cap(s)) // 5

Все элементы получают zero value своего типа. Для int это 0, для string пустая строка, для указателей nil.

Через make slice с длиной и ёмкостью

Когда ты хочешь сразу задать запас:

s := make([]int, 0, 100)
fmt.Println(len(s)) // 0
fmt.Println(cap(s)) // 100

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

Через var

Когда конечная длина неизвестна:

var s []int
s = append(s, 1)

Это nil slice. Он нормально работает с append, len, range.

var s []int
fmt.Println(s == nil) // true
fmt.Println(len(s)) // 0
s = append(s, 1)
fmt.Println(s) // [1]

len и cap в Go slice

Как работает нарезка слайс

Длина и ёмкость слайса постоянно всплывают в задачах и собеседованиях.

s := make([]int, 3, 5)
fmt.Println(len(s)) // 3
fmt.Println(cap(s)) // 5

len показывает, сколько элементов сейчас доступно. cap показывает, сколько элементов помещается в базовый массив от начала слайса до конца массива.

Посмотрим на нарезку:

s := []int{0, 1, 2, 3, 4, 5}
part := s[2:4]
fmt.Println(part) // [2 3]
fmt.Println(len(part)) // 2
fmt.Println(cap(part)) // 4

Почему cap(part) равен 4? Потому что part начинается с элемента s[2], а до конца базового массива остаётся 4 элемента: 2, 3, 4, 5.

Это влияет на append. Если у слайса есть запас ёмкости, append может записать новые элементы в тот же базовый массив. И иногда это меняет данные, которые ты не ожидал менять.

Как работает go slice append

Как работает golang slice append

append добавляет элементы в конец слайса и возвращает новый слайс.

s := []int{1, 2, 3}
s = append(s, 4)
fmt.Println(s) // [1 2 3 4]

Важно всегда сохранять результат:

s = append(s, 10)

Плохо:

append(s, 10) // compile error: value of append is not used

append может вернуть слайс, который ссылается на тот же массив, а может вернуть слайс уже с новым массивом. Это зависит от ёмкости.

Пример, когда хватает места:

s := make([]int, 0, 3)
s = append(s, 1)
s = append(s, 2)
fmt.Println(len(s)) // 2
fmt.Println(cap(s)) // 3

Новый массив не нужен.

Пример, когда места не хватает:

s := make([]int, 0, 2)
s = append(s, 1, 2)
s = append(s, 3)
fmt.Println(len(s)) // 3
fmt.Println(cap(s)) // обычно больше 2

Когда ёмкость исчерпана, Go выделяет новый базовый массив, копирует туда старые элементы и добавляет новые.

Факт

Исторически часто говорили, что ёмкость слайса удваивается до 1024 элементов, а потом растёт примерно на 25%. В современных версиях Go формула роста сложнее и является деталью runtime. В прикладном коде лучше не завязываться на точный коэффициент роста, а заранее задавать capacity там, где размер результата понятен.

Почему append может изменить другой slice

Почему изменения в одном слайсе могут повлиять на другой

Вот место, где многие впервые начинают уважать слайсы.

s1 := []int{1, 2, 3}
s2 := s1[1:2]
s3 := append(s2, 10)
fmt.Println(s1) // [1 2 10]
fmt.Println(s2) // [2]
fmt.Println(s3) // [2 10]

На первый взгляд странно. Мы же добавляли в s2, почему изменился s1?

Потому что s1, s2 и s3 смотрят на один базовый массив. У s2 длина 1, но ёмкость больше. append увидел свободное место и записал 10 в общий массив.

Такая же проблема может появиться при передаче слайса в функцию:

func addSomething(s []int) {
_ = append(s, 10)
}
func main() {
nums := []int{1, 2, 3}
addSomething(nums[:2])
fmt.Println(nums) // [1 2 10]
}

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

Как защититься полным выражением слайса

В Go есть полное выражение слайса:

s[low:high:max]

Оно позволяет ограничить capacity нового слайса.

func addSomething(s []int) {
_ = append(s, 10)
}
func main() {
nums := []int{1, 2, 3}
addSomething(nums[:2:2])
fmt.Println(nums) // [1 2 3]
}

nums[:2] даёт длину 2 и ёмкость 3.

nums[:2:2] даёт длину 2 и ёмкость 2.

Когда функция вызывает append, свободной ёмкости уже нет, поэтому Go создаёт новый массив, а исходный nums не меняется.

Важно

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

go slice copy: как правильно копировать слайсы

Для копирования слайсов есть встроенная функция copy. Она нужна, когда тебе нужна именно независимая копия данных, а не ещё один slice, который смотрит на тот же базовый массив.

Сигнатура по смыслу такая:

copy(dst, src)

Первый аргумент — куда копируем. Второй — откуда.

Частая ошибка:

src := []int{1, 2, 3}
var dst []int
copy(dst, src)
fmt.Println(dst) // []

Почему ничего не скопировалось? Потому что dst имеет длину 0. copy копирует минимум из len(dst) и len(src).

Правильно:

src := []int{1, 2, 3}
dst := make([]int, len(src))
copy(dst, src)
fmt.Println(dst) // [1 2 3]

Можно скопировать часть:

src := []int{1, 2, 3, 4}
dst := make([]int, 2)
n := copy(dst, src)
fmt.Println(dst) // [1 2]
fmt.Println(n) // 2

copy возвращает количество скопированных элементов.

Альтернатива через append:

dst := append([]int(nil), src...)

Так тоже можно. Но в обучающем и командном коде make + copy обычно читается понятнее.

copy vs append (copy()) в Golang

nil slice и empty slice: в чём разница

nil slice и empty slice в Go

В Go есть две похожие, но не одинаковые ситуации.

Nil slice:

var s []string

Пустой, но не nil slice:

s := []string{}

Или:

s := make([]string, 0)

Проверим:

var a []string
b := []string{}
c := make([]string, 0)
fmt.Println(a == nil) // true
fmt.Println(b == nil) // false
fmt.Println(c == nil) // false
fmt.Println(len(a)) // 0
fmt.Println(len(b)) // 0
fmt.Println(len(c)) // 0

Для большинства операций они ведут себя одинаково:

var s []int
fmt.Println(len(s)) // 0
for _, v := range s {
fmt.Println(v) // не выполнится
}
s = append(s, 1)
fmt.Println(s) // [1]

Разница становится заметна на границах системы. Например, в JSONДля API это может быть важно. Одни клиенты ждут [], другие нормально обрабатывают null, а третьи ломаются от неожиданного формата.

type Response struct {
Items []string `json:"items"`
}
var nilSlice []string
emptySlice := []string{}
b1, _ := json.Marshal(Response{Items: nilSlice})
b2, _ := json.Marshal(Response{Items: emptySlice})
fmt.Println(string(b1)) // {"items":null}
fmt.Println(string(b2)) // {"items":[]}

Для API это может быть важно. Одни клиенты ждут [], другие нормально обрабатывают null, а третьи ломаются от неожиданного формата.

Практика: если ты просто собираешь результат в коде, var s []T нормален. Если отдаёшь JSON и хочешь именно пустой массив, инициализируй []T{} или make([]T, 0).

Как проверять, что slice пустой

Лучший способ проверить, есть ли элементы:

if len(s) == 0 {
// пусто
}

Не надо проверять только s == nil, если тебе важно именно отсутствие элементов.

Плохой пример:

func handle(items []Item) {
if items != nil {
process(items)
}
}

Если функция получит пустой, но не nil slice, условие выполнится:

items := []Item{}
handle(items)

Правильнее:

if len(items) > 0 {
process(items)
}

Это одинаково работает и для nil slice, и для empty slice.

Та же логика полезна и для map: если нужно понять, есть ли элементы, обычно проверяй len(m), а не m != nil. Подробнее про map можно прочитать в статье «Map в Go: как работает мапа Golang».

if len(s) == 0 {
// пусто
}

Нарезка slice: s[a:b] и s[a:b:c]

Нарезка slice s[a:b:c]

Нарезка создаёт новый слайс из существующего массива или слайса.

s := []int{0, 1, 2, 3, 4}
part := s[1:4]
fmt.Println(part) // [1 2 3]

Границы работают так:

s[low:high]

low включается, high не включается.

s := []string{"a", "b", "c", "d"}
fmt.Println(s[:2]) // [a b]
fmt.Println(s[2:]) // [c d]
fmt.Println(s[:]) // [a b c d]

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

s := []int{1, 2, 3}
part := s[1:]
part[0] = 100
fmt.Println(s) // [1 100 3]
fmt.Println(part) // [100 3]

Полное выражение слайса:

part := s[1:3:3]

Формула ёмкости:

cap = max - low

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

Удаление элемента из слайса Golang

Удаление элемента из слайса Golang

В Go нет встроенной функции delete для slice, как для map. Поэтому новички постоянно ищут delete slice, slice remove и не знают, как удалить элемент из слайса golang.

Способ зависит от того, важен ли порядок.

Удалить с сохранением порядка

func removeAt(s []int, i int) []int {
return append(s[:i], s[i+1:]...)
}

Пример:

nums := []int{10, 20, 30, 40}
nums = removeAt(nums, 1)
fmt.Println(nums) // [10 30 40]

Порядок сохранился, но элементы после i были сдвинуты.

Удалить без сохранения порядка

Если порядок не важен, можно быстрее:

func removeAtUnordered(s []int, i int) []int {
s[i] = s[len(s)-1]
return s[:len(s)-1]
}

Пример:

nums := []int{10, 20, 30, 40}
nums = removeAtUnordered(nums, 1)
fmt.Println(nums) // [10 40 30]

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

Важный нюанс с указателями

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

func removePtr(s []*User, i int) []*User {
copy(s[i:], s[i+1:])
s[len(s)-1] = nil
return s[:len(s)-1]
}

Без s[len(s)-1] = nil последний элемент может продолжать удерживать объект в памяти через базовый массив.

Удалить элемент из слайса без сохранения порядка

Важно

Для слайсов с указателями удаление через append(s[:i], s[i+1:]...) может оставить ссылку в хвосте базового массива. В долгоживущих структурах это превращается в неприятные утечки.

golang slice contains: как проверить наличие элемента

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

func containsInt(s []int, target int) bool {
for _, v := range s {
if v == target {
return true
}
}
return false
}

Для строк:

func containsString(s []string, target string) bool {
for _, v := range s {
if v == target {
return true
}
}
return false
}

В современных версиях Go можно использовать пакет slices из стандартной библиотеки:

import "slices"
ok := slices.Contains([]int{1, 2, 3}, 2)
fmt.Println(ok) // true

Если проверок много, иногда лучше построить set через map:

ids := []int{10, 20, 30}
set := make(map[int]struct{}, len(ids))
for _, id := range ids {
set[id] = struct{}{}
}
_, ok := set[20]

Один поиск по slice — нормально. Тысячи поисков по большому slice — уже повод подумать о map.

golang sort slice: как сортировать слайсы

Для сортировки в Go есть пакет sort, а в новых версиях ещё и пакет slices.

Сортировка []int через sort:

nums := []int{3, 1, 2}
sort.Ints(nums)
fmt.Println(nums) // [1 2 3]

Сортировка []string:

words := []string{"go", "append", "slice"}
sort.Strings(words)
fmt.Println(words) // [append go slice]

Сортировка структур:

type User struct {
Name string
Age int
}
users := []User{
{Name: "Mark", Age: 24},
{Name: "Niyaz", Age: 30},
}
sort.Slice(users, func(i, j int) bool {
return users[i].Age < users[j].Age
})

Через пакет slices:

slices.Sort(nums)

Для структур:

slices.SortFunc(users, func(a, b User) int {
return cmp.Compare(a.Age, b.Age)
})

Запросы go sort slice, golang sort slice обычно закрываются этими вариантами.

go slice string и golang slice string

Здесь есть два разных смысла.

Первый: slice строк.

words := []string{"go", "slice", "map"}

Второй: преобразование slice в string, чаще всего []byte в string.

b := []byte{'g', 'o'}
s := string(b)
fmt.Println(s) // go

Обратное преобразование:

s := "golang"
b := []byte(s)
fmt.Println(b)

Важно понимать, что строка в Go неизменяемая. Когда ты превращаешь string в []byte, получаешь изменяемую копию данных.

s := "go"
b := []byte(s)
b[0] = 'G'
fmt.Println(string(b)) // Go
fmt.Println(s) // go

Для Unicode-строк часто нужен []rune, а не []byte.

s := "привет"
runes := []rune(s)
fmt.Println(len(s)) // байты
fmt.Println(len(runes)) // символы

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

golang map to slice: как собрать slice из map

map и slice в Go

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

Например, собрать ключи map в slice:

m := map[string]int{
"go": 3,
"slice": 2,
"map": 1,
}
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
fmt.Println(keys)

Собрать значения:

values := make([]int, 0, len(m))
for _, v := range m {
values = append(values, v)
}

Собрать пары:

type Pair struct {
Key string
Value int
}
pairs := make([]Pair, 0, len(m))
for k, v := range m {
pairs = append(pairs, Pair{Key: k, Value: v})
}

Если ты не читал разбор map, лучше вернуться к нему отдельно как работает мапа Golang. Там подробно разобраны ключи, значения, range, delete, sync.Map и внутреннее устройство.

Как скопировать map, slice и interface в Go

Запрос как скопировать map slice interface часто появляется из-за одной проблемы: в Go много ссылочных структур, и простое присваивание не всегда делает независимую копию данных.

Со slice:

src := []int{1, 2, 3}
dst := make([]int, len(src))
copy(dst, src)

С map:

src := map[string]int{"a": 1}
dst := make(map[string]int, len(src))
for k, v := range src {
dst[k] = v
}

Если внутри map лежат slice, нужно копировать и их:

src := map[string][]int{
"a": {1, 2, 3},
}
dst := make(map[string][]int, len(src))
for k, v := range src {
copied := make([]int, len(v))
copy(copied, v)
dst[k] = copied
}

С interface{} или any универсального простого ответа нет. Надо знать реальный тип внутри. Если там слайс, копируешь как слайс. Если map, как map. Если структура с указателями, нужно решать, нужна поверхностная или глубокая копия.

Практика: в Go лучше не пытаться сделать deep copy на все случаи жизни. Обычно безопаснее явно копировать конкретные структуры данных, которые используются в твоём коде.

Производительность: как правильно инициализировать slice

Производительность и preallocate в Golang

Представим, у нас есть слайс []Foo, и мы хотим получить []Bar такой же длины.

Плохой вариант для большого количества данных:

func convert(foos []Foo) []Bar {
bars := make([]Bar, 0)
for _, foo := range foos {
bars = append(bars, fooToBar(foo))
}
return bars
}

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

Лучше задать ёмкость:

func convert(foos []Foo) []Bar {
bars := make([]Bar, 0, len(foos))
for _, foo := range foos {
bars = append(bars, fooToBar(foo))
}
return bars
}

Ещё быстрее, если длина результата точно равна длине входа:

func convert(foos []Foo) []Bar {
bars := make([]Bar, len(foos))
for i, foo := range foos {
bars[i] = fooToBar(foo)
}
return bars
}

Почему второй вариант иногда всё равно выбирают? Потому что append часто проще читать, особенно когда на один входной элемент приходится несколько выходных.

Пример:

func collectKeys(items []Item) []string {
keys := make([]string, 0, len(items)*2)
for _, item := range items {
keys = append(keys, item.StartKey, item.EndKey)
}
return keys
}

Через индексы это будет быстрее на микробенчмарке, но хуже для чтения:

func collectKeys(items []Item) []string {
keys := make([]string, len(items)*2)
for i, item := range items {
keys[i*2] = item.StartKey
keys[i*2+1] = item.EndKey
}
return keys
}

В реальном коде важен баланс. Если участок не горячий, читаемость часто важнее экономии нескольких процентов.

Правило

Если длина результата известна и ты заполняешь каждый элемент один к одному, используй make([]T, len(src)) и запись по индексу. Если количество элементов зависит от условий или на один вход приходится переменное число выходов, используй make([]T, 0, expected) и append.

Утечка памяти из-за нарезки большого slice

Утечка памяти из-за нарезки большого slice

Одна из самых неприятных ловушек со слайсами: маленький слайс может удерживать в памяти огромный массив.

Представим, что мы читаем сообщение размером 1 МБ, а сохранить хотим только первые 5 байт.

func getMessageType(msg []byte) []byte {
return msg[:5]
}

Выглядит нормально. Возвращаем всего 5 байт.

Но msg[:5] не копирует данные. Новый слайс продолжает ссылаться на базовый массив размером 1 МБ. Если сохранить 1000 таких маленьких слайсов, можно случайно удерживать около 1 ГБ памяти, хотя полезных данных всего 5000 байт.

Правильно делать копию:

func getMessageType(msg []byte) []byte {
msgType := make([]byte, 5)
copy(msgType, msg[:5])
return msgType
}

Или компактно:

func getMessageType(msg []byte) []byte {
return append([]byte(nil), msg[:5]...)
}

Полное выражение слайса не решит эту проблему:

return msg[:5:5]

Оно ограничит capacity, но базовый массив всё равно останется в памяти, пока на него есть ссылка через слайс.

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

Утечки памяти со слайсами указателей

Есть ещё один тип утечек: когда слайс содержит указатели или структуры с полями-указателями.

type Foo struct {
Data []byte
}

Допустим, у нас есть 1000 элементов, и каждый держит по 1 МБ.

func keepFirstTwo(foos []Foo) []Foo {
return foos[:2]
}

Мы оставили только два элемента, но базовый массив всё ещё содержит остальные структуры, а внутри них ссылки на большие []byte. GC видит эти ссылки и не освобождает память.

Если сохраняем маленькую часть, лучше копировать:

func keepFirstTwo(foos []Foo) []Foo {
res := make([]Foo, 2)
copy(res, foos[:2])
return res
}

Если удаляем небольшую часть, а большую оставляем, можно обнулять ненужные ссылки:

func keepFirstTwo(foos []Foo) []Foo {
for i := 2; i < len(foos); i++ {
foos[i].Data = nil
}
return foos[:2]
}

Как выбирать:

  • оставляешь маленькую часть большого слайса — копируй
  • оставляешь почти всё, но удаляешь несколько элементов — обнуляй ссылки
  • работаешь с []byte из большого буфера — копируй нужный кусок
  • держишь слайсы долго в памяти — особенно внимательно смотри на базовые массивы

Такие вещи редко всплывают в учебных задачах, зато легко появляются в проде, когда сервис долго живёт и обрабатывает много данных.

Slice в функциях: что меняется, а что нет

Слайс передаётся в функцию по значению. Копируется не весь массив, а только дескриптор: pointer, len, cap.

Изменение элемента видно снаружи:

func change(s []int) {
s[0] = 100
}
func main() {
nums := []int{1, 2, 3}
change(nums)
fmt.Println(nums) // [100 2 3]
}

А изменение самого дескриптора не видно, если ты не вернул новый слайс:

func add(s []int) {
s = append(s, 4)
}
func main() {
nums := []int{1, 2, 3}
add(nums)
fmt.Println(nums) // [1 2 3]
}

Правильно:

func add(s []int) []int {
return append(s, 4)
}
func main() {
nums := []int{1, 2, 3}
nums = add(nums)
fmt.Println(nums) // [1 2 3 4]
}

Это частая ошибка новичков: функция вызывает append, но результат не возвращает.

Фильтрация slice без лишних аллокаций

Фильтрация часто пишется так:

func filterPositive(nums []int) []int {
res := make([]int, 0, len(nums))
for _, n := range nums {
if n > 0 {
res = append(res, n)
}
}
return res
}

Это безопасно и понятно.

Если можно менять исходный слайс, можно фильтровать на месте:

func filterPositiveInPlace(nums []int) []int {
res := nums[:0]
for _, n := range nums {
if n > 0 {
res = append(res, n)
}
}
return res
}

nums[:0] создаёт слайс нулевой длины, но с той же ёмкостью. Мы переиспользуем старый массив и не выделяем новый.

Но такой подход меняет исходные данные. Его нельзя использовать, если вызывающий код ожидает, что старый slice останется нетронутым.

Практика: in-place фильтрация хороша для внутренних буферов и hot path. Для публичных API и кода, где важна предсказуемость, лучше вернуть новый слайс.

Слайсы и конкурентность в Go

Слайсы и конкурентность

Сам slice не является синхронизированной структурой. Если несколько goroutine одновременно пишут в один и тот же slice или делают append без защиты, будет гонка.

Опасный код:

var nums []int
for i := 0; i < 10; i++ {
go func(i int) {
nums = append(nums, i)
}(i)
}

Здесь гонка сразу в двух местах: меняется дескриптор слайса и общий базовый массив.

Правильно через mutex:

var (
mu sync.Mutex
nums []int
)
for i := 0; i < 10; i++ {
go func(i int) {
mu.Lock()
defer mu.Unlock()
nums = append(nums, i)
}(i)
}

Или через канал, если это часть pipeline:

ch := make(chan int)
for i := 0; i < 10; i++ {
go func(i int) {
ch <- i
}(i)
}

Паттерны параллельной обработки через каналы и goroutine лучше отдельно смотреть в статье Fan-in / Fan-out в Go. Там как раз разбирается, как безопасно раздавать задачи воркерам и собирать результаты.

Частые ошибки со слайсами в Go

Путать len и cap

s := make([]int, 3, 10)
s[5] = 1 // panic

Можно обращаться только к индексам меньше len(s).

Не сохранять результат append

append(s, 1) // нельзя

Нужно:

s = append(s, 1)

Думать, что нарезка копирует данные

part := s[:2]
part[0] = 100

Изменится общий базовый массив.

Копировать в nil destination

var dst []int
copy(dst, src) // скопирует 0 элементов

Нужно:

dst := make([]int, len(src))
copy(dst, src)

Проверять пустоту через nil

if s != nil {
// не значит, что есть элементы
}

Лучше:

if len(s) > 0 {
// есть элементы
}

Удерживать большой массив маленьким слайсом

return bigData[:5]

Если слайс живёт долго, лучше скопировать нужные данные.

Что спрашивают про slices на собеседовании по Go

На собеседовании по Golang слайсы спрашивают очень часто. И обычно не потому, что хотят услышать синтаксис. Проверяют понимание памяти.

Частые вопросы:

  • Что такое slice в Go?
  • Чем slice отличается от array?
  • Что такое len и cap?
  • Как устроен slice под капотом?
  • Что делает append?
  • Когда append создаёт новый массив?
  • Почему append может изменить исходный slice?
  • Как работает copy?
  • Чем nil slice отличается от empty slice?
  • Как удалить элемент из slice?
  • Как проверить, содержит ли slice значение?
  • Как отсортировать slice?
  • Почему маленький slice может удерживать большой массив?
  • Что такое full slice expression?
  • Безопасно ли делать append в нескольких goroutine?

Если человек отвечает на эти вопросы уверенно и с примерами, обычно видно, что он не просто проходил тему по учебнику, а реально писал код.

Где слайсы встречаются в backend-разработке

В реальной Go-разработке slices появляются постоянно.

В HTTP API:

type Response struct {
Items []Item `json:"items"`
}

В работе с базой:

ids := make([]int64, 0, len(rows))

В батчевой обработке:

batch := make([]Event, 0, 1000)

В очередях, буферах и воркерах:

tasks := []Task{}

В алгоритмах:

stack := []Node{root}

В строковых задачах:

runes := []rune(text)

Поэтому слайсы нельзя выучить как одну маленькую тему и забыть. Они лежат в основе огромного количества Go-кода.

Если ты сейчас собираешь системный план изучения Go, посмотри ещё Golang Roadmap 2026: путь Go-разработчика за 18 недель. Там слайсы идут в ранней части роадмапа, потому что без них дальше тяжело понимать алгоритмы, структуры данных, JSON, SQL и конкурентность.

Мини-шпаргалка по slices в Golang

Мини-шпаргалка по slices в Golang

Создать slice:

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

Создать через make:

s := make([]int, 0, 100)

Добавить элемент:

s = append(s, 10)

Добавить другой slice:

s = append(s, other...)

Скопировать:

dst := make([]int, len(src))
copy(dst, src)

Удалить с сохранением порядка:

s = append(s[:i], s[i+1:]...)

Удалить без сохранения порядка:

s[i] = s[len(s)-1]
s = s[:len(s)-1]

Проверить пустоту:

len(s) == 0

Проверить наличие элемента:

slices.Contains(s, x)

Отсортировать:

slices.Sort(s)

Сделать строку из []byte:

str := string(bytes)

Сделать []byte из строки:

bytes := []byte(str)

Итог

Слайсы в Go — одна из базовых тем, на которой держится почти весь прикладной код. Но базовая не значит простая.

На уровне синтаксиса нужно знать make, append, copy, len, cap, range, сортировку и удаление элементов. На уровне реальной разработки важно понимать другое: слайс смотрит на базовый массив, нарезка не копирует данные, append может переиспользовать ёмкость, маленький слайс может удерживать большой массив, а nil и empty slice иногда дают разный результат на границах системы.

Главное, что стоит забрать из статьи:

  • slice это дескриптор над массивом, а не сам массив
  • len и cap отвечают за разные вещи
  • append всегда возвращает новый slice header, его нужно сохранять
  • если capacity хватает, append пишет в старый базовый массив
  • нарезка не копирует данные
  • для настоящей копии используй copy или append([]T(nil), src...)
  • пустоту проверяй через len(s), а не через s != nil
  • для больших буферов копируй нужный кусок, если он будет жить долго
  • при удалении указателей не забывай про обнуление хвоста
  • конкурентный append без синхронизации небезопасен

Если ты готовишься к собеседованию по Go, слайсы нужно уметь объяснить на примерах. Не просто сказать, что это динамический массив, а показать, как ведут себя len, cap, append, copy и базовый массив. Именно по этим деталям чаще всего отличают человека, который писал Go руками, от того, кто просто посмотрел пару роликов.

Для закрепления рядом стоит прочитать ещё три темы:

  1. Zero Values в Go, потому что nil slice напрямую связан с zero value
  2. Map в Go, потому что map и slice часто работают вместе
  3. Fan-in / Fan-out в Go, чтобы понимать, как безопасно использовать slices в конкурентной обработке результатов.

Забрать все шпаргалки по хардам Go

Проверь, готов ли ты к собеседованию по Go

24 вопроса уровня реальных собеседований, 12 минут и без регистрации. В конце радар по восьми темам, три главных пробела и разбор ошибок.