Большой разбор Слайсов, Типы и структуры данных Go
- воскресенье, 29 июня 2025 г. в 00:00:08
Привет, меня зовут Рома! Какое-то время назад я захотел изучить всю внутрянку Go, заглянуть в исходники языка и понять, почему все устроено так, как устроено. В этот самый момент я обнаружил, что на просторах интернета практически отсутствуют материалы, которые подробно разбирают типы данных, их вспомогательные функции, детали реализации runtime и так далее. Мной было принято решение сделать это самостоятельно. Изначально я занимался этим для себя, но позже решил, что стоит поделиться моими наблюдениями и выводами с миром.
Представляю вам первую статью из цикла "Типы и структуры данных Go"! Здесь мы познакомимся со Слайсами, разберем внутреннюю реализацию этого типа и его вспомогательных функций. Приятного аппетита!
Слайсы представляют собой последовательности переменной длины, элементы которых имеют одинаковый тип. Тип слайса записывается как []T
, где элементы имеют тип T
; он выглядит как тип массива без размера.
Источник: Alan A. A. Donovan, Brian W. Kernighan - «The Go Programming Language»
SliceType
из go/src/internal/abi/type.go
описывает тип среза в рантайме. Встраивает Type
— общую информацию о типе (размер, выравнивание, флаги и прочее), и добавляет поле Elem
, которое указывает на тип элементов среза. Например, тип []int
будет представлен как SliceType
, где Elem
указывает на тип int
.
SliceType
type SliceType struct {
Type // Встроенная общая информация о типе
Elem *Type // Указатель на тип элементов среза
}
Структура выше не является описанием того, как срезы хранятся в памяти. Для этой цели существует структура slice
из go/src/runtime/slice.go
. Здесь важно понимать, что slice
просто ОПИСАНИЕ для разработчиков, вспомогательный низкоуровневый мост, если хотите. Она не используется в обычном Go-коде и рантайме при компиляции.
slice
type slice struct {
array unsafe.Pointer
len int
cap int
}
Выражение, создающее слайс
s
, отличается от создания массиваa
. Литерал слайса выглядит как литерал массива — последовательность значений, разделённых запятыми и заключённых в фигурные скобки — но без указания размера. Это неявно создаёт массив нужного размера и возвращает слайс, указывающий на него. Как и в случае с массивами, литералы слайсов могут:
задавать значения по порядку,
явно указывать индексы,
или сочетать оба подхода.
Источник: Alan A. A. Donovan, Brian W. Kernighan - «The Go Programming Language»
Другой способ создания среза представляет функция
make()
- она позволяет создать срез из нескольких элементов, которые будут иметь значения по умолчанию. Она имеет следующую форму:
Код
имя_среза := make([] тип_элементов_среза, длина_среза, емкость_среза)
Пример использования:
Код
var users []string = make([]string, 3) // длина среза - 3 users[0] = "Tom" users[1] = "Alice" users[2] = "Bob"
Источник: < metanit.com >
Самой функции не существует, так как компилятор отвечает за генерацию вызова. Однако мы можем найти отдельные компоненты это процесса в go/src/runtime/slice.go
.
makeslice
выделяет память под срез длиной len
и вместимостью cap
. et
— это тип элементов среза (используется для определения размера). makeslice64
— версия makeslice
с параметрами int64
. Приводит int64
к int
и проверяет переполнение.
makeslice
и makeslice64
func makeslice(et *_type, len, cap int) unsafe.Pointer {
mem, overflow := math.MulUintptr(et.Size_, uintptr(cap))
if overflow || mem > maxAlloc || len < 0 || len > cap {
/*
ПРИМЕЧАНИЕ: Генерируем ошибку 'len out of range', а не
'cap out of range', если кто-то делает make([]T, очень_большое_число).
Формально и 'cap out of range' тоже верно, но поскольку вместимость
задаётся неявно, сообщение про длину более понятно.
См. golang.org/issue/4085.
*/
mem, overflow := math.MulUintptr(et.Size_, uintptr(len))
if overflow || mem > maxAlloc || len < 0 {
panicmakeslicelen() // паника: длина выходит за пределы
}
panicmakeslicecap() // паника: вместимость выходит за пределы
}
return mallocgc(mem, et, true) // выделяем память через сборщик мусора
}
func makeslice64(et *_type, len64, cap64 int64) unsafe.Pointer {
len := int(len64)
if int64(len) != len64 {
panicmakeslicelen()
}
cap := int(cap64)
if int64(cap) != cap64 {
panicmakeslicecap()
}
return makeslice(et, len, cap)
}
Не вызывает функцию в рантайме, компилятор сам генерирует код:
рассчитывает ptr := &a[i]
выставляет len := j - i
, cap := cap(a) - i
вставляет проверки границ (i <= j
, j <= cap(a)
и т. д.)
вызывает панику при ошибке
Значение по умолчанию (zero value) для типа слайса — это
nil
. nil-слайс не имеет базового массива. Такой слайс имеет длину и ёмкость равные нулю. Однако существуют не-nil слайсы с нулевой длиной и ёмкостью, например:
Код
[]int{} make([]int, 3)[3:]
Как и в случае с любыми типами, которые могут иметь значение
nil
, значениеnil
конкретного типа слайса может быть записано с помощью выражения преобразования, например:
Код
[]int(nil)
Код
var s []int // len(s) == 0, s == nil s = nil // len(s) == 0, s == nil s = []int(nil) // len(s) == 0, s == nil s = []int{} // len(s) == 0, s != nil
Поэтому, если нужно проверить, пуст ли слайс, используйте:
Код
len(s) == 0
а не:
Код
s == nil
За исключением сравнения с
nil
, nil-слайс ведёт себя как обычный слайс длины 0. Если не указано явно иное, функции Go должны одинаково обрабатывать все слайсы длины 0, будь тоnil
или нет.Источник: Alan A. A. Donovan, Brian W. Kernighan - «The Go Programming Language»
Компилятор просто объявляет переменную, у которой s.ptr = nil
, s.len = 0
, s.cap = 0
. Никаких вызовов рантайма нет, потому что ничего не аллоцируется.
В Go доступ к элементу среза по индексу (s[i]
) не вызывает отдельной функции в рантайме. Это поведение встраивается непосредственно компилятором (gc
) как часть генерации машинного кода.
Однако, границы (bounds
) проверяются и при необходимости вызываются рантайм-функции для генерации panic
. Рассматривать каждую функцию вызова паники не имеет смысла, они очень простые и их много для разных ситуаций.
Встроенная функция
append
добавляет элементы в слайсы:
Код
var runes []rune for _, r := range "Hello, BF" { runes = append(runes, r) } fmt.Printf("%q\\n", runes) // "['H' 'e' 'l' 'l' 'o' ' ' 'B' 'F']"
Цикл использует
append
, чтобы построить слайс из девяти рун, закодированных в строковом литерале, хотя конкретно эту задачу удобнее решить через встроенное преобразование:[]rune("Hello, BF")
.Нельзя также предполагать, что изменения в старом слайсе будут отражаться в новом, или наоборот. Поэтому стандартная практика — всегда присваивать результат
append
обратно в переменную:
Код
runes = append(runes, r)
Это правило касается не только
append
, но и любой функции, изменяющей длину или ёмкость слайса, либо меняющей базовый массив. Указатель, длина и ёмкость слайса не обновляются автоматически — только через присваивание.Наша
appendInt
добавляет один элемент. Встроеннаяappend
позволяет добавлять несколько:
Код
var x []int x = append(x, 1) x = append(x, 2, 3) x = append(x, 4, 5, 6) x = append(x, x...) // добавить сам себя fmt.Println(x) // [1 2 3 4 5 6 1 2 3 4 5 6]
Небольшое изменение делает
appendInt
вариативной функцией:
Код
func appendInt(x []int, y ...int) []int { var z []int zlen := len(x) + len(y) // ... увеличить z до как минимум zlen ... copy(z[len(x):], y) return z }
Источник: Alan A. A. Donovan, Brian W. Kernighan - «The Go Programming Language»
append()
в чистом виде не существует, она генерируется компилятором Go, поэтому искать ее реализацию бессмысленно. Однако в go/src/runtime/slice.go
мы можем увидеть функции, которые append()
задействует.
Функция growslice
, ответственна за перераспределение памяти, когда при добавлении append()
длина превышает емкость. Аргументы: oldPtr
— указатель на исходный массив среза, newLen
— новая длина (= oldLen
+ num
), oldCap
— исходная вместимость среза, num
— количество добавляемых элементов, et
— тип элементов. Возвращаемые значения: newPtr
— указатель на новый буфер, newLen
— то же значение, что и в аргументе, newCap
— вместимость нового буфера. Требуется, чтобы uint(newLen) > uint(oldCap)
. Предполагается, что исходная длина среза равна newLen - num
. Выделяется новый буфер минимум под newLen
элементов. Существующие элементы [0:oldLen]
копируются в новый буфер. Добавленные элементы [oldLen, newLen]
не инициализируются growslice
(хотя для типов с указателями они зануляются). Инициализация добавленных элементов должна выполняться вызывающей стороной. Элементы после newLen
[newLen, newCap]
зануляются. Особенность growslice
— нестандартное соглашение о вызове, что упрощает генерируемый код, который её вызывает. В частности, она принимает и возвращает новую длину, чтобы старая длина не была "живой" (не нужно сохранять/восстанавливать), а новая длина возвращается без дополнительных затрат.
growslice
func growslice(oldPtr unsafe.Pointer, newLen, oldCap, num int, et *_type) slice {
oldLen := newLen - num
if raceenabled {
callerpc := sys.GetCallerPC()
racereadrangepc(oldPtr, uintptr(oldLen*int(et.Size_)), callerpc, abi.FuncPCABIInternal(growslice))
}
if msanenabled {
msanread(oldPtr, uintptr(oldLen*int(et.Size_)))
}
if asanenabled {
asanread(oldPtr, uintptr(oldLen*int(et.Size_)))
}
if newLen < 0 {
panic(errorString("growslice: len out of range"))
}
if et.Size_ == 0 {
/*
append не должен создавать срез с nil-указателем, но ненулевой длиной.
Предполагается, что append не требует сохранения oldPtr в этом случае.
*/
return slice{unsafe.Pointer(&zerobase), newLen, newLen}
}
newcap := nextslicecap(newLen, oldCap)
var overflow bool
var lenmem, newlenmem, capmem uintptr
/*
Специализация для популярных размеров et.Size_:
- Для 1 не нужна деление/умножение.
- Для размера указателя (goarch.PtrSize) компилятор оптимизирует деление в сдвиг.
- Для степеней двойки используется сдвиг по переменной.
noscan — флаг отсутствия указателей в типе.
*/
noscan := !et.Pointers()
switch {
case et.Size_ == 1:
lenmem = uintptr(oldLen)
newlenmem = uintptr(newLen)
capmem = roundupsize(uintptr(newcap), noscan)
overflow = uintptr(newcap) > maxAlloc
newcap = int(capmem)
case et.Size_ == goarch.PtrSize:
lenmem = uintptr(oldLen) * goarch.PtrSize
newlenmem = uintptr(newLen) * goarch.PtrSize
capmem = roundupsize(uintptr(newcap)*goarch.PtrSize, noscan)
overflow = uintptr(newcap) > maxAlloc/goarch.PtrSize
newcap = int(capmem / goarch.PtrSize)
case isPowerOfTwo(et.Size_):
var shift uintptr
if goarch.PtrSize == 8 {
// Маска сдвига для улучшения генерации кода.
shift = uintptr(sys.TrailingZeros64(uint64(et.Size_))) & 63
} else {
shift = uintptr(sys.TrailingZeros32(uint32(et.Size_))) & 31
}
lenmem = uintptr(oldLen) << shift
newlenmem = uintptr(newLen) << shift
capmem = roundupsize(uintptr(newcap)<<shift, noscan)
overflow = uintptr(newcap) > (maxAlloc >> shift)
newcap = int(capmem >> shift)
capmem = uintptr(newcap) << shift
default:
lenmem = uintptr(oldLen) * et.Size_
newlenmem = uintptr(newLen) * et.Size_
capmem, overflow = math.MulUintptr(et.Size_, uintptr(newcap))
capmem = roundupsize(capmem, noscan)
newcap = int(capmem / et.Size_)
capmem = uintptr(newcap) * et.Size_
}
/*
Проверка overflow и capmem > maxAlloc необходима,
чтобы избежать переполнения, которое может вызвать segfault
на 32-битных архитектурах. Например, при таком коде:
type T [1<<27 + 1]int64
var d T
var s []T
func main() {
s = append(s, d, d, d, d)
print(len(s), "\\n")
}
*/
if overflow || capmem > maxAlloc {
panic(errorString("growslice: len out of range"))
}
var p unsafe.Pointer
if !et.Pointers() {
/*
Если в типе нет указателей, выделяем память без GC.
Обнуляем только область после newLen, поскольку
append будет перезаписывать новые элементы.
*/
p = mallocgc(capmem, nil, false)
memclrNoHeapPointers(add(p, newlenmem), capmem-newlenmem)
} else {
/*
Для типов с указателями нужно выделять память с учётом GC,
чтобы GC не сканировал неинициализированную память.
Если включён writeBarrier и есть старые элементы,
то вызывается bulkBarrierPreWriteSrcOnly для корректного
отслеживания указателей.
*/
p = mallocgc(capmem, et, true)
if lenmem > 0 && writeBarrier.enabled {
bulkBarrierPreWriteSrcOnly(uintptr(p), uintptr(oldPtr), lenmem-et.Size_+et.PtrBytes, et)
}
}
memmove(p, oldPtr, lenmem)
return slice{p, newLen, newcap}
}
nextslicecap
вычисляет подходящую новую вместимость (capacity) для среза. Аргументы: newLen
— желаемая длина нового среза, oldCap
— текущая вместимость среза. Возвращаемое значение: рассчитанная вместимость (newcap
), достаточная для размещения newLen
элементов. Проверка на переполнение реализована через сравнение uint(newcap) >= uint(newLen)
, так как при переполнении newcap
становится отрицательным, и это выражение всё ещё будет верным. Если расчёт newcap
привёл к нулю или отрицательному значению (переполнение) — возвращается newLen
как безопасное значение.
Алгоритм:
Если newLen
превышает удвоенную текущую вместимость — сразу возвращается newLen
.
Иначе, если текущая вместимость меньше порога (256
), newcap
удваивается.
Если больше — применяется увеличение примерно на 1.25x с плавным переходом между стратегиями.
nextslicecap
func nextslicecap(newLen, oldCap int) int {
newcap := oldCap
doublecap := newcap + newcap
if newLen > doublecap {
return newLen
}
const threshold = 256
if oldCap < threshold {
return doublecap
}
for {
/*
Переход от роста x2 для маленьких срезов
к росту x1.25 для больших.
Формула даёт относительно плавный переход.
newcap += (newcap + 3*threshold) >> 2
Проверка на достижение нужной длины и на переполнение.
Если newcap переполнится, uint(newcap) > uint(newLen) всё равно будет true.
*/
if uint(newcap) >= uint(newLen) {
break
}
}
// В случае переполнения возвращаем минимально допустимое значение
if newcap <= 0 {
return newLen
}
return newcap
}
/*
reflect_growslice должен быть внутренней функцией,
но он используется внешними пакетами через linkname.
Замеченные нарушители:
- github.com/cloudwego/dynamicgo
Не удаляйте и не изменяйте сигнатуру функции.
См. <https://go.dev/issue/67401>.
*/
// go:linkname reflect_growslice reflect.growslice
func reflect_growslice(et *_type, old slice, num int) slice {
/*
Функция семантически аналогична slices.Grow,
однако вызывающая сторона обязана гарантировать,
что old.len + num > old.cap.
Сначала num уменьшается на количество уже доступных элементов
в [old.len:old.cap], чтобы сохранить память.
*/
num -= old.cap - old.len
new := growslice(old.array, old.cap+num, old.cap, num, et)
/*
growslice не очищает память в диапазоне [old.cap:new.len],
поскольку предполагается, что он будет перезаписан через append().
Но reflect_growslice вызывается не из append(),
поэтому нужно явно занулить эту часть перед возвратом среза.
*/
if !et.Pointers() {
oldcapmem := uintptr(old.cap) * et.Size_
newlenmem := uintptr(new.len) * et.Size_
memclrNoHeapPointers(add(new.array, oldcapmem), newlenmem-oldcapmem)
}
// Сохраняем исходную длину
new.len = old.len
return new
}
В Go нет встроенной функции удаления элементов из среза, как
delete()
вmap
илиremove()
в других языках. Вместо этого используется срезание (slicing) и копирование.
Код
s := []int{10, 20, 30, 40, 50} i := 2 s = append(s[:i], s[i+1:]...) // s = [10 20 40 50]
Без сохранения порядка (быстрее):
Код
s[i] = s[len(s)-1] s = s[:len(s)-1]
С сохранением порядка:
Код
copy(s[i:], s[i+1:]) s = s[:len(s)-1]
На уровне runtime
нет отдельной функции удаления из среза — вся логика реализуется пользователем на уровне языка.
В отличие от массивов, слайсы нельзя сравнивать, поэтому мы не можем использовать оператор
==
, чтобы проверить, содержат ли два слайса одинаковые элементы.Стандартная библиотека предоставляет высокоэффективную функцию
bytes.Equal
для сравнения двух слайсов байтов ([]byte
), но для слайсов других типов мы должны реализовать сравнение вручную:
Код
func equal(x, y []string) bool { if len(x) != len(y) { return false } for i := range x { if x[i] != y[i] { return false } } return true }
С учётом того, насколько естественным кажется такое “глубокое” сравнение и что оно не дороже по времени выполнения, чем
==
для массивов строк, может показаться странным, что сравнение слайсов не работает аналогичным образом.Есть две причины, почему “глубокое” равенство проблематично:
В отличие от элементов массива, элементы слайса косвенные (indirect), поэтому возможно, что слайс может содержать сам себя. Хотя существуют способы справиться с такими случаями, ни один из них не является простым, эффективным и, что особенно важно, очевидным.
Из-за того, что элементы слайса косвенные, фиксированное значение слайса может содержать разные элементы в разное время, поскольку содержимое базового массива может изменяться. Так как хеш-таблицы, например тип
map
в Go, создают только поверхностные копии ключей, требуется, чтобы результат сравнения ключей оставался постоянным в течение всей жизни хеш-таблицы. Поэтому “глубокое” равенство сделало бы слайсы непригодными для использования в качестве ключей.Для ссылочных типов, таких как указатели и каналы, оператор
==
проверяет идентичность ссылок — то есть, указывают ли два значения на одно и то же. Аналогичная “поверхностная” проверка для слайсов могла бы быть полезной и решила бы проблему сmap
, но несогласованность в трактовке==
для массивов и слайсов была бы слишком запутанной. Самый безопасный выбор — просто запретить сравнение слайсов.Единственное допустимое сравнение слайса — это сравнение с
nil
, например:
Код
if summer == nil { // ... }
Источник: Alan A. A. Donovan, Brian W. Kernighan - «The Go Programming Language»
На уровне райнтайма нет отдельной функции для сравнения срезов, потому что cравнение — задача уровня пользователя, cрез — это структура из трех полей (data, len, cap), и сравнение по значению data (указателя) — это просто a.data == b.data
, но не поэлементное сравнение.
Встроенная функция
copy
копирует элементы из исходного срезаsrc
в целевой срезdst
.
Код
func copy(dst, src []Type) int
Она возвращает количество скопированных элементов, которое равно минимуму из
len(dst)
иlen(src)
.Результат не зависит от того, перекрываются ли аргументы (то есть можно копировать даже из одного и того же среза — поведение будет корректным).
Источник: < yourbasic.org >
copy()
— это встроенная функция Go. Она обрабатывается компилятором на этапе компиляции, а не как обычная функция. Компилятор сам генерирует вызов memmove
. Код memmove
написан на ассемблере и будет разниться в зависимости от архитектуры.
memmove
для arm64
// Register map
//
// dstin R0 - указатель назначения (исходно)
// src R1 - указатель источника
// count R2 - количество байт для копирования
// dst R3 - указатель назначения, может изменяться при невыравненных копированиях
// srcend R4 - указатель сразу после конца источника (src + count)
// dstend R5 - указатель сразу после конца назначения (dst + count)
// data R6-R17 - временные регистры для загрузки и сохранения данных
// tmp1 R14 - временный регистр
// Основная идея: копирование разбито на 3 основных случая:
// - маленькие копии до 32 байт
// - средние копии до 128 байт
// - большие копии свыше 128 байт
//
// Для больших копий используется программно-конвейерный цикл,
// который обрабатывает по 64 байта за итерацию.
// Указатель назначения выравнивается по 16 байтам, чтобы уменьшить
// количество невыравненных обращений к памяти.
// Оставшиеся байты (хвост) копируются всегда начиная с конца.
// func memmove(to, from unsafe.Pointer, n uintptr)
// Начало функции memmove, атрибуты NOSPLIT|NOFRAME — не используется стэк-фрейм и нет предостановки планировщика
// CBZ R2, copy0
// Если count (R2) равен нулю, переход к copy0 (ничего не копируем)
// Маленькие копии: 1..16 байт
// CMP $16, R2
// BLE copy16
// Если count <= 16, переход к copy16
// Большие копии:
// CMP $128, R2
// BHI copy_long
// Если count > 128, переход к copy_long (большие копии)
// CMP $32, R2
// BHI copy32_128
// Если count > 32, переход к copy32_128 (средние копии)
// Маленькие копии: 17..32 байт.
// LDP (R1), (R6, R7) - загрузка первых 16 байт из источника (парные регистры)
// ADD R1, R2, R4 - вычисление srcend (адрес после конца исходных данных)
// LDP -16(R4), (R12, R13) - загрузка последних 16 байт из источника
// STP (R6, R7), (R0) - сохранение первых 16 байт в назначение
// ADD R0, R2, R5 - вычисление dstend (адрес после конца назначения)
// STP (R12, R13), -16(R5) - сохранение последних 16 байт в назначение (с конца)
// RET - выход из функции
// Далее идет обработка маленьких копий длиной 1..16 байт, разбитая на случаи копирования
// 8, 4, 2, 1 байт с использованием соответствующих команд загрузки и сохранения.
// Средние копии 33..128 байт:
// Загрузка и сохранение блоками по 16 байт с обеих сторон с проверкой длинны,
// с последующим выходом из функции.
// Большие копии более 128 байт:
// - Проверка длины для выбора режима копирования
// - Выравнивание указателей для оптимальной работы с памятью
// - Обработка возможного перекрытия областей (копирование назад, если перекрытие есть)
// - Программно-конвейерное копирование блоками по 64 байта в цикле
// - Копирование хвоста (оставшихся байт)
// Копирование назад (copy_long_backward) для перекрывающихся областей:
// - Выравнивание концов источника и назначения
// - Циклическое копирование блоками по 64 байта с конца к началу
// Конец функции с возвратом после завершения копирования.
В Go функция len()
для срезов является компиляторно-встроенной. То есть она вытаскивает поля из структуры среза SliceType напрямую, без вызова внешней функции.
В Go функция cap()
для срезов является компиляторно-встроенной. То есть она вытаскивает поля из структуры среза SliceType
напрямую, без вызова внешней функции.
Alan A. A. Donovan, Brian W. Kernighan - «The Go Programming Language»
Go Source Code < github.com >
YourBasic WebSite < yourbasic.org >