Олег Марков
Слайс API в Go - структура среза операции и подводные камни
Введение
Срезы в Go — это один из ключевых инструментов работы с коллекциями данных. На практике почти любой не тривиальный код опирается на них. Смотрите, здесь важно понимать не только базовый синтаксис, но и то, как устроен Слайс API (часто говорят api-slice) — то есть совокупность операций и функций, с помощью которых вы управляете срезами.
Когда вы вызываете append, берете поддиапазон a[2:5], копируете данные copy(dst, src) или передаете срез в функцию, вы фактически используете этот набор возможностей. Если не понимать, как они работают "под капотом", легко получить неожиданные эффекты: лишние аллокации, утечки памяти, влияние одного среза на другой, гонки данных.
В этой статье вы увидите:
- как устроен срез внутри;
- какие операции входят в условный Слайс API;
- как работает
appendи почему иногда он меняет capacity; - как правильно использовать
copyи срезы-поддиапазоны; - как избегать типичных ошибок при работе со срезами.
Давайте разберемся на последовательных примерах и постепенно соберем целостную картину.
Внутреннее устройство среза
Логическая модель среза
Срез в Go — это не просто динамический массив, а описатель (дескриптор) последовательности элементов. В памяти срез представлен как структура из трех полей:
- указатель на первый элемент;
- длина (len);
- емкость (cap).
В языке эта структура не видна напрямую, но компилятор и рантайм работают именно с такой моделью.
Условно это выглядит так:
// Псевдоструктура - так срез устроен в рантайме
type SliceHeader struct {
Data uintptr // Указатель на первый элемент в массиве
Len int // Текущее количество элементов
Cap int // Максимальное возможное количество элементов без реаллокации
}
Вы не должны создавать SliceHeader вручную для обычного кода. Но понимание его структуры сильно помогает при анализе поведения append, copy и поддиапазонов.
Базовые свойства len и cap
Давайте посмотрим простой пример, чтобы вы почувствовали разницу между длиной и емкостью:
package main
import "fmt"
func main() {
// Здесь мы создаем срез из массива литералом
nums := []int{1, 2, 3}
fmt.Println(len(nums)) // 3 - длина среза
fmt.Println(cap(nums)) // 3 - емкость совпадает с длиной
}
len— сколько элементов действительно доступно для чтения и записи по индексу.cap— сколько элементов можно добавить (черезappend), прежде чем рантайм выделит новый массив.
Теперь давайте создадим срез другой длины и емкости:
package main
import "fmt"
func main() {
// Здесь мы выделяем срез через make с длиной 2 и емкостью 5
nums := make([]int, 2, 5)
fmt.Println(len(nums)) // 2 - доступно 2 элемента
fmt.Println(cap(nums)) // 5 - в массиве есть место для 5 элементов
// Здесь мы добавляем новые элементы через append
nums = append(nums, 10, 20)
fmt.Println(len(nums)) // 4 - длина выросла
fmt.Println(cap(nums)) // 5 - емкость пока прежняя
}
Обратите внимание: в примере выше append не выделяет новый массив, потому что емкость позволяет разместить дополнительные элементы.
Срез и базовый массив
Срез всегда "сидит" на каком-то базовом массиве. Этот массив может быть:
- создан как часть литерала
[]int{1,2,3}; - выделен через
make([]T, len, cap); - получен из массива
arr[:].
Вы не управляете этим массивом напрямую — все делается через срез, но важно помнить, что:
- несколько срезов могут указывать на один и тот же базовый массив;
- изменение элемента через один срез отражается в других, если они разделяют память.
Пример:
package main
import "fmt"
func main() {
// Здесь мы создаем базовый массив фиксированной длины
arr := [5]int{1, 2, 3, 4, 5}
// Здесь мы берем срез массива - все элементы
s1 := arr[:]
// Здесь мы берем поддиапазон - элементы с индексами 1,2,3
s2 := arr[1:4]
// Изменяем элемент через второй срез
s2[0] = 20 // Это меняет arr[1]
fmt.Println(arr) // [1 20 3 4 5] - массив изменен
fmt.Println(s1) // [1 20 3 4 5] - срез s1 тоже "видит" изменения
}
Как видите, изменение через один срез затрагивает общий базовый массив.
Создание и инициализация срезов
Литералы срезов
Самый простой способ создать срез — использовать литерал:
// Здесь мы создаем срез целых чисел с тремя элементами
nums := []int{1, 2, 3}
// Здесь мы создаем срез строк
names := []string{"Alice", "Bob", "Charlie"}
Литерал среза одновременно создает базовый массив и срез, указывающий на него.
Функция make
Функция make — ключевой элемент Слайс API. Через нее вы создаете "пустой" срез определенной длины и емкости.
Синтаксис:
// Здесь мы создаем срез типа T с длиной len и емкостью cap
s := make([]T, len, cap)
Если емкость не указана, она равна длине:
// Здесь емкость будет равна 3
s := make([]int, 3)
Пример с комментариями:
package main
import "fmt"
func main() {
// Здесь мы создаем срез длиной 0 и емкостью 5
buf := make([]byte, 0, 5)
fmt.Println(len(buf)) // 0 - в срезе нет логических элементов
fmt.Println(cap(buf)) // 5 - но место в массиве уже выделено
}
Такой подход часто используют для снижения количества аллокаций, если вы заранее знаете примерный объем данных.
Срезы из массивов
Можно создать срез на основе массива:
package main
import "fmt"
func main() {
// Здесь мы создаем массив из 4 элементов
arr := [4]int{10, 20, 30, 40}
// Здесь мы берем срез со всеми элементами массива
s1 := arr[:]
// Здесь мы берем срез с элементами с индексами 1 и 2
s2 := arr[1:3]
fmt.Println(s1) // [10 20 30 40]
fmt.Println(s2) // [20 30]
}
Смотрите, теперь любые изменения через s1 или s2 будут отражаться в arr, пока новые append не приведут к выделению нового массива.
Поддиапазоны и операции над диапазонами
Базовый срез диапазона
Вы уже видели выражения вида a[i:j]. Это ключевая часть api-slice — возможность быстро создавать представление подмножества данных.
Правило индексов:
i— начальный индекс (включительно);j— конечный индекс (исключительно);- длина результата =
j - i; - выражение валидно, если
0 <= i <= j <= cap(a)иj <= len(a)для обычного среза.
Пример:
package main
import "fmt"
func main() {
// Здесь мы создаем срез из 5 элементов
s := []int{0, 1, 2, 3, 4}
// Здесь мы берем поддиапазон с элементами с индексами 1,2,3
sub := s[1:4]
fmt.Println(sub) // [1 2 3]
fmt.Println(len(sub)) // 3
fmt.Println(cap(sub)) // 4 - от s[1] до конца базового массива
}
Трехиндексный срез
Go позволяет задать емкость явно, используя трехиндексную форму: a[i:j:k], где:
i— начало;j— конец (длина);k— ограничение емкости;- длина =
j - i; - емкость =
k - i.
Это тоже часть Слайс API, хотя используется не так часто. Она полезна, если вы хотите "обрезать" доступную емкость и избежать влияния append на соседние данные.
Пример:
package main
import "fmt"
func main() {
// Здесь мы создаем исходный срез
s := []int{0, 1, 2, 3, 4}
// Здесь мы берем поддиапазон с ограниченной емкостью
sub := s[1:3:3]
fmt.Println(sub) // [1 2]
fmt.Println(len(sub)) // 2
fmt.Println(cap(sub)) // 2 - емкость ограничена
// Здесь мы добавляем элемент в sub
sub = append(sub, 99)
fmt.Println(sub) // [1 2 99]
// Обратите внимание - базовый срез s не изменился в части элементов
fmt.Println(s) // [0 1 2 3 4]
}
Здесь я использую трехиндексную форму, чтобы append провоцировал выделение нового массива и не "портил" исходный срез s.
Если бы мы взяли просто sub := s[1:3], емкость бы была больше, и append модифицировал бы элементы s.
Функция append и расширение среза
Общий принцип работы append
Функция append — центральный элемент Слайс API. Она позволяет добавлять элементы в срез:
// Базовый синтаксис
s = append(s, x) // добавить один элемент
s = append(s, x, y, z) // добавить несколько элементов
s = append(s, other...) // добавить все элементы другого среза
Важно: append всегда возвращает новый срез. Иногда этот новый срез использует тот же базовый массив, иногда — новый. Поэтому вы почти всегда должны присваивать результат обратно.
Пример:
package main
import "fmt"
func main() {
// Здесь мы создаем срез с емкостью 3
s := make([]int, 0, 3)
// Здесь мы добавляем элементы
s = append(s, 1)
s = append(s, 2, 3)
fmt.Println(s) // [1 2 3]
fmt.Println(len(s)) // 3
fmt.Println(cap(s)) // 3
// Здесь мы добавляем еще элементы, превышая емкость
s = append(s, 4)
fmt.Println(s) // [1 2 3 4]
fmt.Println(len(s)) // 4
fmt.Println(cap(s)) // емкость увеличена - значение зависит от стратегии рантайма
}
Когда длина среза при добавлении не превышает емкость, новый массив не выделяется. Когда превышает — создается новый массив, обычно большего размера (стратегия роста зависит от версии рантайма, но общая идея — рост примерно в 2 раза для больших срезов).
Когда append изменяет базовый массив, а когда — нет
Давайте сравним два сценария, чтобы вы увидели разницу.
Сценарий 1 - хватает емкости
package main
import "fmt"
func main() {
// Здесь мы создаем срез с длиной 3 и емкостью 5
base := make([]int, 3, 5)
copy(base, []int{1, 2, 3}) // Здесь мы заполняем элементы
// Здесь мы берем другой срез, указывающий на тот же массив
alias := base[:]
// Здесь мы добавляем один элемент - емкости достаточно
base = append(base, 4)
fmt.Println(base) // [1 2 3 4]
fmt.Println(alias) // [1 2 3 4 0] - alias видит измененный массив
}
Оба среза используют один базовый массив. append просто записывает новый элемент.
Сценарий 2 - емкости не хватает
package main
import "fmt"
func main() {
// Здесь мы создаем срез длиной и емкостью 3
base := []int{1, 2, 3}
// Здесь мы берем другой срез, указывающий на тот же массив
alias := base[:]
// Здесь мы добавляем несколько элементов - емкости уже не хватает
base = append(base, 4, 5, 6)
fmt.Println(base) // [1 2 3 4 5 6]
fmt.Println(alias) // [1 2 3] - alias по-прежнему ссылается на старый массив
fmt.Println(len(alias)) // 3
fmt.Println(cap(alias)) // 3
}
В этом случае рантайм создал новый массив, скопировал туда старые элементы и добавил новые. base теперь указывает на новый массив, а alias остался на старом.
Добавление среза к срезу
Очень часто вам нужно "слить" два среза. Для этого используют "распаковку" с помощью ...:
package main
import "fmt"
func main() {
// Здесь мы создаем первый срез
a := []int{1, 2, 3}
// Здесь мы создаем второй срез
b := []int{4, 5}
// Здесь мы добавляем все элементы из b в a
a = append(a, b...)
fmt.Println(a) // [1 2 3 4 5]
}
Три точки означают, что элементы b будут переданы в append как отдельные аргументы.
Функция copy и копирование срезов
Зачем нужен copy
Функция copy — еще один ключевой элемент Слайс API. Она позволяет скопировать элементы из одного среза в другой:
n := copy(dst, src)
Где:
dst— целевой срез;src— источник;n— количество реально скопированных элементов (минимум изlen(dst)иlen(src)).
Пример:
package main
import "fmt"
func main() {
// Здесь мы создаем исходный срез
src := []int{1, 2, 3, 4}
// Здесь мы создаем целевой срез длиной 2
dst := make([]int, 2)
// Здесь мы копируем данные
n := copy(dst, src)
fmt.Println(dst) // [1 2] - скопировалось только два элемента
fmt.Println(n) // 2 - количество скопированных элементов
}
Глубокое и "поверхностное" копирование
copy копирует значения элементов. Если элементы — простые типы (int, string, struct без указателей), можно считать, что это полноценное копирование.
Но если элементы — указатели, срезы, карты или структуры с указателями, то после copy оба среза будут ссылаться на одни и те же объекты.
Пример с указателями:
package main
import "fmt"
func main() {
// Здесь мы создаем два числа
a, b := 10, 20
// Здесь мы создаем срез указателей
src := []*int{&a, &b}
// Здесь мы создаем целевой срез той же длины
dst := make([]*int, len(src))
// Здесь мы копируем указатели
copy(dst, src)
// Меняем значение через dst
*dst[0] = 100
fmt.Println(*src[0]) // 100 - исходный тоже изменился
}
Как видите, copy не создает новые объекты, он копирует только ссылки.
Копирование перекрывающихся срезов
Иногда нужно "сдвинуть" часть среза внутри себя. copy корректно работает и для перекрывающихся областей.
Пример:
package main
import "fmt"
func main() {
// Здесь мы создаем срез
s := []int{1, 2, 3, 4, 5}
// Здесь мы "сдвигаем" элементы влево на один
copy(s, s[1:])
fmt.Println(s) // [2 3 4 5 5] - последний элемент дублируется
}
Этот прием часто используют для "удаления" элемента по индексу с сохранением порядка.
Работа со срезами в функциях
Передача среза как аргумента
Срез в Go — это структура из трех полей, и при передаче в функцию он передается по значению. Но внутри есть указатель на данные, поэтому изменение элементов внутри функции влияет на исходный массив.
Пример:
package main
import "fmt"
// Здесь мы определяем функцию, которая изменяет первый элемент среза
func modifyFirst(s []int) {
// Изменяем элемент по индексу 0
s[0] = 99
}
func main() {
// Здесь мы создаем срез
nums := []int{1, 2, 3}
// Передаем срез в функцию
modifyFirst(nums)
fmt.Println(nums) // [99 2 3] - первый элемент изменился
}
То есть:
- изменение элементов внутри функции видно снаружи;
- изменение самого дескриптора (например,
s = append(s, ...)) не влияет на исходную переменную, если вы не возвращаете новый срез наружу.
Пример:
package main
import "fmt"
// Здесь мы пытаемся добавить элементы к срезу внутри функции
func addNumbers(s []int) {
// append возвращает новый срез, но мы его не возвращаем и не сохраняем
s = append(s, 4, 5)
}
func main() {
// Здесь мы создаем исходный срез
nums := []int{1, 2, 3}
// Вызываем функцию
addNumbers(nums)
fmt.Println(nums) // [1 2 3] - длина не изменилась
}
Чтобы изменение длины было видно снаружи, вы либо должны вернуть новый срез, либо работать с указателем на срез:
package main
import "fmt"
// Вариант с возвратом нового среза
func addReturn(s []int) []int {
s = append(s, 4, 5)
return s
}
// Вариант с указателем на срез
func addPtr(ps *[]int) {
// Разыменовываем указатель и вызываем append
*ps = append(*ps, 4, 5)
}
func main() {
nums := []int{1, 2, 3}
// Первый вариант
nums = addReturn(nums)
fmt.Println(nums) // [1 2 3 4 5]
// Второй вариант
addPtr(&nums)
fmt.Println(nums) // [1 2 3 4 5 4 5]
}
Типичные операции над срезами
Добавление в начало и середину
У append есть важное ограничение — он добавляет только в конец. Но вы можете комбинировать append и поддиапазоны.
Добавление в начало
package main
import "fmt"
func prepend(s []int, v int) []int {
// Здесь мы создаем новый срез длиной 0, но с емкостью на один больше
s = append([]int{v}, s...) // Мы объединяем новый элемент и все старые
return s
}
func main() {
nums := []int{2, 3}
nums = prepend(nums, 1)
fmt.Println(nums) // [1 2 3]
}
Такой подход достаточно прост, но обычно требует выделения нового массива (особенно если срез большой).
Вставка в середину
package main
import "fmt"
func insertAt(s []int, idx int, v int) []int {
// Проверяем границы индекса
if idx < 0 || idx > len(s) {
return s
}
// Добавляем "пустое" место в конец
s = append(s, 0)
// Сдвигаем элементы вправо, начиная с позиции idx
copy(s[idx+1:], s[idx:])
// Записываем новый элемент в позицию idx
s[idx] = v
return s
}
func main() {
nums := []int{1, 2, 4, 5}
nums = insertAt(nums, 2, 3)
fmt.Println(nums) // [1 2 3 4 5]
}
Здесь я использую комбинацию append и copy, чтобы реализовать вставку с сохранением порядка.
Удаление элементов
Удаление по индексу — частая задача. Смотрите, я покажу вам один из стандартных паттернов.
Удаление по индексу с сохранением порядка
package main
import "fmt"
func removeAt(s []int, idx int) []int {
if idx < 0 || idx >= len(s) {
return s
}
// Сдвигаем элементы после idx влево
copy(s[idx:], s[idx+1:])
// Укорачиваем срез на один
return s[:len(s)-1]
}
func main() {
nums := []int{1, 2, 3, 4, 5}
nums = removeAt(nums, 2)
fmt.Println(nums) // [1 2 4 5]
}
Удаление без сохранения порядка
Иногда порядок не важен, и можно удалить элемент более эффективно:
package main
import "fmt"
func removeUnordered(s []int, idx int) []int {
if idx < 0 || idx >= len(s) {
return s
}
// Переносим последний элемент на место удаляемого
s[idx] = s[len(s)-1]
// Укорачиваем срез
return s[:len(s)-1]
}
func main() {
nums := []int{10, 20, 30, 40}
nums = removeUnordered(nums, 1)
fmt.Println(nums) // Например [10 40 30] - порядок изменен
}
Этот прием особенно полезен для больших срезов, когда вы часто удаляете элементы.
Память, аллокации и утечки при работе со срезами
Удержание "лишней" памяти поддиапазонами
Опасный момент Слайс API — когда вы создаете поддиапазон большого среза, он удерживает в памяти весь базовый массив. Даже если новый срез содержит пару элементов.
Пример:
package main
import "fmt"
func main() {
// Здесь мы создаем большой срез
big := make([]byte, 1_000_000)
// Здесь мы берем маленький поддиапазон - первые 10 байт
small := big[:10]
// Если здесь забыть big, сборщик мусора все равно не освободит память,
// потому что small ссылается на тот же массив
fmt.Println(len(small), cap(small)) // 10 и 1_000_000
}
Чтобы "отцепиться" от большого массива, удобно скопировать нужные данные в новый срез:
package main
import "fmt"
func main() {
big := make([]byte, 1_000_000)
// Здесь мы берем нужный диапазон
part := big[100:200]
// Здесь мы создаем новый срез точной длины
data := make([]byte, len(part))
// Копируем только нужные байты
copy(data, part)
// Теперь data не держит большой массив, и тот может быть освобожден
fmt.Println(len(data), cap(data)) // 100 и 100
}
Срез и escape analysis
Когда вы храните срезы в структурах, передаете их между горутинами, рантайму иногда приходится размещать базовые массивы в куче. Тогда важно следить за лишними аллокациями.
Несколько практических советов:
- если вы заранее знаете размер данных, используйте
makeс нужной емкостью; - избегайте лишних копирований срезов, когда они не нужны;
- старайтесь не создавать много временных срезов-поддиапазонов в горячем коде, если это приводит к удержанию больших массивов.
Срезы и горутины
Совместное использование срезов из разных горутин
Срезы сами по себе не потокобезопасны. Если две горутины одновременно:
- читают и записывают элементы;
- или записывают элементы;
то вы можете получить гонку данных.
Общий подход:
- если только чтение — безопасно использовать один и тот же срез;
- если есть запись — либо защищайте доступ мьютексами, либо используйте каналы, либо не делитесь срезами.
Пример потенциальной гонки:
package main
import (
"fmt"
"sync"
)
func main() {
// Здесь мы создаем срез
nums := []int{1, 2, 3}
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
// Изменяем элемент
nums[0] = 100 // Запись
}()
go func() {
defer wg.Done()
// Читаем элемент
fmt.Println(nums[0]) // Чтение
}()
wg.Wait()
}
Без синхронизации этот код может вызвать гонку. Чтобы избежать этого, нужно либо защитить доступ, либо работать с копиями:
// Здесь мы передаем в горутину копию среза
go func(local []int) {
defer wg.Done()
local[0] = 100 // Меняем только локальную копию
}(append([]int(nil), nums...)) // Копируем базовый массив
Практические паттерны использования Слайс API
Буферизация данных
Часто срезы используют как буфер для чтения/записи.
Пример буфера для чтения из io.Reader:
package main
import (
"io"
"os"
)
func main() {
// Здесь мы создаем буфер фиксированного размера
buf := make([]byte, 4096)
for {
// Читаем данные в буфер
n, err := os.Stdin.Read(buf)
if n > 0 {
// Обрабатываем только n байт
process(buf[:n]) // Передаем поддиапазон реальных данных
}
if err == io.EOF {
break
}
if err != nil {
// Здесь надо обработать ошибку
break
}
}
}
func process(data []byte) {
// Здесь мы обрабатываем данные среза
// ...
}
Здесь вы видите типичный прием: один и тот же базовый массив используется многократно, а "видимая" часть меняется через поддиапазоны.
Пулл срезов
Чтобы снизить количество аллокаций, иногда используют пулы срезов (через sync.Pool или кастомные структуры). Внутри таких решений активно используется Слайс API: make, append, диапазоны и copy.
Здесь важно аккуратно обнулять срезы перед возвратом в пул, чтобы не удерживать лишние объекты.
package main
import (
"sync"
)
var bufPool = sync.Pool{
New: func() any {
// Здесь мы создаем новый срез фиксированной емкости
b := make([]byte, 0, 4096)
return &b
},
}
func getBuf() []byte {
// Берем указатель на срез из пула
b := bufPool.Get().(*[]byte)
// Обрезаем длину до нуля, но сохраняем емкость
*b = (*b)[:0]
return *b
}
func putBuf(b []byte) {
// Обнуляем данные, если нужно, и кладем обратно
bufPool.Put(&b)
}
Так вы повторно используете один и тот же базовый массив для разных операций.
Заключение
Срезы в Go и сопутствующий им Слайс API (операции диапазона, функции make, append, copy, передача в функции, работа с емкостью и длиной) образуют мощный и гибкий механизм работы с последовательностями данных. Важно помнить несколько ключевых идей:
- срез — это дескриптор поверх базового массива, а не сам массив;
- len и cap — разные вещи, и
appendопирается именно на емкость; appendможет как модифицировать существующий массив, так и создавать новый — из-за этого копии срезов иногда ведут себя по-разному;- поддиапазоны удерживают в памяти весь базовый массив, что может приводить к скрытым утечкам;
copyкопирует значения элементов, но не обязательно объекты, на которые те ссылаются;- при работе с горутинами срезы нужно использовать аккуратно, синхронизируя доступ при записи.
Если вы будете осознанно использовать эти свойства, то сможете писать более предсказуемый, эффективный и простой для сопровождения код на Go.
Частозадаваемые технические вопросы
Как безопасно "обрезать" емкость среза, чтобы append не затронул соседние данные?
Используйте трехиндексный срез:
sub := s[i:j:j] // len = j-i, cap = j-i
Так вы ограничите емкость поддиапазона, и любой append к sub приведет к выделению нового массива, не изменяя s.
Как скопировать срез "полностью", включая базовый массив, чтобы изменения не влияли на оригинал?
Создайте новый срез нужной длины и используйте copy:
dst := make([]T, len(src)) // Новый массив
copy(dst, src) // Копируем элементы
Теперь dst и src независимы по памяти (кроме случаев, когда элементы — указатели или сложные объекты).
Как эффективно очистить срез, чтобы он не держал в памяти большие объекты?
Если вам нужно, чтобы сборщик мусора мог освободить объекты, на которые ссылаются элементы, обнулите их и укоротите срез:
for i := range s {
s[i] = nil // или zero-value вашего типа
}
s = s[:0] // длина 0, емкость прежняя
Если нужно уменьшить и емкость, создайте новый срез и скопируйте нужные данные.
Почему append к срезу, переданному в функцию, иногда "не работает"?
Потому что append возвращает новый срез, а вы изменяете только локальную копию аргумента. Возвращайте срез из функции или передавайте указатель:
func f(s []int) []int {
s = append(s, 1)
return s
}
или
func f(ps *[]int) {
*ps = append(*ps, 1)
}
Как избежать лишних аллокаций при постепенном росте среза?
Предварительно задайте емкость с помощью make, если примерно знаете ожидаемый размер:
s := make([]T, 0, expected)
Если размер неизвестен, можно увеличивать емкость порциями (например, удваивать свою "целевую" емкость и перераспределять массив вручную), но чаще достаточно встроенной стратегии роста рантайма.