golang

О массивах и слайсах в Go

  • вторник, 6 июня 2023 г. в 00:00:17
https://habr.com/ru/articles/739754/

Массивы в 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", &copyArray)
}

/*
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
*/

При этом в памяти произойдет следующее:

  1. Go понимает что нам не хватает памяти и посмотрит есть ли после текущего сегмента памяти еще столько же ячеек;

  2. Если ячейки есть, он не будет передвигать массив и просто зарезервирует больше памяти;

  3. Если ячеек нет, то он скопирует всю информацию из уже использующегося сегмента и найдет вдвое больше свободных ячеек, после нажождения он перенесет туда все данные и отдаст нам адрес сегмента;

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
}

Вместо заключения 🌚

Если вам понравилась данная статья - то вы всегда можете перейти в мой блог, там больше схожей информации о веб-разработке.

Если у вас остались вопросы - не стесняйтесь задавать их в комментариях. Хорошего времяпрепровождения! 💁🏻‍♂