Pull to refresh

Нарезаем массивы правильно в Go

Reading time9 min
Views17K

Второй очерк из цикла приключений в мире сусликов.

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

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

Как писал ранее, я всю жизнь занимаюсь разработкой программного обеспечения, в основном в сфере WEB, успел познакомиться с многими языками программирования и поработать в разных крупных компаниях. Сейчас руковожу разработкой в компании NUT.Tech, мы там делаем классные и интересные вещи. В данный момент в основном разработка в отделе построена вокруг Go, поэтому о нём я и решил рассказывать.

Статьи серии:

  1. Интерфейсы в Go - как красиво выстрелить себе в ногу

  2. Нарезаем массивы правильно в Go

  3. ...

Расскажу я сегодня об одной из базовых структур языка, некоторые особенности которой при первом знакомстве вгоняют в ступор. Речь пойдёт о срезах и о том, какие интересные “фичи” нам приносит их внутреннее устройство в языке. Но начнем мы издалека - с массивов.

Массивы

Для начала посмотрим, что язык 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) // []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, особенно с вещами, которые в нём происходят автоматически. Старайтесь использовать статические анализаторы, они иногда здорово могут помочь с обнаружением ошибок, в том числе при не оптимальном создании слайсов. О других интересных ошибках я расскажу в следующих сериях.

Tags:
Hubs:
Total votes 18: ↑14 and ↓4+10
Comments14

Articles