Перевод поста Valentin Deleplace от 22 February 2024 Robust generic functions on slices


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

С помощью Type parameters мы можем писать функции типа slices.Index один раз для всех типов слайсов сравниваемых элементов:

// Index returns the index of the first occurrence of v in s,
// or -1 if not present.
func Index[S ~[]E, E comparable](s S, v E) int {
    for i := range s {
        if v == s[i] {
            return i
        }
    }
    return -1
}

Теперь нет необходимости заново реализовывать Index для каждого отдельного типа элементов.

Пакет slices содержит множество таких помощников для выполнения общих операций над слайсами:

s := []string{"Bat", "Fox", "Owl", "Fox"}
s2 := slices.Clone(s)
slices.Sort(s2)
fmt.Println(s2) // [Bat Fox Fox Owl]
s2 = slices.Compact(s2)
fmt.Println(s2)                  // [Bat Fox Owl]
fmt.Println(slices.Equal(s, s2)) // false

Несколько новых функций (Insert, Replace, Delete и т. д.) изменяют слайс. Чтобы понять, как они работают и как правильно их использовать, нам нужно изучить базовую структуру слайса.

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

Например, этот слайс s представляет собой представление на 4 элемента массива размером 6:

This is an image

Если функция изменяет длину слайса, переданного в качестве параметра, то она должна вернуть вызывающей стороне новый слайс. Базовый массив может оставаться неизменным, если ему не нужно увеличиваться. Это объясняет, почему функции append и slices.Compact возвращают значение, а slices.Sort, которая просто упорядочивает элементы, - нет.

Рассмотрим задачу удаления части слайса. До появления дженериков стандартным способом удаления части s[2:5] из слайса s был вызов функции append для копирования конечной части поверх средней:

s = append(s[:2], s[5:]...)

Синтаксис был сложным и чреватым ошибками, в него входили подмножества и переменный параметр. Мы добавили slice.Delete, чтобы упростить удаление элементов:

func Delete[S ~[]E, E any](s S, i, j int) S {
       return append(s[:i], s[j:]...)
}

Однострочная функция Delete более четко выражает замысел программиста. Рассмотрим слайс s длины 6 и емкости 8, содержащий указатели:

This is an image

Этот вызов удаляет элементы по адресам s[2], s[3], s[4] из фрагмента s:

s = slices.Delete(s, 2, 5)

This is an image

Промежуток по индексам 2, 3, 4 заполняется сдвигом элемента s[5] влево и установкой новой длины в 3.

Delete не требует выделения нового массива, так как просто перемещает элементы. Как и append, он возвращает новый слайс. Многие другие функции пакета slices следуют этому шаблону, включая Compact, CompactFunc, DeleteFunc, Grow, Insert и Replace.

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

slices.Delete(s, 2, 5) // incorrect!
// s still has the same length, but modified contents

Проблема нежелательной живучести

До версии Go 1.22 функция slices.Delete не изменяла элементы между новой и исходной длиной слайса. Хотя возвращаемый слайс не включал эти элементы, “пробел”, образовавшийся в конце исходного слайса, продолжал удерживать их. Эти элементы могли содержать указатели на большие объекты (изображение размером 20 МБ), и сборщик мусора не освобождал память, связанную с этими объектами. В результате возникала утечка памяти, которая могла привести к значительным проблемам с производительностью.

В приведенном выше примере мы успешно удаляем указатели p2, p3 и p4 из s[2:5], сдвигая один элемент влево. Но p3 и p4 все еще присутствуют в базовом массиве, за пределами новой длины s. Сборщик мусора их не удалит. Менее очевидно, что p5 не является одним из удаленных элементов, но его память все равно может утечь из-за указателя p5, хранящегося в серой части массива.

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

Поэтому у нас было два варианта:

  • Либо сохраните эффективную реализацию Delete. Пусть пользователи сами устанавливают устаревшие указатели в nil, если они хотят быть уверены, что значения, на которые они указывают, могут быть освобождены.
  • Или измените Delete так, чтобы устаревшие элементы всегда были равны нулю. Это дополнительная работа, делающая Delete чуть менее эффективным. Обнуление указателей (установка их в nil) позволяет собирать мусор, когда объекты становятся недоступными.

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

Исправление

Ключевым моментом является то, что “установка устаревших указателей в nil” не так проста, как кажется. На самом деле, эта задача настолько подвержена ошибкам, что мы не должны перекладывать бремя ее написания на пользователя. Из прагматизма мы решили модифицировать реализацию пяти функций Compact, CompactFunc, Delete, DeleteFunc, Replace, чтобы “очистить хвост”. Приятным побочным эффектом стало снижение когнитивной нагрузки, и теперь пользователям не нужно беспокоиться об этих утечках памяти.

В Go 1.22 вот как выглядит память после вызова Delete:

This is an image

Код, измененный в этих пяти функциях, использует новую встроенную функцию clear (Go 1.21) для установки устаревших элементов в нулевое значение типа элемента s:

This is an image

Нулевое значение E равно nil, если E является типом указателя, слайса, мапы, канала или интерфейса.

Тесты не проходят

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

Если вы проигнорируете возвращаемое значение Delete:

slices.Delete(s, 2, 3)  // !! INCORRECT !!

то вы можете ошибочно предположить, что s не содержит ни одного указателя nil. Пример на Go Playground.

Если вы проигнорируете возвращаемое значение Compact:

slices.Sort(s) // correct
slices.Compact(s) // !! INCORRECT !!

то можно ошибочно предположить, что s правильно отсортировано и сжато. Пример.

Если вы присвоите возвращаемое значение Delete другой переменной, а сами продолжите использовать исходный слайс:

u := slices.Delete(s, 2, 3)  // !! INCORRECT, if you keep using s !!

то вы можете ошибочно предположить, что s не содержит ни одного указателя nil. Пример.

Заключение

API пакета slices - это хорошее улучшение по сравнению с традиционным до-дженерик синтаксисом удаления или вставки элементов.

Мы рекомендуем разработчикам использовать новые функции, избегая при этом перечисленные выше подводные камни(“gotchas”).

Благодаря последним изменениям в реализации, определенный класс утечек памяти автоматически предотвращается, без каких-либо изменений в API и без дополнительной работы для разработчиков.


Источники


Комментарии в Telegram-группе!