Второй очерк из цикла приключений в мире сусликов.
Это вторая статья серии небольших рассказов о необычных подводных камнях, которые можно встретить в начале разработки на Go. Напоминаю, что в статьях есть примеры кода, будьте с ними аккуратнее - не все из них будут компилироваться и работать, читайте внимательно комментарии, везде указано, на какой строке происходит ошибка. Также в блоках кода везде табуляция заменена на пробелы - это сделано намеренно, чтобы статьи выглядели у всех одинаково.
Начинать рассказывать снова буду издалека, с самых основ, но это необходимо для полного понимания. В конце же рассказа вас, как и прежде, будет ждать самое интересное, но всё равно стоит читать его с самого начала.
Как писал ранее, я всю жизнь занимаюсь разработкой программного обеспечения, в основном в сфере WEB, успел познакомиться с многими языками программирования и поработать в разных крупных компаниях. Сейчас руковожу разработкой в компании NUT.Tech, мы там делаем классные и интересные вещи. В данный момент в основном разработка в отделе построена вокруг Go, поэтому о нём я и решил рассказывать.
Статьи серии:
Нарезаем массивы правильно в Go
...
Расскажу я сегодня об одной из базовых структур языка, некоторые особенности которой при первом знакомстве вгоняют в ступор. Речь пойдёт о срезах и о том, какие интересные “фичи” нам приносит их внутреннее устройство в языке. Но начнем мы издалека - с массивов.
Массивы
Для начала посмотрим, что язык Go даёт нам для работы со структурами данных, известные в других языках как списки, массивы, векторы и тому подобное.
Под массивом в Go обычно понимается структура данных фиксированного размера, хранящая элементы одного типа. Фиксированный размер означает то, что после создания в массив нельзя будет добавить новые элементы и количество элементов уже не может стать меньше. Размер, он же длина, как и тип элементов, должны быть известны на этапе компиляции, поэтому они задаются сразу в коде.
a := [3]int{1, 2, 3}
Что же у нас тут происходит. А происходит примерно следующее: инициализируется переменная a
и в неё помещаются элементы типа int
с длиной три элемента (записана в квадратных скобках). Также есть более удобная форма подобной записи.
a := [...]int{1, 2, 3}
Здесь происходит всё ровно то же самое, но три точки в скобках позволяют нам не записывать длину самим, вместо этого она будет автоматически выведена компилятором из количества элементов.
У массивов есть одна важная особенность: переменные, имеющие в качестве типа массивы разной длины не взаимозаменяемы, то есть с точки зрения Go, это переменные совершенно разных типов. Например:
// объявляем переменную с типом массива из двух элементов
var a [2]int
b := [1]int{1}
a = b
// ура! ошибка компиляции
// cannot use b (type [1]int) as type [2]int in assignment
c := [2]int{1, 2}
a = c
// а так ошибки нет, всё компилируется и работает
Посмотрим на несколько простых примеров работы с массивами:
// создание
a := [3]int{1, 2, 3}
// получение элемента
fmt.Println(a[0]) // 1
fmt.Println(a[4])
// ошибка времени компиляции
// invalid array index 4 (out of bounds for 3-element array)
// но есть нюанс, если задавать элемент через переменную -
// всё упадёт во время выполнения
i := 4
fmt.Println(a[i])
// panic: runtime error: index out of range [4] with length 3
// изменение элемента
a[0] = 42
fmt.Printf("%#v\n", a) // [3]int{42, 2, 3}
// одной интересной особенностью является то,
// что у массива без заданных значений
// всё равно можно получить элемент по индексу
var b [3]int
fmt.Println(b[1]) // 0
// всё дело в том, что Go сам инициализирует
// все элементы значениями по умолчанию
fmt.Printf("%#v\n", b) // [3]int{0, 0, 0}
В оперативной памяти же массивы выглядят просто как последовательность значений одного размера. Они занимают фиксированный объем и имеют постоянное расположение в памяти на всё время жизни, пока за ними не придет сборщик мусора.
И чтобы быть совсем уверенными в том, что разобрались в массивах, заглянем в исходный код Go, который к нашему счастью написан на Go.
// src/go/types/array.go
type Array struct {
len int64
elem Type
}
Достаточно просто выглядит структура, всего два поля: длина - поле len
(length) и тип элементов - поле elem
(element). Тут всё кажется понятным, так что время перейти к срезам.
Срезы
Эта структура данных обычно чуть менее привычна, чем массивы, тем не менее в Go преимущественно встретить можно именно её. Срезы очень похожи на массивы с точки зрения использования. Главным отличием от массивов является то, что срез не имеет строго фиксированного размера и количество элементов в нём может меняться в течение жизни программы. То есть, простыми словами, в него можно добавить и удалить из него элементы.
На этот раз сразу начнем с исходного кода суслика.
// src/runtime/slice.go
type slice struct {
array unsafe.Pointer
len int
cap int
}
Тут всё немного сложнее: появилось поле cap
(capacity), оно отражает размер (емкость) коллекции и может отличаться от len
, которое тут фактически просто количество элементов. Ну и вместо поля elem
, у нас поле array
, которое хранит ссылку на определённый элемент исходного массива в памяти, с этого элемента и начинается срез.
Небольшое отступление: в русскоязычных источниках чаще можно прочитать перевод поля cap
, как "объем" или "емкость", встречал ещё вариант "вместимость", всё это ближе к английскому, чем размер, поэтому далее в тексте я буду употреблять термин "ёмкость".
Длине среза и его ёмкости нужно уделять особое внимание. С одной стороны, Go самостоятельно полностью управляет значениями в этих полях и делает все процедуры по перераспределению занимаемой памяти, с другой стороны, иногда это ведет к интересным и неожиданным последствиям.
Для начала посмотрим на простые примеры работы со срезами:
// в отличии от массива срез нужно инициализировать без указания размера
a := []int{1, 2, 3}
// во время инициализации среза сначала инициализируется массив,
// там сохраняются переданные значения и сразу создаётся срез,
// указывающий на этот массив и хранящий длину и емкость
// аналогично массивам можно получать и изменять элементы
fmt.Println(a[0]) // 1
a[0] = 42
fmt.Printf("%#v\n", a) // [3]int{42, 2, 3}
// при получении элемента за границами среза
// произойдет ошибка времени выполнения
fmt.Println(a[4])
// panic: runtime error: index out of range [4] with length 3
// также срез можно создать на основе существующего массива
m := [3]string{"Шито", "Крыто", "Корыто"}
b := m[0:2]
fmt.Printf("%#v\n", b) // []string{"Шито", "Крыто"}
Тут есть одна особенность, которая не бросается сразу в глаза и происходит из-за того, что срез - это ссылка на исходный массив, значит, чтобы существовать этому срезу, и массив должен существовать. Поэтому, если мы определили массив (или срез) из большого количества элементов, взяли небольшой новый срез от него и дальше используем только значения нового среза, значит большое количество элементов из исходного массива (среза) будут жить в памяти, несмотря на то, что они нам больше не нужны. Таким образом, мы получаем некоторую утечку памяти, за этим нужно аккуратно следить.
С основами вроде бы разобрались, теперь посмотрим внимательнее на то, что же такое ёмкость и длина среза.
Нужно не забывать, что практически срез - это просто ссылка на массив, и каждый элемент среза - это ссылки на элементы связанного массива. По этой причине нужно быть аккуратным, когда мы в функцию передаем срез. В Go всё можно передать по значению, и срез не исключение, но значением среза является ссылка, поэтому при такой передаче функция будет менять значения исходного массива, вот так вот запутанно всё, но привыкнуть можно. Посмотрим на простой пример:
func main() {
m := [...]int{1, 2, 3} // создаём массив
s := m[:] // таким образом можно создать срез, содержащий весь массив
// теперь нам понадобится функция, которая будет менять нулевой
// элемент переданного среза
func(l []int) {
l[0] = 42
}(s)
// выводим исходный массив
fmt.Printf("%#v\n", m) // [3]int{42, 2, 3}
// первый элемент поменялся в исходном массиве,
// несмотря на то, что срез в функцию был передан "по значению"
}
Благодаря этой хитрости работает пакет сортировки, который принимает на вход срез и ничего не возвращает, сортируя значения сразу в переданном срезе, без лишних выделений памяти.
Теперь пора разобраться до конца с длиной и размером. Для получения значения этих параметров есть одноименные функции len()
и cap()
.
func main() {
a := []int{1, 2, 3}
fmt.Println(cap(a)) // 3
fmt.Println(len(a)) // 3
// ёмкость и длина у нас тут равны, но что если удалить один элемент
a = append(a[:1], a[2:]...)
fmt.Printf("%#v\n", a) // []int{1, 3}
fmt.Println(cap(a)) // 3
fmt.Println(len(a)) // 2
// ёмкость массива осталась неизменной, но длина его стала меньше
// теперь добавим несколько элементов
a = append(a, 10, 11, 12, 13, 14, 15)
fmt.Println(cap(a)) // 8
fmt.Println(len(a)) // 8
// ёмкость и длина изменились автоматически, всё хорошо
}
И тут мы наконец подошли к самому интересному вопросу: “Что происходит, когда ёмкость среза увеличивается?”
А происходит следующее: создается в памяти новый массив, туда копируются все значения из среза, к ним добавляются новые значения, которые и привели к увеличению ёмкости среза, в срезе в поле array
устанавливается ссылка на новый массив. Это можно увидеть воспользовавшись пакетом reflect:
func main() {
a := []string{"Шито"}
fmt.Println(len(a), cap(a)) // 1 1
fmt.Printf("%#v\n", reflect.ValueOf(a).Pointer()) // 0xc00010c220
a = append(a, "Крыто")
fmt.Println(len(a), cap(a)) // 2 2
fmt.Printf("%#v\n", reflect.ValueOf(a).Pointer()) // 0xc000130000
a = append(a, "Корыто")
fmt.Println(len(a), cap(a)) // 3 4 (внезапно)
fmt.Printf("%#v\n", reflect.ValueOf(a).Pointer()) // 0xc00012e040
// на самом деле ссылки будут разными при каждом запуске,
// но тут главное можно увидеть, что после каждого добавления элемента
// и, как следствие, увеличения емкости массива,
// срез начинает ссылаться на новую область памяти
// также в последнем примере мы добавили один элемент,
// а ёмкость увеличился на два, это связано с оптимизацией -
// постоянно перемещать элементы в новую область памяти дорого,
// поэтому Go пытается нам помочь и заранее увеличить размер массива
// если мы теперь добавим четвертый элемент, ссылка уже не поменяется,
// потому что емкость останется прежней
a = append(a, "_")
fmt.Println(len(a), cap(a)) // 4 4
fmt.Printf("%#v\n", reflect.ValueOf(a).Pointer()) // 0xc00012e040
}
Операция эта не самая дешёвая: нужно и память новую выделить и значения туда скопировать. Поэтому в мире Go стараются такого избегать, несмотря на оптимизации, которые пытается делать язык. А избежать этого часто достаточно просто: нужно создать срез сразу с нужной ёмкостью, для этого можно использовать встроенную функцию make()
. Для создания среза первым аргументом передается тип среза, вторым длина и третьим ёмкость. Посмотрим на примере:
a := make([]int, 2, 3)
// тут мы создаём срез длинной два и ёмкостью три
// так как мы задаем длину, в срезе уже будут элементы
// со значениями по умолчанию, это просто для примера,
// вторым аргументом можно было просто передать 0
fmt.Printf("%#v\n", a) // []int{0, 0}
fmt.Println(len(a), cap(a)) // 2 3
Таким образом, в случаях с заранее известным числом элементов можно уменьшить количество выполняемых операций. Для этого, кстати, есть неплохой статический анализатор, который умеет подсказывать места, в которых можно использовать технику предварительного аллоцирования массива с нужной емкостью: https://github.com/alexkohler/prealloc. Там же есть и бенчмарки, показывающие насколько замедляют код лишние увеличения ёмкости.
Но на этом мой рассказ не заканчивается, впереди есть ещё кое-что интересное. Следующий пример является следствием того, что мы рассмотрели выше. Но, тем не менее, лучше один раз увидеть, чем сойти с ума в поисках ошибки.
// создаем некоторый массив и срез от него
a := [...]int{1, 2}
s := a[:]
// как уже делали ранее, меняем первый элемент массива через срез
s[0] = 42
fmt.Printf("%#v\n", a) // []int{42, 2}
// отлично, поменялся первый элемент в исходном массиве
// теперь добавим к срезу одно значение
s = append(s, 3)
// и снова поменяем первый элемент
s[0] = 1
fmt.Printf("%#v\n", a) // []int{42, 2}
// если вы читали внимательно,
// то уже понимаете, почему ничего не изменилось
Итак, рассказываю, при втором присвоении ничего не происходит, потому что функция append
при добавлении элемента создала новый массив и скопировала все элементы среза туда. После этого срез уже никак не связан с первоначальным массивом и через этот срез больше нельзя менять элементы исходного массива.
Для полноты картины приведу еще один пример, на этот раз с сортировкой.
a := [...]int{2, 1, 3}
s := a[:]
// сортируем срез от меньшего к большему
sort.Slice(s, func(i, j int) bool { return s[i] < s[j] })
fmt.Println(a) // [1 2 3]
// отлично, исходный массив отсортирован
// срез следовательно тоже, так как он
// просто ссылается на отсортированный массив
fmt.Println(s) // [1 2 3]
// теперь изменим размер среза и попробуем
// ещё раз отсортировать, но уже от большего
s = append(s, 42)
sort.Slice(s, func(i, j int) bool { return s[i] > s[j] })
fmt.Println(a) // [1 2 3]
// как и ожидалось, исходный массив остался не отсортирован,
// но зато отсортирован срез s
fmt.Println(s) // [42 3 2 1]
Но и функция append
не так проста. В комментариях мне подсказали очень хороший пример, который лёг в основу следующего.
func main() {
// на этот раз создадим срез, а не массив,
// на основе которого дальше создадим новый срез,
// так как в жизни, чаще в основе наших срезов
// будут лежать другие срезы, но в этом примере с массивом
// всё работало бы аналогично
a := []int{1, 2, 3, 4, 5, 6}
// создаём новый срез, от нашего базового среза
s := a[:2]
fmt.Printf("%#v\n", s) // []int{1, 2}
fmt.Println(len(s), cap(s)) // 2 6
// тут нужно обратить внимание, что cap равняется шести,
// всё потому что новый срез создаётся с таким объёмом,
// чтобы уместиться в объём базового,
// при этом быть максимально большим
// например, у среза a[2:3] объём был бы равен четырём,
// так как мы "отрезали" кусок от исходного среза,
// начиная со второго элемента (цифры 3), и до конца изначально
// выделенной памяти осталось ещё четыре ячейки памяти
// дальше делаем несколько добавлений в конец нашего нового среза
s = append(s, 800)
s = append(s, 900)
// и смотрим что же получилось
fmt.Printf("%#v\n", s) // []int{1, 2, 800, 900}
// выглядит всё хорошо: два элемента добавились в срез
fmt.Println(len(s), cap(s)) // 4 6
// тут тоже всё понятно: к отрезанным двум элементам
// добавили ещё два элемента и длина стала четыре
// но посмотрим что стало с исходным нашим срезом
fmt.Printf("%#v\n", a) // []int{1, 2, 800, 900, 5, 6}
// append не просто добавил элементы к новому срезу,
// он их записал поверх элементов исходного среза
}
Заключение
В процессе написания статьи мне задали вопрос: “А что же в итоге использовать? Срезы или массивы? И зачем нужны массивы, если можно просто всегда использовать срезы?”. Ответ следующий: всегда используй массивы, где это возможно и не ведет к усложнению кода. К сожалению, в реальном коде мест, где получается использовать массивы, не так много. Тем не менее, массив остаётся более строгим типом данных, который заставляет программиста правильнее работать с оперативной памятью, что иногда может защитить от ненужного замедления некоторых участков кода.
Как и многое в Go, срезы на первый взгляд кажутся несложными, но имеют интересные подводные камни. Для прочтения очень рекомендую эту статью Go Slices: usage and internals из официального блога Go.
Будьте аккуратны с Go, особенно с вещами, которые в нём происходят автоматически. Старайтесь использовать статические анализаторы, они иногда здорово могут помочь с обнаружением ошибок, в том числе при не оптимальном создании слайсов. О других интересных ошибках я расскажу в следующих сериях.
Комментарии (12)
QeqReh
28.12.2021 14:52+1Вопрос на засыпку. Так сказать закрепим материал)
Что выведет данный код?
a := [...]int{1,2,3,4,5,6} b := a[2:5] b[1] = 7 b = append(b, 8) b[1] = 9 fmt.Printf(`%v`, a)
lowitea Автор
28.12.2021 16:05Оба раза
b[1]
изменит значение в массивеa
, из-за того чтоlen
не выходит за границыcap
. Но тут на самом деле даже интересней, потому чтоappend
перезапишет последний элемент массиваa
. Это связано с тем, что срезb
создаётся со значениемcap
умещающимся в выделенную память для массиваa
. То есть другим языком если например взять срезa[:2]
его значениемcap
в данном примере будет6
, несмотря на то, чтоlen
будет2
. И всеappend
будут перезаписывать элементы исходного массива, покаlen
не выйдет за границыcap
. Спасибо большое за этот пример, если не возражаете добавлю его в статью с упоминанием вас.QeqReh
29.12.2021 08:23Все верно. Конечно не возвражаю.
Два действия с
b[1]
являются отвлекающим маневром)Вчера так же показал своим коллегам, им понравилось и немного попались на уловку с
append
. "Немного", так как были сомнения)
sundmoon
28.12.2021 17:16+4Можно поинтересоваться, зачем в языке с GC эта машинерия?
Где почитать про неэффективность коллекций например в JDK и .NET, толкнувшую голанг выставить наружу именно вот эти кишки?
lowitea Автор
29.12.2021 12:32GC тут в целом не причём, в первую очередь думаю это связано с попытками сделать более эффективное использование/переиспользование памяти.
Я тоже пытался найти что-то на тему мотивации. Но подозреваю, что такое решение было принято внутри команды.
Bergtagen
29.12.2021 18:54И зачем нужны массивы, если можно просто всегда использовать срезы?
Может массивы размещаются в стеке? Это было бы логично, раз мы знаем их размеры на этапе компиляции, то почему бы и нет, но подтверждений этому я не нашёл. Везде пишут: "Ну мы сделали, а там смотрите сами, если удобно, то вот"
lowitea Автор
29.12.2021 20:40+1С размещением там не так всё просто. Массивы могут размещаться на стеке, а срезы только на стеке и живут (но в срезах нет значений, они просто ссылки на массивы). Но при этом массивы могут убегать в кучу, если в них слишком много элементов.
Где-то в спецификации Go найти про это я не смог. Поисследовать поведение можно командой
go build -gcflags="-m"
, она выводит информацию о том, кто уходит в кучу со стека.Немного подробнее это объясняется в этой статье на медиуме.
Bergtagen
30.12.2021 12:35Очень интересно. Получается, что массивы до 64КБ живут в стеке и передаются по ссылке, а более крупные живут в куче и передаются по значению, при том, что это массивы, не слайсы. Тёмные закоулки Гошки)
kilgur
30.12.2021 07:30+1Вот, вы пишете:
// создаем некоторый массив и срез от него a := []int{1, 2} s := a[:]
Но ведь
a
-- это не массив, а срез. Фактически, тут у вас 2 среза, ссылающихся на один массив. Вы или многоточие в квадратные скобки добавьте, или тоже срезом назовите.
godzie
Это не совсем так, расположение в памяти может меняться потому что есть механизм stack grow. В случае алокации в куче на практике фиксированно (потому что гц не мувит в настоящий момент), но GO не дает гарантий что адрес не может поменяться.
lowitea Автор
Спасибо за уточнение, всё действительно так.