· 11 мин 👁 1.9k Начинающий

Язык Go — слайсы

Слайсы — основной инструмент работы с последовательностями данных в Go. Разбираем внутреннее устройство, len и cap, срезание, копирование, append и неочевидные ситуации.

слайсымассивыappendcapcopyпамять
Содержание

Слайсы — основной способ работы с последовательностями данных в 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, чтобы избежать лишних переаллокаций