Go. Структуры данных
Слайсы:
внутреннее устройство
Слайсы: внутреннее устройство
Введение во внутреннее устройство слайсов
Представьте длинную полку с пронумерованными книгами (это базовый массив). А слайс — это карточка с инструкцией, на которой написано:
  • «Начни с книги номер X» (указатель)
  • «Возьми Y книг подряд» (длина)
  • «На полке есть место ещё для Z книг» (ёмкость)
Вы можете создать несколько карточек для одной и той же полки, каждая будет указывать на свой диапазон книг. Изменяя книгу по одной карточке, вы измените её и для всех остальных карточек — ведь полка-то одна!
Давайте заглянем «под капот» слайсов и разберёмся, как эта «карточка» устроена на низком уровне.
Содержание
Что такое слайс на самом деле?
Слайс в Go — это структура данных, состоящая из трёх полей:
// Упрощённое представление структуры слайса в Go
type sliceHeader struct {
    Pointer uintptr // ← указатель на базовый массив
    Length  int    // ← текущая длина слайса
    Capacity int    // ← ёмкость базового массива
}
Теперь посмотрим, как эта структура выглядит в реальном коде. Мы создадим слайс и заглянем в его внутреннее представление с помощью unsafe.Pointer:
package main

import (
    "fmt"
    "unsafe"
)

func main() {
    // Создаём слайс
    numbers := []int{10, 20, 30, 40, 50}
    
    fmt.Printf("Слайс: %v\n", numbers)
    fmt.Printf("Длина: %d\n", len(numbers))
    fmt.Printf("Ёмкость: %d\n", cap(numbers))
    
    // Получаем доступ к внутреннему представлению (только для демонстрации!)
    header := (*[3]uintptr)(unsafe.Pointer(&numbers))
    fmt.Printf("Указатель: %d\n", header[0])
    fmt.Printf("Длина: %d\n", header[1])
    fmt.Printf("Ёмкость: %d\n", header[2])
}
Что здесь происходит?
  • numbers
    Слайс, который на самом деле является структурой из трёх полей
  • unsafe.Pointer
    Позволяет нам заглянуть во внутреннее представление
  • Указатель указывает на первый элемент базового массива
  • Длина
    Количество элементов, которые мы видим через «окно»
  • Ёмкость
    Размер всего базового массива
Базовый массив и его роль
Как слайсы связаны с массивами
Когда мы создаём несколько слайсов из одного массива, все они указывают на один и тот же базовый массив. Это означает, что изменение элемента через один слайс будет видно и в других слайсах. Давайте проверим это на практике:
package main

import "fmt"

func main() {
    // Создаём массив
    array := [8]int{0, 1, 2, 3, 4, 5, 6, 7}
    fmt.Printf("Исходный массив: %v\n", array)
    
    // Создаём разные слайсы из одного массива
    slice1 := array[2:6] // [2, 3, 4, 5]
    slice2 := array[0:4] // [0, 1, 2, 3]
    slice3 := array[4:]  // [4, 5, 6, 7]
    
    fmt.Printf("slice1: %v (len=%d, cap=%d)\n", slice1, len(slice1), cap(slice1))
    fmt.Printf("slice2: %v (len=%d, cap=%d)\n", slice2, len(slice2), cap(slice2))
    fmt.Printf("slice3: %v (len=%d, cap=%d)\n", slice3, len(slice3), cap(slice3))
    
    // Изменяем элемент в одном из слайсов
    slice1[1] = 999 // изменяем элемент с индексом 1 в slice1 (это array[3])
    
    fmt.Printf("\nПосле изменения slice1[1] = 999:\n")
    fmt.Printf("slice1: %v\n", slice1) // [2, 999, 4, 5]
    fmt.Printf("slice2: %v\n", slice2) // [0, 1, 2, 999] ← тоже изменился!
    fmt.Printf("slice3: %v\n", slice3) // [4, 5, 6, 7]
    fmt.Printf("array:  %v\n", array)   // [0, 1, 2, 999, 4, 5, 6, 7]
}
Что здесь происходит?
  • Все три слайса указывают на один и тот же базовый массив
  • Изменение элемента в одном слайсе затрагивает все остальные слайсы
  • Ёмкость каждого слайса зависит от его положения в базовом массиве
Полезная техника
Срезание с нулевой длиной (slice[:0]) создаёт пустой слайс, который всё ещё указывает на тот же базовый массив и сохраняет его ёмкость. Это позволяет переиспользовать уже выделенную память без дополнительных аллокаций.
Утечка памяти при срезании
Из того, что слайсы разделяют базовый массив, вытекает неочевидная проблема: даже маленький срез удерживает в памяти весь базовый массив. Это может привести к утечкам памяти:
package main

import "fmt"

func processData() {
    // Создаём большой слайс
    bigData := make([]byte, 1_000_000) // 1MB данных
    
    // Берём маленький срез
    smallSlice := bigData[:100] // только 100 байт
    
    // Но smallSlice удерживает весь bigData в памяти!
    process(smallSlice)
    
    // bigData не будет собран сборщиком мусора, пока smallSlice существует
}

func process(data []byte) {
    fmt.Printf("Обрабатываем %d байт\n", len(data))
}

func main() {
    processData()
}
Решение: создать полную копию нужной части данных:
func processData() {
    bigData := make([]byte, 1_000_000)
    
    // Создаём полную копию нужной части
    smallSlice := make([]byte, 100)
    copy(smallSlice, bigData[:100])
    
    process(smallSlice)
    // Теперь bigData может быть собран сборщиком мусора
}
Создание слайсов с make()
Мы уже знаем, что слайс состоит из трёх полей: указатель на базовый массив, длина и ёмкость. При создании слайса через литерал ([]int{1, 2, 3}) Go автоматически задаёт эти параметры. Но что, если мы хотим контролировать их явно?
Для этого существует встроенная функция make(), которая создаёт слайс с указанными длиной и ёмкостью. У неё есть два варианта использования:
package main

import "fmt"

func main() {
    // Вариант 1: make([]тип, длина)
    // Создаёт слайс с length = capacity
    slice1 := make([]int, 5)
    fmt.Printf("slice1: %v (len=%d, cap=%d)\n", slice1, len(slice1), cap(slice1))
    // Вывод: slice1: [0 0 0 0 0] (len=5, cap=5)
    
    // Вариант 2: make([]тип, длина, ёмкость)
    // Создаёт слайс с заданными length и capacity
    slice2 := make([]int, 3, 10)
    fmt.Printf("slice2: %v (len=%d, cap=%d)\n", slice2, len(slice2), cap(slice2))
    // Вывод: slice2: [0 0 0] (len=3, cap=10)
    
    // Все элементы инициализируются нулевыми значениями типа
    slice3 := make([]string, 2, 5)
    fmt.Printf("slice3: %v (len=%d, cap=%d)\n", slice3, len(slice3), cap(slice3))
    // Вывод: slice3: [ ] (len=2, cap=5)
}
Ёмкость не может быть меньше длины. Компилятор не пропустит код make([]int, 5, 3).
Параметр capacity позволяет оптимизировать работу с памятью. Когда вы будете добавлять элементы через append, лучше сразу выделить нужную ёмкость:
// Если заранее знаем количество элементов
slice := make([]int, 0, 1000) // length=0, capacity=1000
for i := 0; i < 1000; i++ {
    slice = append(slice, i) // Реаллокаций не будет
}
Когда вы будете заполнять элементы по индексу, создайте слайс нужного размера:
// Создаём слайс нужного размера
numbers := make([]int, 5) // length=5, capacity=5
for i := 0; i < 5; i++ {
    numbers[i] = i * 10 // Обращаемся по индексу
}
Индексация работает только в пределах length, а не capacity. Для добавления элементов за пределы length используйте append().
Операция append и реаллокация
Как работает append
Когда вы добавляете элементы в слайс с помощью append, Go проверяет, достаточно ли ёмкости в базовом массиве. Если места хватает, элемент просто добавляется. Если нет — создаётся новый, более вместительный массив, данные копируются, и слайс начинает указывать на него. Давайте посмотрим, как это работает:
package main

import "fmt"

func main() {
    // Создаём слайс с небольшой ёмкостью
    slice := make([]int, 3, 5) // длина 3, ёмкость 5
    slice[0], slice[1], slice[2] = 10, 20, 30
    
    fmt.Printf("Начальный: %v (len=%d, cap=%d)\n", slice, len(slice), cap(slice))
    
    // Добавляем элемент - ёмкость позволяет
    slice = append(slice, 40)
    fmt.Printf("После append 40: %v (len=%d, cap=%d)\n", slice, len(slice), cap(slice))
    
    // Добавляем ещё один элемент - ёмкость позволяет
    slice = append(slice, 50)
    fmt.Printf("После append 50: %v (len=%d, cap=%d)\n", slice, len(slice), cap(slice))
    
    // Добавляем элемент - ёмкость не позволяет, произойдёт реаллокация
    slice = append(slice, 60)
    fmt.Printf("После append 60: %v (len=%d, cap=%d)\n", slice, len(slice), cap(slice))
    
    // Теперь слайс указывает на новый базовый массив!
}
Стратегия роста ёмкости
Go автоматически управляет ростом ёмкости слайсов, чтобы минимизировать количество реаллокаций. Интересно посмотреть, как именно увеличивается ёмкость при последовательных append. Создадим пустой слайс и будем добавлять в него элементы, отслеживая моменты изменения ёмкости:
package main

import "fmt"

func main() {
    // Наблюдаем за ростом ёмкости
    slice := []int{}
    
    for i := 0; i < 20; i++ {
        oldCap := cap(slice)
        slice = append(slice, i)
        newCap := cap(slice)
        
        if oldCap != newCap {
            fmt.Printf("Элемент %d: ёмкость изменилась с %d на %d\n", i, oldCap, newCap)
        }
    }
}
Вывод программы (примерный):
Элемент 0: ёмкость изменилась с 0 на 1
Элемент 1: ёмкость изменилась с 1 на 2
Элемент 2: ёмкость изменилась с 2 на 4
Элемент 4: ёмкость изменилась с 4 на 8
Элемент 8: ёмкость изменилась с 8 на 16
Go удваивает ёмкость, когда текущая становится недостаточной, но может использовать и другие стратегии для оптимизации.
Важные особенности append
При работе с append важно понимать два ключевых момента:
1. Append всегда возвращает новую структуру слайса
slice = append(slice, element) // ✅ Правильно
append(slice, element)           // ❌ Неправильно - изменения потеряются
2. Поведение зависит от наличия свободного места
Если в базовом массиве есть место (length < capacity):
  • Элемент добавляется в базовый массив
  • Увеличивается только длина слайса
  • Все слайсы, указывающие на этот массив, могут «увидеть» новый элемент, если он попадает в их ёмкость
Если в базовом массиве нет места (length == capacity):
  • Создаётся новый, более вместительный массив
  • Данные копируются в новый массив
  • Слайс начинает указывать на новый массив
  • Старые слайсы продолжают указывать на старый массив
Оптимизация: предварительное выделение памяти
Мы уже рассмотрели, как make() помогает избежать реаллокаций. Теперь давайте измерим реальный выигрыш в производительности:
package main

import (
    "fmt"
    "time"
)

func main() {
    // ❌ Плохо: многократные реаллокации
    start := time.Now()
    var bad []int
    for i := 0; i < 100000; i++ {
        bad = append(bad, i)
    }
    fmt.Printf("Плохой подход: %v, время: %v\n", len(bad), time.Since(start))
    
    // ✅ Хорошо: предварительное выделение памяти
    start = time.Now()
    good := make([]int, 0, 100000) // заранее выделяем ёмкость
    for i := 0; i < 100000; i++ {
        good = append(good, i)
    }
    fmt.Printf("Хороший подход: %v, время: %v\n", len(good), time.Since(start))
}
Что здесь происходит?
  • Первый подход вызывает множественные реаллокации (примерно 17 раз для 100 000 элементов)
  • Второй подход выделяет всю необходимую память сразу, избегая реаллокаций
  • Разница в производительности может быть в 2−3 раза
Заключение
Теперь вы понимаете, как устроены слайсы «под капотом». Давайте подведём итоги:
Ключевые моменты:
  • Слайс — это структура из трёх полей: указатель, длина, ёмкость
  • Все слайсы, созданные из одного базового массива, разделяют память
  • append может вызывать реаллокацию и создание нового базового массива
  • Срезание не создаёт копию данных, только новую «рамку»
  • Передача слайса в функцию копирует только структуру, не данные
  • Неправильное понимание внутреннего устройства может привести к утечкам памяти
Что дальше?
Теперь вы готовы переходить к другим структурам данных в Go:
  • Мапы
    Объявление, добавление и удаление элементов, итерация
  • Структуры
    Определение, инициализация, доступ к полям
Поздравляю, вы только что заглянули «под капот» слайсов в Go!
Это знание поможет вам писать более эффективный и предсказуемый код.