Язык Go — слайсы
Слайсы — основной инструмент работы с последовательностями данных в Go. Разбираем внутреннее устройство, len и cap, срезание, копирование, append и неочевидные ситуации.
Содержание
Слайсы — основной способ работы с последовательностями данных в Go. Большинство задач, для которых в других языках используют массивы, в Go решаются слайсами. Чтобы не попасть в ловушки, нужно понимать, как слайс устроен внутри.
Что такое слайс на самом деле
Слайс — это не массив. Это дескриптор, состоящий из трёх полей:
- указатель на элемент массива, с которого начинается слайс
- длина (
len) — количество элементов, доступных через слайс - ёмкость (
cap) — количество элементов от начала слайса до конца подлежащего массива
// под капотом слайс выглядит примерно так:
// type slice struct {
// ptr *T // указатель на данные
// len int // длина
// cap int // ёмкость
// }
a := [6]int{10, 20, 30, 40, 50, 60} // массив из 6 элементов
s := a[1:4] // слайс: начало с индекса 1, конец перед индексом 4
// s.ptr -> &a[1]
// s.len == 3 (элементы: 20, 30, 40)
// s.cap == 5 (от a[1] до конца массива: 20, 30, 40, 50, 60)
fmt.Println(s) // [20 30 40]
fmt.Println(len(s)) // 3
fmt.Println(cap(s)) // 5
Слайс — это ссылка на массив
Два слайса, указывающие на один массив, видят изменения друг друга:
a := []int{1, 2, 3, 4, 5}
s1 := a[0:3] // [1 2 3]
s2 := a[1:4] // [2 3 4]
s1[1] = 99 // меняем второй элемент s1, это a[1]
fmt.Println(s1) // [1 99 3]
fmt.Println(s2) // [99 3 4] — s2 тоже изменился, они делят массив
fmt.Println(a) // [1 99 3 4 5]
Это важно понимать при работе с функциями. Функция, принимающая слайс, может изменять его элементы — и изменения будут видны снаружи:
func fill(s []int, val int) {
for i := range s {
s[i] = val
}
}
data := []int{1, 2, 3, 4, 5}
fill(data[1:4], 0) // заполняем нулями элементы с 1 по 3
fmt.Println(data) // [1 0 0 0 5]
Read принимает слайс, а не указатель и размер
В C функция чтения обычно принимает указатель на буфер и размер:
read(fd, buf, count). В Go слайс уже содержит и то и другое:
// сигнатура Read из пакета os
// func (f *File) Read(buf []byte) (n int, err error)
buf := make([]byte, 1024)
// читаем только первые 32 байта большого буфера — срезаем его
n, err := f.Read(buf[0:32])
// Read видит слайс длиной 32 и не выйдет за его пределы
Тот же результат, но неэффективно — побайтовое чтение через срезание:
var n int
var err error
for i := 0; i < 32; i++ {
nbytes, e := f.Read(buf[i : i+1]) // срезаем буфер до одного байта
n += nbytes
if nbytes == 0 || e != nil {
err = e
break
}
}
Это показывает механику: buf[i:i+1] — слайс длиной 1, указывающий на buf[i].
len и cap: длина и ёмкость
len — сколько элементов сейчас в слайсе. cap — сколько может быть без переаллокации.
s := make([]int, 3, 10) // длина 3, ёмкость 10
fmt.Println(len(s), cap(s)) // 3 10
// можно расширить слайс до ёмкости без переаллокации
s = s[:7]
fmt.Println(len(s), cap(s)) // 7 10
// нельзя выйти за ёмкость
// s = s[:11] // panic: runtime error: slice bounds out of range
len и cap корректно работают с nil-слайсом — оба возвращают 0:
var s []int
fmt.Println(s == nil) // true
fmt.Println(len(s)) // 0 — не паника
fmt.Println(cap(s)) // 0 — не паника
Срезание: операции с индексами
Базовый синтаксис s[low:high] — элементы от low до high (не включая high):
s := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
fmt.Println(s[2:5]) // [2 3 4]
fmt.Println(s[:3]) // [0 1 2] — low по умолчанию 0
fmt.Println(s[7:]) // [7 8 9] — high по умолчанию len(s)
fmt.Println(s[:]) // [0 1 2 3 4 5 6 7 8 9] — весь слайс
Трёхиндексный срез: ограничение ёмкости
Третий индекс ограничивает ёмкость результата. Это защищает от случайного доступа к элементам за пределами нужного диапазона:
s := []int{0, 1, 2, 3, 4, 5}
// s[low:high:max] — ёмкость результата равна max-low
t := s[1:3:4]
fmt.Println(t) // [1 2]
fmt.Println(len(t)) // 2
fmt.Println(cap(t)) // 3 (4-1)
// без третьего индекса t имел бы cap == 5 (6-1)
// и append мог бы затронуть элементы исходного слайса
u := s[1:3]
fmt.Println(cap(u)) // 5 — видит до конца s
Это важно при передаче подслайса в функцию, которая делает append: без ограничения ёмкости append может перезаписать элементы исходного слайса.
Копирование слайсов
copy(dst, src) копирует min(len(dst), len(src)) элементов и возвращает количество скопированных:
src := []int{1, 2, 3, 4, 5}
dst := make([]int, 3)
n := copy(dst, src) // копирует min(3, 5) = 3 элемента
fmt.Println(dst) // [1 2 3]
fmt.Println(n) // 3
// src не изменился
fmt.Println(src) // [1 2 3 4 5]
copy работает правильно даже при перекрывающихся слайсах:
s := []int{1, 2, 3, 4, 5}
// сдвигаем элементы влево на одну позицию
copy(s, s[1:]) // копируем s[1:] в s[0:]
fmt.Println(s) // [2 3 4 5 5] — последний элемент дублируется
append: как это работает под капотом
Встроенный append — это именно то, что описано в документации через Append. Посмотрим на ручную реализацию, чтобы понять механику:
func Append(slice, data []byte) []byte {
l := len(slice)
if l+len(data) > cap(slice) { // данные не влезают — нужна переаллокация
// выделяем вдвое больше нужного, чтобы реже делать аллокации
newSlice := make([]byte, (l+len(data))*2)
copy(newSlice, slice) // копируем старые данные
slice = newSlice
}
slice = slice[0 : l+len(data)] // расширяем длину
copy(slice[l:], data) // копируем новые данные в конец
return slice // возвращаем новый дескриптор
}
Ключевой момент: слайс передаётся по значению. Функция получает копию дескриптора (указатель, len, cap). Если внутри происходит переаллокация, новый дескриптор должен быть возвращён — иначе вызывающий код не узнает о новом адресе данных.
Именно поэтому append всегда используется как s = append(s, ...):
s := []int{1, 2, 3}
s = append(s, 4, 5) // добавляем два элемента
fmt.Println(s) // [1 2 3 4 5]
// append нескольких элементов сразу
s = append(s, []int{6, 7, 8}...) // ... разворачивает слайс в аргументы
fmt.Println(s) // [1 2 3 4 5 6 7 8]
append и скрытое разделение памяти
Самая неочевидная ловушка: если ёмкости хватает, append не делает переаллокацию и новый слайс делит память со старым:
a := make([]int, 3, 6) // len=3, cap=6
a[0], a[1], a[2] = 1, 2, 3
b := append(a, 4) // cap(a)=6, места хватает — переаллокации нет
b[0] = 99 // меняем b[0]
fmt.Println(a) // [99 2 3] — a тоже изменился! они делят массив
fmt.Println(b) // [99 2 3 4]
Чтобы этого избежать — используйте трёхиндексный срез или явное копирование:
a := make([]int, 3, 6)
a[0], a[1], a[2] = 1, 2, 3
// ограничиваем ёмкость — append будет вынужден сделать переаллокацию
b := append(a[:3:3], 4) // третий индекс == len, cap(a[:3:3]) == 3
b[0] = 99
fmt.Println(a) // [1 2 3] — a не изменился
fmt.Println(b) // [99 2 3 4]
Рост ёмкости при append
Go не гарантирует конкретный коэффициент роста, но исторически он близок к удвоению для малых слайсов:
s := make([]int, 0)
prevCap := 0
for i := 0; i < 20; i++ {
s = append(s, i)
if cap(s) != prevCap {
fmt.Printf("len=%d cap=%d\n", len(s), cap(s))
prevCap = cap(s)
}
}
// len=1 cap=1
// len=2 cap=2
// len=3 cap=4
// len=5 cap=8
// len=9 cap=16
// len=17 cap=32
Переаллокация — это дорого. Если размер известен заранее, передайте его в make:
n := 10000
s := make([]int, 0, n) // выделяем сразу, append не будет делать переаллокации
for i := 0; i < n; i++ {
s = append(s, i)
}
nil-слайс vs пустой слайс
Это разные вещи с одинаковым поведением в большинстве случаев — но не во всех:
var nilSlice []int // nil-слайс: ptr=nil, len=0, cap=0
emptySlice := []int{} // пустой слайс: ptr!=nil, len=0, cap=0
emptySlice2 := make([]int, 0) // тоже пустой
fmt.Println(nilSlice == nil) // true
fmt.Println(emptySlice == nil) // false
fmt.Println(len(nilSlice)) // 0
fmt.Println(len(emptySlice)) // 0
// append работает одинаково для обоих
nilSlice = append(nilSlice, 1)
fmt.Println(nilSlice) // [1]
Разница проявляется при сериализации в JSON:
import "encoding/json"
type Response struct {
Items []int `json:"items"`
}
r1 := Response{Items: nil}
r2 := Response{Items: []int{}}
b1, _ := json.Marshal(r1)
b2, _ := json.Marshal(r2)
fmt.Println(string(b1)) // {"items":null} — nil-слайс → null
fmt.Println(string(b2)) // {"items":[]} — пустой слайс → пустой массив
Если API должен возвращать [] вместо null — инициализируйте слайс явно.
Удаление элемента из слайса
В Go нет встроенной операции удаления. Стандартный идиом — через append:
s := []int{1, 2, 3, 4, 5}
i := 2 // удаляем элемент с индексом 2 (значение 3)
s = append(s[:i], s[i+1:]...)
fmt.Println(s) // [1 2 4 5]
Важно: это изменяет порядок — нет, порядок сохраняется, но есть более быстрый способ если порядок не важен:
s := []int{1, 2, 3, 4, 5}
i := 2
// меняем удаляемый элемент с последним и обрезаем
s[i] = s[len(s)-1]
s = s[:len(s)-1]
fmt.Println(s) // [1 2 5 4] — порядок нарушен, но O(1) вместо O(n)
Итоги
- Слайс — это дескриптор из трёх полей: указатель, длина, ёмкость. Не массив
- Присвоение слайса не копирует данные — оба слайса ссылаются на один массив
len— текущая длина,cap— максимальная без переаллокацииlenиcapвозвращают 0 дляnil-слайса, паники нетs[low:high:max]— трёхиндексный срез ограничивает ёмкость результатаcopy(dst, src)— копируетmin(len(dst), len(src))элементовappendвозвращает новый дескриптор — всегда используйтеs = append(s, ...)- Если ёмкости хватает,
appendне делает переаллокацию и новый слайс делит память со старым nil-слайс и пустой слайс ведут себя одинаково дляlen,cap,append— но по-разному в JSON- Если размер известен — передавайте ёмкость в
make, чтобы избежать лишних переаллокаций