О массивах и слайсах в Go
Массивы в Go являлись для меня одной из сложных тем, так как я не понимал как они работают. В данной статье рассмотрим как же именно работают слайсы и массивы в Go, а также как именно работает append
и copy
.
Массивы
Массивы — коллекция элементов одного типа. Длина массива не может изменяться. Вот как мы можем создать массив в Go:
arr := [4]int{3,2,5,4}
Если мы создадим два массива в Go с разными длинами, то два массива будут иметь разные типы, так как длина массива в Go, входит в его тип:
a := [3]int{}
b := [2]int{}
// (a) [2]int и (b) [3]int - разные типы
Более того, если нам лень писать длину массива, то мы можем сказать компилятору, чтобы он сам подсчитал длину:
a := [...]int{1, 2, 3} // [3]int
Передача по значению
Переменная, которую мы инициализировали со значением массива, содержит именно значения массива, а не ссылку на первый элемент массива (как это сделано в C).
Именно поэтому массив в Go является примитивным типом данных, он может копироваться при передаче в другую переменную. По умолчанию в Go все значения копируются, а не передаются с помощью ссылки. Это значит, что если мы передадим наш массив в функцию, то Go скопирует данный массив и в функции будет находиться уже совершенно другой массив (вернее точная копия исходного массива).
Внизу мы рассмотрим пример, где мы скопируем массив, а затем посмотрим на адрес, по которому хранится значение:
package main
import "fmt"
func main() {
var initArray = [...]int{1, 2, 3}
var copyArray = initArray
fmt.Printf("Address of initArray: %p\n", &initArray)
fmt.Printf("Address of copyArray: %p\n", ©Array)
}
/*
Output:
Address of initArray: 0xc00001a018
Address of copyArray: 0xc00001a030
*/
Слайсы
Слайсы в Go более гибкие, они позволяют изменять свою длину. По сути слайсы являются надмножеством массивов. Слайсы создают нам массив, которым мы можем пользоваться как обычным массивом и при надобности расширяют его.
Слайсы можно создать двумя способами:
// С помощью make
var foo []byte
s = make([]byte, 5, 5)
// С помощью shorthand syntax
bar := []byte{}
Способ с make
Способ с make
является более интересным, так как дает нам возможность задать тип, длину и вместимость.
С типом я думаю никаких проблем быть не должно. Тип слайса формируется в виде []тип
.
С длинной тоже ничего интересного. В зависимости от введенного количества — массив заполнится нулевыми значениями, например:
package main
import "fmt"
func main() {
var foo = make([]byte, 5)
var bar = make([]int, 10)
var fee = make([]string, 2)
fmt.Println(foo, bar, fee)
}
/*
Output:
[0 0 0 0 0] [0 0 0 0 0 0 0 0 0 0] [ ]
*/
Последний параметр — вместимость играет важную роль в производительности программы, а также является интересным. По сути он говорит о том сколько памяти нужно выделить заранее под наш массив, чтобы при расширении нам не пришлось искать новый участок памяти.
Например, если мы создадим массив с вместимостью в 10 элементов, наполним его 5-ю элементами, а потом добавим один — адрес массива не изменится:
package main
import "fmt"
func main() {
var foo = make([]int, 5, 10)
fmt.Printf("Address of foo array [before append]: %p\n", &foo)
foo = append(foo, 222)
fmt.Printf("Address of foo array [after append]: %p\n", &foo)
}
/*
Output:
Address of foo array [before append]: 0xc0000aa018
Address of foo array [after append]: 0xc0000aa018
*/
К слову, если мы явно не задали вместимость слайса (то есть использовали конструкцию
make([]int, 5)
), то вместимость будет равна длине массива (в данном случае — 5).
Если же мы укажем вместимость массива меньше, чем его длину, то код и вовсе нескомпилируется:
package main
import "fmt"
func main() {
var foo = make([]int, 5, 4)
fmt.Printf("Capacity of the array: ", cap(foo))
}
/*
./prog.go:6:24: invalid argument: length and capacity swapped
*/
Что будет если мы переполним вместимость?
Если же мы переполним вместимость слайса, то вместимость умножится на 2:
package main
import "fmt"
func main() {
var foo = make([]int, 10, 10) // Изначальная вместимость - 10
foo = append(foo, 2) // Добавляем элемент
fmt.Println("Length of the array:", len(foo))
fmt.Println("Capacity of the array:", cap(foo))
}
/*
Output:
Length of the array: 11
Capacity of the array: 20
*/
При этом в памяти произойдет следующее:
Go понимает что нам не хватает памяти и посмотрит есть ли после текущего сегмента памяти еще столько же ячеек;
Если ячейки есть, он не будет передвигать массив и просто зарезервирует больше памяти;
Если ячеек нет, то он скопирует всю информацию из уже использующегося сегмента и найдет вдвое больше свободных ячеек, после нажождения он перенесет туда все данные и отдаст нам адрес сегмента;
Shorthand-syntax
С короткой версией объявления слайса все проще:
package main
import "fmt"
func main() {
foo := []int{1, 2, 3}
fmt.Println("Length of the array:", len(foo))
fmt.Println("Capacity of the array:", cap(foo))
}
/*
Output:
Length of the array: 3
Capacity of the array: 3
*/
В примере вверху Go создаст массив (под капотом) с длиной в три ячейки и такой же вместимостью.
Срезы на слайсах
Срезом на слайсе является дочерний слайс, который ссылется только на часть слайса:
package main
import "fmt"
func main() {
name := []string{"D", "a", "n", "i", "i", "l"}
firstThreeLetters := name[:3]
fmt.Println(firstThreeLetters)
}
/*
Output:
[D a n]
*/
Не смотря на то, что слайс и срез — понятия взаимозаменяемые (а если быть точнее, то срез — перевод от англ. slice), мы будем называть слайсами все новосозданные слайсы с помощью make () или shorthand-синтаксиса, а срезами будем называть слайсы проделанные над уже существующим массивом.
Мы также можем делать срезы на массивах, таким образом мы можем делать массивы динамически расширяемыми:
package main
import "fmt"
func main() {
nameArray := [6]string{"D", "a", "n", "i", "i", "l"}
nameSlice := nameArray[:]
nameSlice = append(nameSlice, "!")
fmt.Println(nameSlice)
}
/*
Output:
[D a n i i l !]
*/
Слайс под капотом
Слайс под капотом является структурой, которая содержит ссылку на исходный массив, длину и вместимость:
struct {
array *[]T
length int
capacity int
}
Когда мы создаем новый слайс или срезаем массив, то ссылка массива присвивается полю array
, с помощью данного указателя слайс сможет обращаться к массиву под капотом. length
и capacity
хранят длину и вместимость, соответственно.
Поскольку слайс ссылается на часть массива, мы можем срезать часть массива. Срез не копирует элементы массива, он просто ссылается на них. Таким образом при изменении среза, изменится и массив, с которого мы брали срез:
package main
import "fmt"
func main() {
nameArray := [6]string{"D", "a", "n", "i", "i", "l"}
nameSlice := nameArray[:3]
nameSlice[len(nameSlice) - 1] = "m"
fmt.Println(nameSlice) // [D a m]
fmt.Println(nameArray) // [D a m i i l]
}
Мы также можем сделать так, чтобы срез занял всю длину исходного массива. Так как слайс хранит вместимость исходного массива — мы можем сделать срез снова и указать параметр cap(nameArray)
:
package main
import "fmt"
func main() {
nameArray := [6]string{"D", "a", "n", "i", "i", "l"}
nameSlice := nameArray[:3]
nameSlice[len(nameSlice)-1] = "m"
fmt.Println(nameSlice) // [D a m]
// Делаем новый срез
nameSlice = nameSlice[0:cap(nameSlice)]
fmt.Println(nameSlice) // [D a m i i l]
}
У вас может возникнуть вопрос: почему мы не срезали
cap(nameSlice) - 1
, ибо мы указали в конце несуществующий индекс (на один больше, нежели существует в массиве). Все дело в том, что последний элемент при срезе не включается в срез.То есть, первый индекс идет включительно в срез, а последний — не включительно.
Копирование
Как уже можно понять, при срезе с массива или слайса мы не создаем новый слайс. Также, если мы присвоим одной переменной значение слайса другой переменной — они обе будут указывать на один массив:
package main
import (
"fmt"
)
func main() {
nameSlice := []string{"D", "a", "n", "i", "i", "l"}
secondNameSlice := nameSlice
secondNameSlice[0] = "T"
fmt.Println(nameSlice, secondNameSlice) // [T a n i i l] [T a n i i l]
}
Мы можем избежать такого поведения с помощью копирования. Для того чтобы скопировать слайс (создать независимую копию) — нам достаточно использовать функцию copy
:
package main
import (
"fmt"
)
func main() {
nameSlice := []string{"D", "a", "n", "i", "i", "l"}
secondNameSlice := make([]string, len(nameSlice), cap(nameSlice))
copy(secondNameSlice, nameSlice)
secondNameSlice[0] = "T"
fmt.Println(nameSlice, secondNameSlice) // [D a n i i l] [T a n i i l]
}
сopy и append под капотом
Мы можем заметить два различия: при использовании функции append
— мы переприсваивали значение переменной:
foo := []int {}
foo = append(foo, 1)
В случае с copy
мы просто передаем саму переменную (не ссылку, а именно переменную!):
foo := []int {1, 2}
bar := []int {}
copy(bar, foo)
Вот как работает копирование под капотом:
func copy(to []T, from []T) {
for i := range from {
to[i] = from[i]
}
}
Разработчики Go решили не добавлять часть с инициализацией нового слайса внутрь copy
.
В случае с дополнением все немного иначе. Тут разработчики Go посчитали, что функция сама должна решать нужно ли инициализировать новый слайс, или можно дополнить данные в уже существующий слайс:
func append(slice []T, data ...T) []T {
initialLength := len(slice)
finalLength := m + len(data)
if finalLength > cap(slice) { // if necessary, reallocate
// allocate double what's needed, for future growth.
newSlice := make([]byte, (n+1)*2)
copy(newSlice, slice)
slice = newSlice
}
slice = slice[0:finalLength]
copy(slice[initialLength:finalLength], data)
return slice
}