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

Что такое 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 одновременно:
  • очень быстрыми;
  • очень удобными;
  • и иногда очень опасными.
  • 600+ записей собесов с идеальными ответами
  • прокачка Go в игровом формате (как Duolingo)
  • структурированная обновляемая база знаний по Go
  • комьюнити с быстрым фидбеком
  • практика и лекции, которые реально готовят к рынку

990 ₽/месяц

Закрытый IT-клуб ВЕКТОР: сообщество + приложение

Чем 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?
Если человек отвечает на эти вопросы уверенно и с примерами, обычно видно, что он не просто проходил тему по учебнику, а реально писал код.
  • 600+ записей собесов с идеальными ответами
  • прокачка Go в игровом формате (как Duolingo)
  • структурированная обновляемая база знаний по Go
  • комьюнити с быстрым фидбеком
  • практика и лекции, которые реально готовят к рынку

990 ₽/месяц

Более 600 разборов вопросов с собесов в крупных компаниях в закрытом IT-клубе ВЕКТОР:

Где слайсы встречаются в 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 в конкурентной обработке результатов.
Senior Go developer
Работал в Авито в инфраструктуре
Кодил на Go, Java, Python, JS
200+ собеседований провел лично
Менторю больше 2 лет
У меня большой нетворк: всегда в курсе, как проходит найм в разных компаниях
Нияз
Автор