Как стать автором
Обновить

Срезы(slices) в Go

Время на прочтение15 мин
Количество просмотров4K

Срезы (slices) в Go могут показаться простыми при первом знакомстве, но их эффективное использование требует понимания внутреннего устройства и особенностей работы с памятью. Многие разработчики сталкиваются с путаницей между понятиями длины и емкости срезов, что может привести к неэффективному использованию памяти или даже утечкам. Важно разобраться, как эти концепции работают при выполнении базовых операций: инициализации, добавлении элементов, копировании и нарезке.

В этой статье мы рассмотрим внутреннее устройство срезов, длину, емкость и связь с базовыми массивами, а также разберем распространенные ошибки и оптимальные практики их использования. Мы изучим особенности работы с функциями append и copy, узнаем, как предотвращать утечки памяти при работе с большими срезами, и обсудим различия между нулевыми и пустыми срезами. Также уделим внимание вопросам производительности, включая правильную инициализацию срезов и работу с указателями, чтобы помочь вам писать более эффективный и безопасный код.

Что скрывается за срезом?

Под капотом срез в Go — это структура данных, которая предоставляет доступ к подмножеству элементов базового массива. Срез состоит из трех ключевых компонентов:

  • Указатель на массив — ссылка на первый элемент базового массива, доступный через срез.

  • Длина (len) — количество элементов, которые в данный момент доступны в срезе и могут быть использованы для чтения или записи.

  • Емкость (cap) — общее количество элементов в базовом массиве, начиная с элемента, на который указывает срез.

Важно понимать, что базовый массив является статической структурой фиксированного размера, которая не изменяется после создания. Динамическое поведение, такое как добавление элементов или изменение размера, обеспечивается именно срезами. Когда емкость среза становится недостаточной для добавления новых элементов, создается новый массив большего размера. Затем данные копируются из старого массива в новый, и срез начинает ссылаться на этот новый массив.

Инициализация среза: длина и емкость

Чтобы создать срез, используется функция make. Она позволяет задать как длину, так и емкость:

s := make([]int, 3, 6) // Создаем срез длиной 3 и емкостью 6

Здесь мы создали срез, который ссылается на массив из шести элементов. Первые три элемента инициализируются нулевым значением типа int (то есть 0), а оставшиеся три элемента зарезервированы, но пока не используются. Если выведем этот срез, то увидим только первые три элемента:

fmt.Println(s) // [0 0 0]
Рис.1  Срез длиной 3 и емкостью 6
Рис.1 Срез длиной 3 и емкостью 6

На рисунке видно, что первые три элемента инициализированы, а остальные просто зарезервированы.

Как работает обновление элементов?

Обновление элементов в пределах длины среза происходит без изменений длины или емкости. Например:

s[1] = 1
fmt.Println(s) // [0 1 0]
Рис.2  Обновление второго элемента среза: s[1] = 1
Рис.2 Обновление второго элемента среза: s[1] = 1

Однако попытка обратиться к элементу за пределами длины вызовет ошибку:

s[4] = 0 // panic: runtime error: index out of range [4] with length 3

Как использовать зарезервированное место?

Чтобы добавить новый элемент в срез, используется функция append:

s = append(s, 2)
fmt.Println(s) // [0 1 0 2]
Рис.3  Присоединение элемента к срезу s
Рис.3 Присоединение элемента к срезу s

Здесь функция append использует зарезервированное место в массиве. Длина среза увеличивается с 3 до 4, но емкость остаётся прежней (6).

Что происходит, если емкость исчерпана?

Добавим ещё три элемента:

s = append(s, 3, 4, 5)
fmt.Println(s) // [0 1 0 2 3 4 5]

Когда массив заполняется, Go создаёт новый массив с увеличенной емкостью. Затем все элементы копируются в новый массив, и добавляется новый элемент.

Рис. 4  Поскольку исходный резервный массив заполнен, Go создает другой массив и копирует в него все элементы
Рис. 4 Поскольку исходный резервный массив заполнен, Go создает другой массив и копирует в него все элементы

ПРИМЕЧАНИЕ: В Go ёмкость среза удваивается до тех пор, пока он не станет содержать 1024 элемента, после чего увеличивается на 25%.

Теперь срез ссылается на новый массив. А что произойдет с предыдущим массивом? Если на него больше нет ссылок, он освобождается сборщиком мусора (GC), если был выделен в куче.

"Нарезка" срезов

'Нарезка" — это операция, которая позволяет создавать новые срезы из существующих. Например:

s1 := make([]int, 3, 6) // [0 0 0]
s2 := s1[1:3]           // Новый срез с длиной 2 и емкостью 5
Рис.5  Срезы s1 и s2 ссылаются на один и тот же резервный массив, но с разной длиной и емкостью
Рис.5 Срезы s1 и s2 ссылаются на один и тот же резервный массив, но с разной длиной и емкостью

Оба среза (s1 и s2) ссылаются на один и тот же массив, но имеют разные длины и емкости. Изменение элемента в одном срезе отразится и на другом:

s1[1] = 1
fmt.Println(s1, s2) // [0 1 0] [1 0]
Рис.6  Поскольку за s1 и s2 стоит один и тот же массив, обновление общего элемента делает изменение видимым в обоих срезах
Рис.6 Поскольку за s1 и s2 стоит один и тот же массив, обновление общего элемента делает изменение видимым в обоих срезах

Однако, если мы добавим элемент в s2, он не повлияет на s1, так как длина s2 изменится, но не затронет s1:

s2 = append(s2, 2)
fmt.Println(s1, s2) // [0 1 0] [1 0 2]
Рис.7  Добавление элемента в s2
Рис.7 Добавление элемента в s2

Особенности работы с памятью

Когда срезы разделяют один массив, важно помнить, что изменения в одном срезе могут повлиять на другой. Однако, если емкость исчерпана, Go создает новый массив для одного из срезов, и они больше не будут связаны:

s2 = append(s2, 3, 4, 5)
fmt.Println(s1, s2) // [0 1 0] [1 0 2 3 4 5]
Рис.8  Добавление элементов в s2 до тех пор, пока резервный массив не окажется заполненным
Рис.8 Добавление элементов в s2 до тех пор, пока резервный массив не окажется заполненным

Теперь s1 и s2 ссылаются на разные массивы. s1 продолжает использовать исходный массив, а s2 работает с новым.

Подводя итог

Срезы в Go — это отличный инструмент, который упрощает работу с массивами. Однако их эффективное использование требует четкого понимания концепций длины и емкости:

  • Длина — это количество доступных элементов.

  • Емкость — это общий объем зарезервированной памяти.

  • При добавлении элементов за пределы емкости создается новый массив, что может повлиять на производительность.

Инициализация срезов: длина, емкость и производительность

Как было показано ранее, при инициализации среза с помощью оператора make важно указать длину и, при необходимости, емкость . Однако многие разработчики (и я в том числе) иногда забывают правильно задать эти параметры, что может привести к неоптимальному использованию памяти.

Давайте рассмотрим практический пример. Предположим, нам нужно реализовать функцию convert, которая преобразует срез типа Foo в срез типа Bar. Оба среза будут иметь одинаковое количество элементов. Вот одна из типичных реализаций:

func convert(foos []Foo) []Bar {
    bars := make([]Bar, 0) // Создаем пустой срез
    for _, foo := range foos {
        bars = append(bars, fooToBar(foo)) // Добавляем элементы
    }
    return bars
}

На первый взгляд, всё выглядит просто. Сначала мы создаем пустой срез bars, а затем используем append для добавления элементов. Но здесь есть проблема. Когда мы добавляем первый элемент, Go создает резервный массив размером 1. Каждый раз, когда массив заполняется, Go создает новый массив, удваивая его емкость (как мы обсуждали в предыдущем разделе).

Такая логика создания нового массива повторяется несколько раз: при добавлении третьего элемента, пятого, девятого и так далее. Если входной срез содержит, например, 1000 элементов, этот алгоритм потребует выделения десяти резервных массивов и копирования более 1000 элементов из одного массива в другой. Это создает дополнительную нагрузку на сборщик мусора, который должен очистить память от всех временных массивов.

Как улучшить производительность?

Чтобы помочь среде выполнения Go, можно использовать два подхода. Первый — переиспользовать тот же код, но создать срез с заданной емкостью :

func convert(foos []Foo) []Bar {
    n := len(foos)
    bars := make([]Bar, 0, n) // Создаем срез с заданной емкостью
    for _, foo := range foos {
        bars = append(bars, fooToBar(foo))
    }
    return bars
}

Здесь мы заранее резервируем место под массив из n элементов. Теперь добавление элементов вплоть до n происходит в рамках одного резервного массива, что значительно сокращает количество операций по выделению памяти.

Второй подход — создать срез с заданной длиной :

func convert(foos []Foo) []Bar {
    n := len(foos)
    bars := make([]Bar, n) // Создаем срез с заданной длиной
    for i, foo := range foos {
        bars[i] = fooToBar(foo) // Присваиваем значение по индексу
    }
    return bars
}

Поскольку мы инициализируем срез с определённой длиной, память под n элементов уже выделена, и они инициализированы нулевым значением. Теперь вместо append мы используем прямое присваивание по индексу.

Какой подход лучше?

Чтобы сравнить производительность этих решений, проведём бенчмарк с входным срезом из 1 миллиона элементов:

  • Первый вариант (пустой срез + append) оказывается почти на 400 % медленнее , чем два других.

  • Второй вариант (срез с заданной емкостью + append) работает немного медленнее третьего варианта.

  • Третий вариант (срез с заданной длиной + прямое присваивание) оказывается на 4 % быстрее , поскольку мы избегаем вызовов append.

Почему append всё ещё популярен?

Если третий подход эффективнее, почему разработчики часто предпочитают использовать append? Рассмотрим пример из проекта Pebble , хранилища ключей и значений с открытым исходным кодом.

Функция collectAllUserKeys должна преобразовать срез структур в срез байтов. Результирующий срез будет в два раза длиннее входного:

func collectAllUserKeys(cmp Compare, tombstones []tombstoneWithLevel) [][]byte {
    keys := make([][]byte, 0, len(tombstones)*2) // Создаем срез с заданной емкостью
    for _, t := range tombstones {
        keys = append(keys, t.Start.UserKey)
        keys = append(keys, t.End)
    }
    // ...
}

Здесь разработчики сознательно выбрали использование append с заданной емкостью. Почему? Если бы они использовали срез с заданной длиной, код стал бы сложнее для восприятия:

func collectAllUserKeys(cmp Compare, tombstones []tombstoneWithLevel) [][]byte {
    keys := make([][]byte, len(tombstones)*2) // Создаем срез с заданной длиной
    for i, t := range tombstones {
        keys[i*2] = t.Start.UserKey
        keys[i*2+1] = t.End
    }
    // ...
}

Обратите внимание, как усложнилась работа с индексами. Поскольку эта функция не слишком чувствительна к производительности, разработчики предпочли читаемость кода.

Что делать, если длина среза неизвестна?

А что если длина выходного среза зависит от каких-то условий? Например:

func convertConditionally(foos []Foo) []Bar {
    var bars []Bar // Создаем пустой срез
    for _, foo := range foos {
        if something(foo) { // Добавляем только при выполнении условия
            bars = append(bars, fooToBar(foo))
        }
    }
    return bars
}

Если условие выполняется в 99 % случаев, возможно, стоит инициализировать bars с заданной длиной или емкостью. Однако решение всегда зависит от конкретной ситуации.

Преобразование одного типа среза в другой — частая операция в Go. Если длина будущего среза известна заранее, нет веской причины создавать пустой срез. Мы можем выбрать между созданием среза с заданной емкостью или с заданной длиной. Хотя второй подход работает немного быстрее, использование append с заданной емкостью часто проще для реализации и чтения.

Нулевые и пустые срезы

В процессе работы с языком Go многие разработчики сталкиваются с путаницей между нулевыми и пустыми срезами. На первый взгляд эти понятия кажутся схожими, но между ними есть важные различия, которые могут существенно повлиять на работу программы.

Основные определения

  • Срез считается пустым, если его длина равна 0

  • Срез считается нулевым, если его значение равно nil

Эти два состояния не всегда взаимозаменяемы, и важно понимать их различия для эффективной работы с данных.

Способы инициализации срезов

Рассмотрим несколько способов создания срезов и их особенности:

var s []string // Вариант 1 - нулевой срез
s = []string(nil) // Вариант 2 - также нулевой срез
s = []string{} // Вариант 3 - пустой, но не нулевой срез
s = make([]string, 0) // Вариант 4 - аналогично варианту 3

При выводе информации о каждом из этих срезов получим следующее:

1: empty=true   nil=true
2: empty=true   nil=true
3: empty=true   nil=false
4: empty=true   nil=false

Как видно из примера, все срезы являются пустыми, но только первые два варианта являются нулевыми.

Особенности использования

Производительность: Нулевые срезы не требуют выделения памяти, что делает их более эффективными по сравнению с пустыми срезами.

Работа с append(): Функция append() работает одинаково хорошо как с нулевыми, так и с пустыми срезами:

var s1 []string
fmt.Println(append(s1, "foo")) // [foo]

Использование в функциях: При возврате среза из функции предпочтительнее использовать нулевой срез, если нет необходимости в конкретной инициализации:

func f() []string {
    var s []string
    if foo() {
        s = append(s, "foo")
    }
    return s
}

Создание срезов известной длины

Когда заранее известна длина среза, рекомендуется использовать make():

func intsToStrings(ints []int) []string {
    s := make([]string, len(ints))
    for i, v := range ints {
        s[i] = strconv.Itoa(v)
    }
    return s
}

Этот подход помогает избежать дополнительных выделений памяти и копирований.

Особенности работы с библиотеками

Важно помнить, что некоторые стандартные библиотеки различают нулевые и пустые срезы. Например, при работе с encoding/json:

var s1 []float32 // Нулевой срез
customer1 := customer{
    ID: "foo",
    Operations: s1,
}

s2 := make([]float32, 0) // Пустой срез
customer2 := customer{
    ID: "bar",
    Operations: s2,
}

Результаты маршалинга будут различаться:

{"ID":"foo","Operations":null}
{"ID":"bar","Operations":[]}

Аналогичное поведение наблюдается и при использовании Reflect.DeepEqual, что особенно важно учитывать при написании юнит-тестов.

Рекомендации по использованию

  1. var s []string, если конечная длина среза неизвестна.

  2. []string(nil) как компактный способ создания нулевого среза.

  3. Для срезов известной длины make([]string, length).

  4. []string{} при создании срезов без начальных элементов.

Проверка срезов на наличие элементов

В процессе работы с срезами в Go часто возникает необходимость проверить, содержит ли срез элементы. На первый взгляд, задача кажется простой, но неправильный подход может привести к трудноуловимым ошибкам.

Типичный пример, демонстрирующий распространенную ошибку:

func handleOperations(id string) {
    operations := getOperations(id)
    
    if operations != nil { // Проверка на nil
        handle(operations)
    }
}

func getOperations(id string) []float32 {
    operations := make([]float32, 0)
    if id == "" {
        return operations // Возвращаем пустой срез
    }
    // Добавление элементов
    return operations
}

Проблема заключается в том, что функция getOperations никогда не возвращает nil, а всегда возвращает пустой срез. Следовательно, проверка operations != nil всегда будет возвращать true, что приведет к некорректной работе программы.

Один из вариантов решения – модифицировать getOperations так, чтобы она возвращала nil:

func getOperations(id string) []float32 {
    operations := make([]float32, 0)
    
    if id == "" {
        return nil // Теперь возвращаем nil
    }
    
    // Добавление элементов...
    return operations
}

Но этот подход не универсален. Например, если мы работаем с внешней библиотекой, изменить её поведение невозможно. Наиболее корректный способ проверки – использовать длину среза:

func handleOperations(id string) {
    operations := getOperations(id)
    
    if len(operations) != 0 { // Проверка длины среза
        handle(operations)
    }
}

Этот подход работает корректно в обоих случаях:

  1. Если срез равен nil, то len(operations) вернет 0

  2. Если срез пустой, но не nil, то len(operations) также вернет 0

Такой подход позволяет избежать различий между нулевыми и пустыми срезами, которые могут стать источником ошибок. Как отмечается в официальных рекомендациях Go, при проектировании интерфейсов не должно быть разницы между nil и пустым срезом – оба варианта должны восприниматься одинаково вызывающей стороной. Этот принцип применим и к картам: для проверки их пустоты следует использовать длину, а не сравнение с nil.

Использование функции copy

Копирование данных между срезами — частая задача в Go, и встроенный метод copy создан специально для этой цели. Однако неправильное понимание его работы может привести к неожиданным результатам.

src := []int{0, 1, 2}
var dst []int
copy(dst, src)
fmt.Println(dst) // Выведет []

Почему результат пуст? Дело в том, что функция copy копирует количество элементов, равное минимуму из длин двух срезов. В данном случае dst — это нулевой срез с длиной 0, поэтому ничего не скопируется.

Чтобы выполнить полное копирование, необходимо правильно инициализировать целевой срез:

src := []int{0, 1, 2}
dst := make([]int, len(src)) // Создаем срез нужной длины
copy(dst, src)
fmt.Println(dst) // Выведет [0 1 2]

Важно помнить:

  • Первым аргументом copy идет целевой срез (куда копируем)

  • Вторым — исходный срез (откуда копируем)

  • Количество скопированных элементов = min(len(src), len(dst))

Существует альтернативный способ копирования с использованием append:

src := []int{0, 1, 2}
dst := append([]int(nil), src...)

Этот метод создает копию за одну строку и может показаться более компактным. Однако использование copy считается более идиоматичным и понятным для других разработчиков, несмотря на необходимость дополнительной строки для инициализации целевого среза.

Подводя итог:

  1. При использовании copy всегда проверяйте длину целевого среза

  2. Помните о порядке аргументов функции

  3. Хотя существуют альтернативные способы копирования, copy остается предпочтительным вариантом

Особенности использования append

Функция append в Go может таить в себе скрытые опасности, особенно когда речь идет о работе с срезами, полученными через нарезку (slicing). Рассмотрим пример, который демонстрирует эту проблему:

s1 := []int{1, 2, 3}
s2 := s1[1:2]
s3 := append(s2, 10)

На первый взгляд может показаться, что мы просто создаем новый срез s3, добавляя элемент к s2. Однако реальность такова, что все три среза (s1, s2, s3) разделяют один и тот же базовый массив. В результате выполнения этого кода получим:

s1 = [1 2 10]
s2 = [2]
s3 = [2 10]

Почему так происходит? Дело в том, что append работает следующим образом:

  1. Проверяет, есть ли свободная емкость в базовом массиве

  2. Если есть — обновляет существующий массив

  3. Если нет — создает новый массив

Рис.9  Оба среза имеют один и тот же резервный массив, но разные длины и емкости
Рис.9 Оба среза имеют один и тот же резервный массив, но разные длины и емкости

В нашем случае s2 имеет емкость 2 при длине 1, поэтому append просто обновил общий базовый массив, изменив значение третьего элемента.

Проблема при передаче срезов в функции

Эта особенность может привести к неожиданным последствиям при передаче срезов в функции:

func main() {
    s := []int{1, 2, 3}
    f(s[:2])
    fmt.Println(s) // [1 2 10]
}

func f(s []int) {
    _ = append(s, 10)
}

Хотя мы передали только первые два элемента, третий элемент также был изменен из-за общего базового массива.

Рис.10 За всеми срезами стоит один и тот же резервный массив
Рис.10 За всеми срезами стоит один и тот же резервный массив

Существует два основных способа защиты от таких побочных эффектов:

1.Создание копии среза

func main() {
    s := []int{1, 2, 3}
    copy := make([]int, len(s[:2]))
    copy(copy, s[:2])
    f(copy)
    fmt.Println(s) // [1 2 3]
}

Но это увеличивает сложность кода и требует дополнительной памяти для копии.

2.Использование полного выражения среза :

func main() {
    s := []int{1, 2, 3}
    f(s[:2:2]) // Полное выражение среза
    fmt.Println(s) // [1 2 3]
}

Здесь s[:2:2] создает срез с ограниченной емкостью, предотвращая возможность изменения оригинального среза через append.

На рис. 11 показана разница между обычным и полным выражением среза:

  • s[0:2] создает срез длиной 2 и емкостью 3

  • s[0:2:2] создает срез длиной 2 и емкостью 2

Рис.11 s[0:2] создает срез длиной 2 и емкостью 3, тогда как s[0:2:2] создает срез длиной 2 и емкостью 2
Рис.11 s[0:2] создает срез длиной 2 и емкостью 3, тогда как s[0:2:2] создает срез длиной 2 и емкостью 2

Использование полного выражения среза является более эффективным решением, так как оно, не требует дополнительной памяти, сохраняет читаемость кода, явно ограничивает область влияния возможных изменений.

При работе с append важно помнить о разделяемом базовом массиве и потенциальных побочных эффектах. Для защиты от нежелательных изменений используйте полное выражение среза или создавайте копию данных. Это особенно актуально при проектировании API, где важно контролировать возможные побочные эффекты для обеспечения безопасности и предсказуемости работы программы.

Проблема утечки емкости

Представим, что мы реализуем собственный двоичный протокол, где каждое сообщение может содержать до 1 миллиона байт, а первые 5 байт определяют тип сообщения. Мы хотим сохранять последние 1000 типов сообщений:

func getMessageType(msg []byte) []byte {
    return msg[:5]
}

На первый взгляд все выглядит корректно – мы возвращаем только первые 5 байт. Однако в реальности новый срез сохраняет ссылку на весь исходный массив длиной 1 миллион байт. В результате вместо ожидаемых 5 КБ (1000 × 5 байт) мы потребляем около 1 ГБ памяти!

На рис. 12 показана проблема: даже после того как основной срез msg больше не используется, созданный через нарезку срез продолжает удерживать в памяти весь массив из 1 миллиона байт.

Рис.12
Рис.12

Решение проблемы

Чтобы избежать утечки памяти, необходимо создавать настоящую копию данных:

func getMessageType(msg []byte) []byte {
    msgType := make([]byte, 5)
    copy(msgType, msg)
    return msgType
}

Теперь msgType – это полностью независимый срез с длиной и емкостью 5, который действительно занимает только необходимые 5 байт.

Ограничения полного выражения среза

Может показаться, что использование полного выражения среза решит проблему:

func getMessageType(msg []byte) []byte {
   return msg[:5:5]
}

Однако это не так. Несмотря на то, что создается срез с ограниченной емкостью, базовый массив по-прежнему остается в памяти. Даже принудительный вызов сборщика мусора (runtime.GC()) не освобождает недоступное пространство за пределами первых 5 байт.

Мониторинг памяти

Для проверки потребления памяти можно использовать следующий код:

func printAlloc() {
   var m runtime.MemStats
   runtime.ReadMemStats(&m)
   fmt.Printf("%d KB\n", m.Alloc/1024)
}

Как вывод:

  • Нарезка больших срезов или массивов может привести к значительному потреблению памяти

  • Сборщик мусора не освобождает недоступные участки базового массива

  • Для предотвращения утечек используйте явное копирование данных через copy

  • Полные выражения среза не решают проблему утечек памяти

Понимание этих особенностей поможет эффективно управлять памятью в Go-приложениях и избежать непредвиденного высокого потребления ресурсов.

Утечки памяти при работе со срезами и указателями: особенности сборки мусора в Go

Помимо утечек емкости, о которых шла речь ранее, существует еще одна важная проблема — работа со срезами, содержащими элементы-указатели или структуры с полями-указателями. Рассмотрим эту ситуацию на примере.

Предположим, у нас есть структура Foo, содержащая байтовый срез:

type Foo struct {
    v []byte
}

Мы создаем срез из 1000 элементов Foo, для каждого выделяем по 1 МБ памяти, а затем сохраняем только первые два элемента через нарезку:

func keepFirstTwoElementsOnly(foos []Foo) []Foo {
    return foos[:2]
}

Ожидание vs Реальность

Логично предположить, что после операции нарезки и вызова сборщика мусора (runtime.GC()), память, выделенная под 998 "лишних" элементов, должна быть освобождена. Однако реальность такова:

83 KB        // Начальное выделение (1000 нулевых Foo)
1024072 KB   // После добавления 1 МБ к каждому Foo
1024072 KB   // После нарезки и сборки мусора!

Почему так происходит? Дело в том, что:

  1. Срез все еще ссылается на исходный базовый массив

  2. Каждый элемент Foo содержит поле v — это указатель на отдельный массив

  3. Сборщик мусора видит, что базовый массив все еще используется и не может освободить память под "лишние" элементы

Решения проблемы

1.Создание копии среза:

func keepFirstTwoElementsOnly(foos []Foo) []Foo {
    res := make([]Foo, 2)
    copy(res, foos)
    return res
}

Этот метод создает новый независимый срез, содержащий только нужные элементы. Теперь сборщик мусора сможет освободить память под остальные элементы.

2.Обнуление полей:

func keepFirstTwoElementsOnly(foos []Foo) []Foo {
    for i := 2; i < len(foos); i++ {
        foos[i].v = nil
    }
    return foos[:2]
}

Здесь мы явно обнуляем ссылки на массивы в "лишних" элементах, позволяя сборщику мусора их очистить, но сохраняем базовую емкость среза.

На рис. 13 показаны два подхода:

  • Первый вариант (копирование) лучше, когда нужно сохранить небольшое количество элементов

  • Второй вариант (обнуление) эффективнее, когда нужно сохранить большую часть исходного среза

Рис.13  Вариант 1 используется до i-го элемента, вариант 2 - после
Рис.13 Вариант 1 используется до i-го элемента, вариант 2 - после

Выбор решения зависит от конкретной ситуации:

  • Если важно минимизировать потребление памяти и сохраняется малая часть элементов — используйте копирование

  • Если важна производительность и сохраняется большая часть элементов — используйте обнуление полей

Важные выводы:

  1. При работе со срезами, содержащими указатели или структуры с полями-указателями, необходимо помнить о двух типах утечек:

    • Утечка емкости (резервный массив)

    • Утечка через указатели (поля структур)

  2. Для предотвращения утечек используйте:

    • Копирование данных

    • Явное обнуление ссылок

  3. Выбор метода зависит от соотношения сохраняемых и удаляемых элементов

Мы рассматрели срезы (slices) в языке Go, их внутреннее устройство, особенности работы с длиной и емкостью, а также распространенные ошибки и оптимальные практики их использования; как происходит инициализация срезов, добавление элементов через append, копирование данных через copy, и как избежать утечек памяти при работе с большими срезами или срезами, содержащими указатели; уделили внимание различию между нулевыми и пустыми срезами, особенностям нарезки срезов, влиянию общего базового массива на изменение данных, а также методам предотвращения побочных эффектов и утечек памяти через использование полных выражений среза, явного копирования данных или обнуления полей.

Теги:
Хабы:
Если эта публикация вас вдохновила и вы хотите поддержать автора — не стесняйтесь нажать на кнопку
+13
Комментарии17

Публикации

Работа

Go разработчик
82 вакансии

Ближайшие события