Слайсы в Go
В Go, как и во многих языках программирования, есть массивы.
arr := [5]int{10, 11, 12, 13, 14}goПроблема в том, что они не очень гибкие. Длина массива – часть его типа, поэтому массив нельзя расширить. Например, мы не можем добавить новое число в конец массива.
К счастью, Go предоставляет удобный инструмент для работы с массивами – слайсы.
Слайс – это дескриптор части нижележащего массива.
Чтобы взять слайс от массива, нужно воспользоваться синтаксисом:
arr[lowIndex : highIndex]К примеру, возьмем слайс с 1-го по 3-й элемент включительно.
arr := [5]int{10, 11, 12, 13, 14}
sl := arr[1:4]
fmt.Println(sl) // [11 12 13]goЗаметим, что элемент с индексом highIndex не включается в слайс.
Слайс под капотом
Слайс – структура размером в три машинных слова:
- Указатель на нижележащий массив.
- Количество элементов в слайсе (длина).
- Количество элементов в промежутке от начала слайса до конца нижележащего массива (капасити).
// src/runtime/slice.go
type slice struct {
array unsafe.Pointer
len int
cap int
}goПосмотрим как выглядит слайс и массив из примера выше:
Наверху изображен массив из пяти элементов. Снизу – слайс из трех полей: addr, len и cap.
Сейчас слайсу доступны 3 (len) элемента, они выделены синим. Слайс может расшириться до 4-х элементов (cap), 4-й элемент выделен серым (поскольку он недоступен). Расширение слайса будем подробнее рассматривать далее в статье.
Длину и капасити слайса можно получить функциями len() и cap().
fmt.Println(len(sl)) // 3
fmt.Println(cap(sl)) // 4goАдрес addr можно получить функцией SliceData() из пакета unsafe:
ptr := unsafe.SliceData(sl)
fmt.Printf("%p\n", ptr) // 0x1400011e030
fmt.Println(ptr == &arr[1]) // truegoАдрес addr слайса совпадает с адресом элемента с индексом 1 нижележащего массива.
Создание слайса
Выше мы рассмотрели способ взятия слайса от существующего массива. Но можно создать слайс и без массива. Ниже рассмотрим способы.
После каждого способа мы будем смотреть, как выглядит слайс под капотом. Для этого создадим хелпер:
package main
import (
"fmt"
"unsafe"
)
type sliceHeader struct {
array unsafe.Pointer
len int
cap int
}
func inspectSlice(sl []int) {
sh := (*sliceHeader)(unsafe.Pointer(&sl))
fmt.Println("addr:", sh.array)
fmt.Println("len:", sh.len)
fmt.Println("cap:", sh.cap)
fmt.Println()
}gonil слайс
var nilSlice []int
inspectSlice(nilSlice)goaddr: <nil>
len: 0
cap: 0Отличительная особенность nil слайса – у него нет нижележащего массива. Но функции len() и cap() на таком слайсе работают без ошибок – обе вернут 0. Это удобно, потому что клиентскому коду не нужно проверять слайс на nil перед тем как узнать его длину или капасити.
Этот способ создания слайса стоит использовать, когда заранее неизвестно, сколько в слайсе будет элементов и будут ли они там вообще.
Пустой слайс
emptySlice := []int{}
inspectSlice(emptySlice)goaddr: 0x1025d74a0
len: 0
cap: 0Отличие от nil слайса – у пустого слайса есть адрес нижележащего массива. Но на что он указывает, если в массиве нет элементов?
Для аллокаций с нулевым размером Go использует специальную переменную zerobase:
// runtime/malloc.go
// base address for all 0-byte allocations
var zerobase uintptrgoПоскольку переменная приватная для пакета runtime, получить её адрес напрямую нельзя. Однако есть обходные пути. Например, мы можем взять адрес пустой структуры:
var emptyStruct struct{}
emptySlice := []int{}
fmt.Printf("%p\n", &emptyStruct) // 0x1025d74a0
fmt.Printf("%p\n", unsafe.SliceData(emptySlice)) // 0x1025d74a0goПустая структура и пустой массив – это аллокации с нулевым размером. У них одинаковый адрес, который указывает на zerobase переменную.
По-дефолту, если вы не знаете, сколько элементов будет в слайсе, стоит использовать nil слайс. Но есть корнер-кейсы.
Например, некоторые библиотеки работают по-разному с nil слайсом и пустым слайсом. Один из известных примеров – пакет encoding/json:
jsonNil, _ := json.Marshal([]int(nil))
jsonEmpty, _ := json.Marshal([]int{})
fmt.Println(string(jsonNil)) // Выведет: null
fmt.Println(string(jsonEmpty)) // Выведет: []goНапример, если вы хотите в JSON API вернуть пустой массив ([]) вместо null, то вам нужно использовать []int{} при инициализации.
Функция make
Слайс можно создать с помощью встроенной функции make(). Она создаст нижележащий массив и вернет слайс, указывающий на первый элемент этого массива. Функция принимает три аргумента:
- Тип слайса. Например,
[]int. - Длина слайса (
len). - Капасити слайса (
cap). Этот аргумент опциональный.
Если передано два аргумента, то cap будет равно len:
makeSlice := make([]int, 3)
inspectSlice(makeSlice)
fmt.Printf("Адрес первого элемента: %p\n", &makeSlice[0])goaddr: 0x14000018080
len: 3
cap: 3
Адрес первого элемента: 0x14000018080Схематично нижележащий массив и слайс выглядят так:
Нижележащий массив заполнен нулями, поскольку это дефолтное значение для типа int.
Теперь попробуем создать слайс с разными значениями len и cap.
makeSlice2 := make([]int, 2, 3)
inspectSlice(makeSlice2)
fmt.Printf("Адрес первого элемента: %p\n", &makeSlice2[0])goaddr: 0x1400001c270
len: 2
cap: 3
Адрес первого элемента: 0x1400001c270Слайс отличается от предыдущего тем, что доступны только два элемента. Третий элемент станет доступен, если расширить слайс.
Используйте этот вид инициализации, если вы заранее понимаете, сколько элементов будет в слайсе.
Слайс литерал
При создании слайса иногда бывает потребность положить в него заранее известные значения. В этом случае можно воспользоваться слайс литералом:
sliceLiteral := []int{100, 500, 200}
inspectSlice(sliceLiteral)
fmt.Printf("Адрес первого элемента: %p\n", &sliceLiteral[0])goaddr: 0x1400001a120
len: 3
cap: 3
Адрес первого элемента: 0x1400001a120У слайс литералов начальные len и cap равны количеству элементов в слайсе.
Какой способ создания выбрать?
Расширение слайса
Создадим слайс с len 1 и cap 3:
sl := make([]int, 1, 3)
sl[0] = 100
fmt.Println(sl, len(sl), cap(sl))goВыведет:
[100] 1 3Если мы попробуем добавить второй элемент, то получим панику:
sl[1] = 200gopanic: runtime error: index out of range [1] with length 1Почему это происходит? Так выглядит слайс и нижележащий массив в данный момент:
Видим, что в нижележащем массиве есть место еще для 2 элементов, но оно пока недоступно.
Чтобы оно стало доступно, нужно расширить слайс. Один из способов – взять слайс от существующего слайса:
// Расширим слайс на один элемент
sl = sl[:len(sl)+1]goПосле расширения новый слайс выглядит так:
Второй элемент принял дефолтное значение int – 0. Мы можем его поменять:
sl[1] = 200
fmt.Println(sl, len(sl), cap(sl))goВыведет:
[100 200] 2 3Слайс будет выглядеть так:
Функция append
Есть и другой способ добавить элемент в конец слайса, не прибегая к ручному расширению слайса. Это встроенная в язык функция append():
sl = append(sl, 300)goСнова посмотрим на слайс:
fmt.Println(sl, len(sl), cap(sl))go[100 200 300] 3 3Теперь длина слайса равна его капасити. Что будет если мы снова сделаем append? Посмотрим через inspectSlice:
inspectSlice(sl, "До append")
sl = append(sl, 400)
inspectSlice(sl, "После append")goВыведет:
До append
addr: 0x140000aa018
len: 3
cap: 3
После append
addr: 0x140000ba060
len: 4
cap: 6Примечательны здесь две вещи:
- Поменялся адрес нижележащего массива.
- Капасити возросло вдвое.
Если мы делаем append в слайс, у которого len == cap, то:
- Будет создан новый нижележащий массив с увеличенным капасити.
- В этот массив будут скопированы все элементы предыдущего массива.
- Будет добавлен элемент, который мы и собирались добавить.
Слайс теперь ссылается на новый массив. Поскольку на старый массив больше ничто не ссылается, то рано или поздно он будет очищен Garbage Collector’ом.
Слайс от слайса
В Go можно взять слайс от существующего слайса, используя тот же синтаксис, что и для массивов:
slice[low:high]goПолная форма [low:high]
originalSlice := []int{100, 200, 300, 400, 500}
newSlice := originalSlice[1:4]
fmt.Println(newSlice) // [200 300 400]Новый слайс содержит элементы с индекса low до high - 1 включительно.
Краткие формы
Go позволяет опускать low и/или high:
[:high] – опускаем low, по умолчанию равен 0:
originalSlice := []int{100, 200, 300, 400, 500}
// Эквивалентно originalSlice[0:3]
newSlice := originalSlice[:3]
fmt.Println(newSlice) // [100 200 300][low:] – опускаем high, по умолчанию равен len(slice):
originalSlice := []int{100, 200, 300, 400, 500}
// Эквивалентно originalSlice[2:5]
newSlice := originalSlice[2:]
fmt.Println(newSlice) // [300 400 500][:] – опускаем оба, эквивалентно [0:len(slice)]:
originalSlice := []int{100, 200, 300, 400, 500}
// Эквивалентно originalSlice[0:5]
newSlice := originalSlice[:]
fmt.Println(newSlice) // [100 200 300 400 500]Основное применение [:] – конвертация массива в слайс. Это нужно, когда функция принимает слайс, а у вас есть массив:
package main
import "fmt"
func sum(nums []int) int {
total := 0
for _, n := range nums {
total += n
}
return total
}
func main() {
arr := [5]int{1, 2, 3, 4, 5}
// Ошибка компиляции: cannot use arr (type [5]int) as type []int
// sum(arr)
// Правильно: конвертируем массив в слайс
fmt.Println(sum(arr[:])) // 15
}goТаблица значений по умолчанию
| Синтаксис | low | high | Описание |
|---|---|---|---|
slice[low:high] | low | high | Полная форма |
slice[:high] | 0 | high | От начала до high |
slice[low:] | low | len(slice) | От low до конца |
slice[:] | 0 | len(slice) | Весь слайс |
Несколько слайсов, указывающих на один массив
Рассмотрим следующий слайс:
originalSlice := make([]int, 0, 6)
originalSlice = append(originalSlice, 100, 200, 300, 400)goМы зарезервировали место для шести элементов, но добавили только четыре. Поэтому длина оригинального слайса равна 4, а капасити – 6.
Предположим, что мы хотим взять слайс из второго и третьего элемента нашего слайса (200 и 300).
newSlice := originalSlice[1:3]
inspectSlice(newSlice)
fmt.Println("Адрес второго элемента оригинального слайса:", &originalSlice[1])goВыведет:
addr: 0x14000136038
len: 2
cap: 5
Адрес второго элемента оригинального слайса: 0x14000136038Адрес первого элемента нового слайса совпадает с адресом второго элемента оригинального слайса.
Что произойдет, если мы сделаем append к новому слайсу?
fmt.Println("Оригинальный слайс до append:", originalSlice)
fmt.Println()
newSlice = append(newSlice, 700)
fmt.Println("Оригинальный слайс после append:", originalSlice)
fmt.Println("Новый слайс:", newSlice)Выведет:
Оригинальный слайс до append: [100 200 300 400]
Оригинальный слайс после append: [100 200 300 700]
Новый слайс: [200 300 700]Мы хотели лишь добавить число 700 к новому слайсу, но возник сайд-эффект: в оригинальном слайсе число 400 тоже заменилось на 700. Это произошло, потому что оба слайса указывают на один и тот же нижележащий массив, а значит изменения в одном слайсе отражаются на другом.
Ограничение капасити при взятии слайса
Что если мы хотим защититься от сайд-эффекта append, описанного в предыдущем разделе?
На этот случай Go предоставляет нам возможность указать третий аргумент при взятии слайса. Этот аргумент устанавливает капасити нового слайса:
newSlice := originalSlice[1:3:3]goДалее будет приведен полный код. Запустим его и посмотрим, что происходит:
// Создаем оригинальный слайс
originalSlice := make([]int, 0, 6)
originalSlice = append(originalSlice, 100, 200, 300, 400)
fmt.Println("Оригинальный слайс до создания нового слайса:", originalSlice)
fmt.Println()
fmt.Println("Оригинальный слайс под капотом")
inspectSlice(originalSlice)
fmt.Println("Адрес второго элемента оригинального слайса:", &originalSlice[1])
fmt.Println()
// Создаем новый слайс
newSlice := originalSlice[1:3:3]
fmt.Println("Новый слайс:", newSlice)
fmt.Println()
fmt.Println("Новый слайс под капотом")
inspectSlice(newSlice)
// Аппендим в новый слайс
newSlice = append(newSlice, 700)
fmt.Println("Оригинальный слайс после append в новый слайс:", originalSlice)
fmt.Println("Новый слайс после append:", newSlice)
fmt.Println()
fmt.Println("Оригинальный слайс под капотом после append")
inspectSlice(originalSlice)
fmt.Println("Новый слайс под капотом после append")
inspectSlice(newSlice)goВыведет:
Оригинальный слайс до создания нового слайса: [100 200 300 400]
Оригинальный слайс под капотом
addr: 0x1400011e030
len: 4
cap: 6
Адрес второго элемента оригинального слайса: 0x1400011e038
Новый слайс: [200 300]
Новый слайс под капотом
addr: 0x1400011e038
len: 2
cap: 2
Оригинальный слайс после append в новый слайс: [100 200 300 400]
Новый слайс после append: [200 300 700]
Оригинальный слайс под капотом после append
addr: 0x1400011e030
len: 4
cap: 6
Новый слайс под капотом после append
addr: 0x1400012a000
len: 3
cap: 4Разберем, что здесь происходит:
- Мы берем слайс (
newSlice) от оригинального слайса (originalSlice), но на этот раз мы также ограничиваем капасити нового слайса. - Новый слайс указывает на второй элемент оригинального слайса, но при этом имеет
len == cap == 2. - До
appendоба слайса указывают на один и тот же нижележащий массив. - Поскольку у
newSlicelen == cap, то послеappendсоздается второй нижележащий массив с capacity 4 (удвоенным капасити). В этот массив копируются значения 200 и 300, а также добавляется значение 700. - Массив, на который указывает
originalSlice, не затронут.
Копирование слайса
Вернемся к моменту, когда мы взяли слайс с ограничением капасити:
newSlice := originalSlice[1:3:3]goОграничив капасити, мы избавились от нежелательных эффектов append. Однако мы не избавились от неожиданной модификации нижележащего массива. Приведем пример:
newSlice := originalSlice[1:3:3]
newSlice[0] = 800
fmt.Println("Оригинальный слайс:", originalSlice)
fmt.Println("Новый слайс:", newSlice)goВыведет:
Оригинальный слайс: [100 800 300 400]
Новый слайс: [800 300]Второй элемент оригинального слайса тоже поменялся. Надеемся, что исходя из предыдущего материала очевидно, почему это произошло.
Оба слайса все еще указывают на один и тот же нижележащий массив. Поэтому если мы изменим общий элемент, то изменение отразится на обоих слайсах.
Защититься от этого можно путем копирования слайса:
newSlice := make([]int, 2)
copy(newSlice, originalSlice[1:3])
newSlice[0] = 800
fmt.Println("Оригинальный слайс:", originalSlice)
fmt.Println("Новый слайс:", newSlice)goВыведет:
Оригинальный слайс: [100 200 300 400]
Новый слайс: [800 300]После создания через make слайс newSlice получит новый нижележащий массив, в который и будут скопированы элементы из оригинального слайса.
Перед копированием нужно создать слайс с нужной длиной. Иначе скопируется меньше элементов, чем ожидается:
var newSlice []int
copy(newSlice, originalSlice[1:3])
fmt.Println("Новый слайс:", newSlice)goВыведет:
Новый слайс: []Происходит из-за того, что copy копирует min(len(dst), len(src)) элементов. В нашем случае dst (destination) слайс имеет длину 0, поскольку это nil слайс. Поэтому итоговая длина тоже 0.
Корнер кейсы
Слайс с нулевой длиной
Через make можно создать слайс с нулевой длиной и ненулевым капасити.
zeroLenSlice := make([]int, 0, 3)goПопытка модификации элемента такого слайса приведет к панике. Для заполнения в этом случае используется append.
zeroLenSlice = append(zeroLenSlice, 1)
zeroLenSlice = append(zeroLenSlice, 2)
zeroLenSlice = append(zeroLenSlice, 3)
// Три строки выше можно также объединить в одну:
// zeroLenSlice = append(zeroLenSlice, 1, 2, 3)goСлайс с нулевой длиной может быть полезен, например, когда мы не знаем точно, сколько элементов окажутся в итоговом массиве, но знаем верхнюю границу.
В примере ниже функция фильтрует четные числа. Мы не знаем, сколько в слайсе nums четных чисел. Но точно знаем, что их не может быть больше len(nums):
package main
func getEvens(nums []int) []int {
evens := make([]int, 0, len(nums))
for _, n := range nums {
if n%2 == 0 {
evens = append(evens, n)
}
}
return evens
}goЗадавая верхнюю границу, мы можем избежать создания новых нижележащих массивов во время append.
Подслайс вне len оригинального слайса
Подслайс можно брать не только от элементов, которые доступны оригинальному слайсу, но и от тех, которые находятся вне len.
slice := make([]int, 3, 6)
slice[0] = 100
slice[1] = 800
slice[2] = 300
subslice := slice[3:]goВ подслайс можно добавлять элементы с помощью append (аналогично как мы рассмотрели в предыдущей секции).
Такой подход может понадобиться, если мы захотим разделить нижележащий массив на секции и работать с ними по-разному. Представим, что мы хотим собрать пакет байтов и отправить его по сети. В этом случае мы можем захотеть отдельно работать с заголовком и телом пакета.
package main
import "encoding/binary"
func buildPacket(payloadData []byte) []byte {
// резервируем 4 байта для хедера
buf := make([]byte, 4, 1024)
// Пишем хедер в буфер
binary.BigEndian.PutUint32(buf[0:4], 0xDEADBEEF)
// Берем подслайс для тела пакета
body := buf[4:4]
body = append(body, payloadData...)
// Возвращаем пакет целиком
return buf[:4+len(body)]
}goПодслайс с границами равными cap оригинального слайса
Такой код в Go валиден:
slice := make([]int, 3, 6)
slice[0] = 100
slice[1] = 800
slice[2] = 300
subslice := slice[6:6]goЗаметим, что индекс 6 находится вне границ нижележащего массива. Тем не менее, мы можем обозначить границы подслайса этим индексом.
Визуально это будет выглядеть так:
Примечательно, что addr подслайса указывает на первый элемент нижележащего массива, а не на позицию с индексом 6. Это легко проверить:
subslice := slice[6:6]
fmt.Println(reflect.ValueOf(subslice).UnsafePointer() == unsafe.Pointer(&slice[0]))
fmt.Println(subslice == nil)goВыведет:
true
falseЭто не случайность, а осознанное решение компилятора. Обычно адрес подслайса вычисляется как адрес массива плюс смещение на low элементов. Но если капасити результата равна нулю, компилятор отбрасывает смещение и оставляет поинтер на начале массива.
Причина в устройстве Garbage Collector. Он определяет, какому объекту принадлежит поинтер, по адресу. Поинтер на позицию сразу за концом массива неотличим от поинтера на соседний объект в куче. Если бы компилятор создавал такие поинтеры, GC считал бы соседний объект живым, пока жив наш пустой подслайс, и не освобождал бы его память.
Сделать поинтер nil тоже нельзя. Тогда получился бы nil-слайс. А по спецификации результат слайсинга не-nil слайса разделяет нижележащий массив с операндом – nil-слайс же не разделяет ничего. Поэтому slice[6:6] обязан быть не-nil, что и подтверждает второе false в выводе выше.
Правило срабатывает для любого подслайса с нулевой капасити, а не только на границе cap. Например, slice[2:2:2] тоже получит поинтер на начало массива. То же самое происходит при взятии подстроки: s[len(s):] указывает на начало s.
Есть ли юзкейс для подобного подслайса? Честно говоря, придумать его трудно. Но само правило стоит знать: оно объясняет, почему addr пустого подслайса не всегда совпадает с тем, что подсказывает арифметика индексов.
Утечки памяти при использовании слайсов
Утечка капасити
Представим, что у нас есть функция, которая достает заголовки (все до пустой строки) из некого слайса байт. Это может быть, например, HTTP ответ или файл.
package leak
import "bytes"
func findHeader(data []byte) []byte {
index := bytes.Index(data, []byte("\r\n\r\n"))
if index == -1 {
return nil
}
return data[:index]
}Мы ищем индекс пустой строки, которая отделяет заголовки от тела, и если находим его, то возвращаем слайс от слайса data. Под капотом у data находится нижележащий массив. Он может быть объемным и занимать много мегабайт.
Хотя нам нужны только заголовки из data, наш новый слайс ссылается на весь нижележащий массив целиком. А пока на массив есть ссылки, Garbage Collector не очистит занимаемую им память.
Решение – не брать слайс от слайса, а создать новый слайс с отдельным нижележащим массивом и скопировать туда нужные элементы.
package safe
import "bytes"
func findHeader(data []byte) []byte {
index := bytes.Index(data, []byte("\r\n\r\n"))
if index == -1 {
return nil
}
result := make([]byte, index)
copy(result, data[:index])
return result
}Использование поинтеров в слайсах
Похоже на предыдущую утечку, но в другом контексте.
Предположим, что у нас есть структура User:
package leak
type User struct {
ID int64
Name string
}goУ нас также есть функция, которая принимает слайс указателей на структуру User и возвращает слайс из первых двух пользователей.
package leak
func takeFirstTwo(users []*User) []*User {
if len(users) < 2 {
return users
}
return users[:2]
}goМы возвращаем слайс от слайса, а значит код страдает от той же проблемы, что и в предыдущем разделе. Однако здесь есть и более глубокая проблема. У нас слайс указателей, а это значит, что значения User выделены на хипе (куче). Если в нижележащем массиве 1000 пользователей, а пользуемся мы только двумя, Garbage Collector не сможет освободить память в куче, занятую остальными 998 пользователями.
Решения здесь два.
Как и в предыдущем разделе мы можем скопировать нужных юзеров в слайс с отдельным нижележащим массивом:
package safecopy
func takeFirstTwo(users []*User) []*User {
res := make([]*User, 2)
copy(res, users)
return res
}goЕщё один вариант – присвоить nil «лишним» пользователям:
package nilify
func takeFirstTwo(users []*User) []*User {
if len(users) < 2 {
return users
}
clear(users[2:])
return users[:2]
}goВстроенная функция clear (появилась в Go 1.21) присваивает каждому элементу слайса нулевое значение – для слайса указателей это nil. Теперь Garbage Collector сможет освободить память 998 пользователей, поскольку на их структуры больше ничего не ссылается.