Олег Марков
Слайс API в Go Golang - практическое руководство по api-slice
Введение
Слайсы (slices) в Go — это основной инструмент работы с последовательностями данных. Обычные массивы в Go фиксированы по размеру и неудобны для динамических структур. Именно поэтому в стандартной библиотеке и в типичном Go-коде вы почти всегда увидите работу не с массивами, а со слайсами.
Под Слайс API (или api-slice) будем понимать набор возможностей языка и стандартной библиотеки для:
- создания и инициализации слайсов;
- безопасного доступа к элементам;
- изменения длины и емкости;
- добавления и удаления элементов;
- копирования и слияния слайсов;
- эффективной передачи данных между функциями без лишних аллокаций.
Смотрите, я покажу вам, как шаг за шагом разобраться с тем, как устроен слайс «под капотом» и как использовать его API так, чтобы код был и читаемым, и эффективным.
Базовое устройство слайса (api-slice основа)
Логическая модель слайса
Слайс в Go — это не просто «динамический массив». Внутри он представляет собой небольшую структуру, которая содержит:
- указатель на подлежащий массив (array);
- длину (len);
- емкость (cap).
С точки зрения языка вы этого напрямую не видите, но полезно понимать, как это работает.
Обратите внимание, как можно проиллюстрировать состояние слайса:
package main
import "fmt"
func main() {
// Создаем базовый массив из 5 элементов
arr := [5]int{10, 20, 30, 40, 50}
// На основе массива создаем слайс из элементов с индекса 1 по 3 (3 не включительно)
sl := arr[1:3]
fmt.Println("sl:", sl) // [20 30]
fmt.Println("len:", len(sl)) // 2 - в слайсе два элемента
fmt.Println("cap:", cap(sl)) // 4 - емкость от arr[1] до конца массива
// Изменим второй элемент слайса
sl[1] = 300
fmt.Println("sl после изменения:", sl) // [20 300]
fmt.Println("arr после изменения:", arr) // [10 20 300 40 50]
}
Комментарии к примеру:
- слайс
slхранит ссылку на часть массиваarr; - изменение элемента слайса меняет соответствующий элемент исходного массива;
len(sl)— количество доступных элементов слайса;cap(sl)— сколько элементов можно добавить (расширяя срез вправо), прежде чем потребуется новый массив.
Это и есть фундаментальное устройство Слайс API: большинство операций работают с этой тройкой — указатель, длина, емкость.
Нулевой слайс и пустой слайс
Важно различать:
- нулевой слайс (nil slice);
- пустой слайс с длиной 0.
Давайте разберемся на примере:
package main
import "fmt"
func main() {
// Нулевой слайс - значение по умолчанию
var s1 []int
fmt.Println(s1 == nil) // true - слайс не указывает ни на какой массив
// Пустой слайс, созданный через литерал
s2 := []int{}
fmt.Println(s2 == nil) // false - указывает на массив длины 0
// Пустой слайс через make
s3 := make([]int, 0)
fmt.Println(s3 == nil) // false - тоже указывает на массив длины 0
}
С точки зрения большинства операций (итерации, append, len) нулевой и пустой слайсы ведут себя похоже. Но при сравнении с nil вы увидите разницу.
Создание слайсов: основные способы api-slice
Создание через литерал слайса
Самый простой и наглядный способ — использовать литерал:
numbers := []int{1, 2, 3, 4}
// numbers - слайс с длиной 4 и емкостью 4
Комментарии:
- компилятор сам создаст подлежащий массив и обернет его в слайс;
- удобно, когда вам известны значения заранее.
Можно сразу создать слайс слайсов:
matrix := [][]int{
{1, 2, 3}, // первая "строка"
{4, 5, 6}, // вторая "строка"
{7, 8, 9}, // третья "строка"
}
// matrix - слайс, каждый элемент которого тоже слайс int
Создание через make
Функция make — базовая часть Слайс API для выделения динамических массивов.
Сигнатура для слайсов:
make([]T, length, capacity)
length— начальная длина;capacity— емкость (можно не указывать, тогда емкость = длина).
Пример:
package main
import "fmt"
func main() {
// Создаем слайс длиной 5 и емкостью 10
sl := make([]int, 5, 10)
fmt.Println("len:", len(sl)) // 5
fmt.Println("cap:", cap(sl)) // 10
fmt.Println("sl:", sl) // [0 0 0 0 0] - элементы по умолчанию
// Изменим пару элементов
sl[0] = 100
sl[4] = 500
fmt.Println("sl после изменений:", sl) // [100 0 0 0 500]
}
Если не указать емкость:
sl := make([]int, 5) // len = 5, cap = 5
Зачем вообще отдельный параметр capacity? Он дает вам возможность заранее зарезервировать место под элементы и тем самым уменьшить количество аллокаций при append.
Срезы на основе массивов и слайсов
Срез на основе массива
Вы уже видели пример выше, но давайте разберем варианты синтаксиса:
arr := [5]int{10, 20, 30, 40, 50}
// Полный срез с указанием начала и конца
s1 := arr[1:4] // элементы с индекса 1 по 3
// Срез от начала массива
s2 := arr[:3] // с 0 до 2
// Срез до конца массива
s3 := arr[2:] // с 2 до конца
// Срез всего массива
s4 := arr[:] // все элементы
Все эти слайсы разделяют один и тот же подлежащий массив.
Срез на основе слайса
Аналогично можно делать срез от уже существующего слайса:
base := []int{1, 2, 3, 4, 5, 6}
// Срез середины
middle := base[2:5] // элементы 3, 4, 5
// Срез от начала
head := base[:3]
// Срез "хвоста"
tail := base[3:]
Все они продолжают ссылаться на один и тот же подлежащий массив (этот массив был создан при инициализации base).
Функция append и динамическое расширение api-slice
Функция append — это, по сути, центральная часть практического Слайс API. Она позволяет вам добавлять элементы в слайс без ручного управления массивами.
Базовый синтаксис append
Смотрите, я покажу вам базовый пример:
package main
import "fmt"
func main() {
// Нулевой слайс
var sl []int
// Добавляем элементы по одному
sl = append(sl, 10) // создается новый массив и sl указывает на него
sl = append(sl, 20)
sl = append(sl, 30)
fmt.Println("sl:", sl) // [10 20 30]
fmt.Println("len:", len(sl)) // 3
fmt.Println("cap:", cap(sl)) // может быть 4 или 3 - зависит от реализации
}
Главное правило: append может вернуть новый слайс, который ссылается на другой, новый массив. Поэтому результат всегда нужно присваивать переменной, иначе изменения будут потеряны.
Добавление нескольких элементов
Вы можете добавить сразу несколько значений:
sl := []int{1, 2}
// Добавляем два элемента
sl = append(sl, 3, 4)
fmt.Println(sl) // [1 2 3 4]
Объединение слайсов с помощью append
Чтобы слить два слайса в один, используйте оператор ...:
a := []int{1, 2}
b := []int{3, 4, 5}
// Здесь я размещаю пример, чтобы вам было проще понять.
a = append(a, b...) // "раскрываем" слайс b в список аргументов
fmt.Println(a) // [1 2 3 4 5]
Если вы забудете ..., получите ошибку компиляции, потому что append ожидает значения типа T, а не []T.
Как работает рост емкости при append
Строгой спецификации по росту нет, но общее поведение таково:
- если
len < cap, новый элемент помещается в уже существующий массив; - если
len == cap, создается новый массив большего размера (обычно примерно в 2 раза больше), в него копируются данные, и слайс начинает ссылаться на новый массив.
Покажу вам это на практике:
package main
import "fmt"
func main() {
sl := make([]int, 0, 2) // len=0, cap=2
for i := 1; i <= 4; i++ {
sl = append(sl, i)
fmt.Printf("После append %d: len=%d, cap=%d, sl=%v\n",
i, len(sl), cap(sl), sl)
}
}
Вы увидите, как емкость растет, когда текущей становится недостаточно.
Копирование слайсов: функция copy
В Слайс API есть специальная функция copy, позволяющая копировать элементы одного слайса в другой.
Сигнатура и базовое использование
Сигнатура:
copy(dst, src []T) int
Возвращает количество скопированных элементов — это минимальное из len(dst) и len(src).
Пример:
package main
import "fmt"
func main() {
src := []int{1, 2, 3, 4}
dst := make([]int, 2) // длина 2
// Копируем элементы
n := copy(dst, src)
fmt.Println("dst:", dst) // [1 2]
fmt.Println("скопировано элементов:", n) // 2
}
Комментарии:
- если целевой слайс меньше, часть данных обрежется;
- если целевой больше, лишние элементы останутся как значения по умолчанию.
Копирование перекрывающихся слайсов
Смотрите пример с перекрытием:
package main
import "fmt"
func main() {
sl := []int{1, 2, 3, 4, 5}
// Давайте посмотрим, что происходит в следующем примере.
// Сдвигаем часть слайса влево на один элемент
copy(sl[1:], sl[2:]) // копируем элементы со сдвигом
fmt.Println(sl) // [1 3 4 5 5]
}
Функция copy корректно работает, даже если исходный и целевой слайсы «смотрят» на один и тот же массив и частично перекрываются. Это удобно для операций удаления и вставки.
Удаление элементов из слайса api-slice
В Слайс API нет отдельной встроенной функции remove, но на практике используется несколько типовых шаблонов. Покажу вам самые распространенные.
Удаление элемента по индексу (порядок важен)
Когда важен порядок элементов, используют копирование «хвоста» поверх удаляемого элемента и обрезание слайса:
package main
import "fmt"
func main() {
sl := []int{10, 20, 30, 40, 50}
i := 2 // удаляем элемент с индексом 2 (значение 30)
// Обратите внимание, как этот фрагмент кода решает задачу.
sl = append(sl[:i], sl[i+1:]...)
fmt.Println(sl) // [10 20 40 50]
}
Комментарии:
sl[:i]— элементы до удаляемого;sl[i+1:]— элементы после удаляемого;appendсоздаст новый слайс поверх подлежащего массива или с новым массивом, если не хватит емкости.
Удаление элемента по индексу (порядок не важен)
Если порядок можно не сохранять, можно сделать быстрее:
package main
import "fmt"
func main() {
sl := []int{10, 20, 30, 40, 50}
i := 1 // удалим элемент с индексом 1 (20)
// Переносим последний элемент на место удаляемого
sl[i] = sl[len(sl)-1]
// Обрезаем слайс на один элемент
sl = sl[:len(sl)-1]
fmt.Println(sl) // например [10 50 30 40] - порядок изменился
}
Здесь вы не копируете «хвост», что снижает количество операций.
Удаление диапазона элементов
Иногда нужно удалить сразу несколько элементов подряд:
package main
import "fmt"
func main() {
sl := []int{1, 2, 3, 4, 5, 6, 7}
from, to := 2, 5 // удалим элементы с индексами 2,3,4
// Срез до начала удаляемого диапазона + срез после него
sl = append(sl[:from], sl[to:]...)
fmt.Println(sl) // [1 2 6 7]
}
Вставка элементов в середину слайса
Официального метода «вставить в середину» нет, но Слайс API позволяет легко собрать нужный паттерн из append и copy.
Вставка одного элемента
Покажу вам простой способ через append:
package main
import "fmt"
func main() {
sl := []int{10, 20, 40, 50}
i := 2
value := 30
// Теперь вы увидите, как это выглядит в коде.
sl = append(sl[:i], append([]int{value}, sl[i:]...)...)
fmt.Println(sl) // [10 20 30 40 50]
}
Комментарии:
- внутри создается временный слайс
[]int{value}; - затем он конкатенируется со «хвостом»
sl[i:]; - итоговый результат добавляется к
sl[:i].
Минус такого подхода — временные аллокации.
Вставка без лишних аллокаций (copy)
Более эффективный способ — заранее увеличить слайс и сдвинуть хвост:
package main
import "fmt"
func main() {
sl := []int{10, 20, 40, 50}
i := 2
value := 30
// Расширяем слайс на один элемент
sl = append(sl, 0) // временное значение, будет перезаписано
// Сдвигаем хвост вправо на одну позицию
copy(sl[i+1:], sl[i:])
// Вставляем новый элемент
sl[i] = value
fmt.Println(sl) // [10 20 30 40 50]
}
Здесь я размещаю пример, чтобы вам было проще увидеть шаги:
append(sl, 0)увеличивает длину и при необходимости емкость;copy(sl[i+1:], sl[i:])освобождает позициюi;- присваивание
sl[i] = valueвставляет элемент.
Работа с len и cap в api-slice
Получение длины и емкости
Функции len и cap — часть встроенного Слайс API:
sl := make([]int, 3, 10)
l := len(sl) // 3
c := cap(sl) // 10
Длина определяет:
- сколько элементов уже «занято»;
- до какого индекса можно безопасно обращаться.
Емкость определяет:
- сколько элементов можно теоретически поместить в текущий подлежащий массив без его перевыделения.
Изменение длины через срезы
Поскольку длина слайса — это просто число в структуре-слайсе, вы можете менять ее, создавая новый слайс на основе старого:
package main
import "fmt"
func main() {
sl := make([]int, 5, 10)
fmt.Println("len:", len(sl), "cap:", cap(sl)) // len=5, cap=10
// Уменьшаем длину до 3
sl = sl[:3]
fmt.Println("после уменьшения:", len(sl), cap(sl)) // len=3, cap=10
// Увеличиваем длину до 7 (но не больше cap)
sl = sl[:7]
fmt.Println("после увеличения:", len(sl), cap(sl)) // len=7, cap=10
}
Важно: при увеличении длины новые элементы получают значения по умолчанию (для чисел — 0, для строк — пустая строка и т.п.).
Итерация по слайсам и изменение элементов
Итерация с помощью range
Стандартный способ обхода — цикл for range:
sl := []int{10, 20, 30}
for i, v := range sl {
// i - индекс
// v - копия значения по этому индексу
fmt.Println(i, v)
}
Если индекс не нужен:
for _, v := range sl {
fmt.Println(v)
}
Если значение не нужно:
for i := range sl {
fmt.Println(i)
}
Изменение элементов внутри range
Чтобы изменить элементы, обращайтесь к слайсу по индексу:
package main
import "fmt"
func main() {
sl := []int{1, 2, 3}
for i := range sl {
// Умножаем каждый элемент на 10
sl[i] *= 10
}
fmt.Println(sl) // [10 20 30]
}
Если вы будете изменять только переменную v, а не sl[i], изменения не попадут обратно в слайс, потому что v — это копия.
Передача слайсов между функциями
Передача по значению, но без копирования массива
Слайс как структура (указатель, длина, емкость) передается в функцию по значению. Но подлежащий массив не копируется. Поэтому изменения элементов внутри функции будут видны снаружи.
Теперь давайте перейдем к примеру:
package main
import "fmt"
func multiplyBy2(sl []int) {
// Умножаем каждый элемент на 2
for i := range sl {
sl[i] *= 2
}
}
func main() {
sl := []int{1, 2, 3}
multiplyBy2(sl)
fmt.Println(sl) // [2 4 6] - изменения видны в main
}
Изменение длины слайса внутри функции
Если вы в функции делаете append, вы можете получить новый слайс, который указывает на новый массив. Это изменение не будет автоматически отражено в вызывающем коде, если вы не вернете слайс из функции.
Покажу вам, как это реализовано на практике:
package main
import "fmt"
func addElementWrong(sl []int, v int) {
// Ошибка проектирования - новый слайс теряется
sl = append(sl, v)
}
func addElementCorrect(sl []int, v int) []int {
// Возвращаем новый слайс
sl = append(sl, v)
return sl
}
func main() {
sl := []int{1, 2, 3}
addElementWrong(sl, 4)
fmt.Println("после addElementWrong:", sl) // [1 2 3]
sl = addElementCorrect(sl, 4)
fmt.Println("после addElementCorrect:", sl) // [1 2 3 4]
}
Если функция должна расширять слайс, всегда планируйте, что она вернет новый слайс.
Семантика ссылок и подводные камни api-slice
Общий подлежащий массив и «утечки» памяти
Когда вы делаете срез от большого массива, новый слайс продолжает держать ссылку на весь массив, а не только на свою часть. Это может приводить к удержанию в памяти больших структур.
Давайте разберемся на примере:
package main
import "fmt"
func getPrefix(data []byte) []byte {
// Возвращаем первые 3 байта
return data[:3]
}
func main() {
big := make([]byte, 1_000_000) // большой слайс
prefix := getPrefix(big)
_ = prefix
fmt.Println("len(big):", len(big)) // 1000000
// Переменная prefix продолжает удерживать big в памяти,
// хотя нам нужно всего 3 байта.
}
Чтобы избежать этого, часто копируют нужную часть в новый слайс:
func safePrefix(data []byte) []byte {
prefix := make([]byte, 3)
copy(prefix, data[:3])
return prefix
}
Так новый слайс не будет держать ссылку на старый большой массив.
Изменения через разные слайсы
Если два слайса разделяют подлежащий массив, изменение через один слайс отражается в другом.
package main
import "fmt"
func main() {
base := []int{1, 2, 3, 4}
a := base[1:3] // [2 3]
b := base[2:] // [3 4]
a[1] = 300 // меняем элемент с индексом 1 в a
fmt.Println("base:", base) // [1 2 300 4]
fmt.Println("a:", a) // [2 300]
fmt.Println("b:", b) // [300 4]
}
Здесь важно осознавать, какие слайсы используют один и тот же массив, чтобы избежать неожиданных побочных эффектов.
Практические паттерны работы со Слайс API
Реализация стека на слайсах
Слайс отлично подходит как база для стека (LIFO):
package main
import "fmt"
// push - добавляет элемент в стек
func push(stack []int, v int) []int {
stack = append(stack, v)
return stack
}
// pop - снимает элемент с вершины стека
func pop(stack []int) ([]int, int) {
if len(stack) == 0 {
return stack, 0 // в реальном коде лучше возвращать ошибку
}
// Берем последний элемент
v := stack[len(stack)-1]
// Убираем его из среза
stack = stack[:len(stack)-1]
return stack, v
}
func main() {
var stack []int
stack = push(stack, 10)
stack = push(stack, 20)
stack = push(stack, 30)
fmt.Println("stack:", stack) // [10 20 30]
stack, v := pop(stack)
fmt.Println("pop:", v, "stack:", stack) // 30 [10 20]
}
Здесь вы видите типичный шаблон Слайс API: функции, которые меняют размер слайса, возвращают его наружу.
Реализация очереди (FIFO)
Очередь можно реализовать как слайс с «головой» в начале, но при этом стоит аккуратно следить за тем, как растет слайс и как часто вы «обрезаете» его слева.
Заключение
Слайс API (api-slice) в Go объединяет в себе несколько ключевых механизмов:
- внутреннюю структуру с указателем на массив, длиной и емкостью;
- операции создания (
make, литералы, срезы); - расширение (
append); - копирование (
copy); - управление длиной и емкостью (
len,cap, срезы вида s[:i]); - гибкие паттерны удаления, вставки, объединения и передачи по функциям.
Как видите, большинство типичных задач с динамическими коллекциями в Go решается при помощи нескольких базовых инструментов. Важно не просто знать синтаксис, но и понимать, когда создается новый массив, какие слайсы делят один и тот же массив, и как избежать скрытых утечек памяти из-за длинноживущих ссылок.
Если вы будете строить свои функции так, чтобы явно возвращать новые слайсы при изменении длины, а также заранее продумывать емкость при частых append, ваш код будет и понятным, и эффективным.
Частозадаваемые технические вопросы по теме статьи и ответы на них
Как обрезать емкость слайса, чтобы он больше не «видел» хвост массива?
Иногда нужно, чтобы слайс не только имел меньшую длину, но и не мог расширяться в хвост исходного массива. Для этого создают новый слайс через append к пустому:
s := s[:n] // сначала обрезаем длину
s = append([]T(nil), s...) // создаем новый массив ровно нужной длины
Теперь cap(s) == len(s), и будущие append потребуют нового массива.
Как сделать «глубокую» копию слайса структур, чтобы изменения не затрагивали исходник?
Если элементы — структуры без указателей, достаточно copy:
copyDst := make([]MyStruct, len(src))
copy(copyDst, src)
Если внутри структур есть указатели или слайсы, нужно поэлементно копировать вложенные данные вручную, создавая новые объекты.
Почему нельзя сравнивать слайсы между собой через оператор ==?
В Go оператор == для слайсов запрещен, кроме сравнения с nil. Это связано с тем, что слайс — это дескриптор массива, а не сами данные. Для сравнения содержимого используйте:
- пакет
slices(Go 1.21+):slices.Equal(a, b); - или ручной цикл по элементам.
Как безопасно преобразовать []T в []interface{}?
Нельзя просто привести []T к []interface{} напрямую. Нужно создать новый слайс и поэлементно заполнить:
vals := make([]interface{}, len(src))
for i, v := range src {
vals[i] = v
}
Это связано с тем, что []T и []interface{} имеют разное внутреннее представление.
Как уменьшить использование памяти слайсом после удаления большого количества элементов?
После массовых удалений длина уменьшается, но емкость остается прежней, и подлежащий массив продолжает занимать память. Чтобы освободить память, создайте новый слайс и скопируйте в него актуальные элементы:
trimmed := make([]T, len(s))
copy(trimmed, s)
s = trimmed
Теперь старый большой массив станет кандидатом на сборку мусора, если на него больше никто не ссылается.