Массивы в 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
}

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

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

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

Комментарии (12)


  1. alex103
    05.06.2023 03:18
    +1

    "Все мозги разбил на части, все извилины заплёл, И канатчиковы власти колят нам второй укол "

    Чем отличается "мы переприсваивали значение переменной " от "мы просто передаем саму переменную (не ссылку, а именно переменную!) " ?

    Надо как то этот важный и тонкий момент попонятнее описывать. Что б вот прям различие очевидно было!

    (Можно даже в картинках..)

    :-)


  1. NeoCode
    05.06.2023 03:18
    +3

    Срез слайса это конечно круто (интересно как на английском:) ).

    А вообще конечно лихо они объединили владеющую и невладеющую структуры данных, это наверное стало возможно из-за того что язык со сборкой мусора?


    1. miga
      05.06.2023 03:18

      В голанге нет концепции владения, так что не надо пытаться приложить концепции из крестов - это только сделает вам больнее :)


  1. gnomeby
    05.06.2023 03:18

    Интересная тема с удвоением размера. Получается на очень толстых и плавно растущих массивах можно получить почти половинную потерю памяти под резерв.

    И тогда надо писать свои кастомные append`ы, которые это дело нивелируют.


    1. Elias506
      05.06.2023 03:18

      Такая особенность у аппендров есть, да. Тут банальный вопрос: известна ли вам заранее длина слайса? Если да, то будем выделять памяти столько, сколько нужно.

      Если же нет, то вам нужно выбрать стратегию, по которой память под массив будет переаллоцироваться. Разработчики Go выбрали вариант увеличения памяти в 2 раза (редко не в 2). В большинстве случаев такая стратегия эффективна.
      Аллоцировать память для каждого нового элемента большого слайса выглядит сомнительным вариантом, поэтому не очень понятно, о каком "кастомном append'е" идет речь


      1. gnomeby
        05.06.2023 03:18

        Ну например есть у меня много массивов элементов, но всегда в каждом будет не больше 1.2 млн штук. И я это выяснил на профилировании. При этом далеко не все из них и до миллиона доходят. Те что выйдут за миллион, будут жрать на 2 млн элементов и большая часть памяти будет в простое.


        1. ErgoZru
          05.06.2023 03:18
          +1

          Нет, слайс увеличивается не всегда в 2 раза. Автор забыл упомянуть изменения в go 1.14 и 1.20 (и в 1.21 вроде как планируются еще изменения по оптимизации).

          Формула роста такова:

          starting cap    growth factor 
          256             2.0 
          512             1.63 
          1024            1.44 
          2048            1.35 
          4096            1.30

          Следить за ней можно вот тут в исходниках: go/src/runtime/slice.go

          https://go.googlesource.com/go/+/2dda92ff6f9f07eeb110ecbf0fc2d7a0ddd27f9d вот тут изменения


  1. Dolios
    05.06.2023 03:18

    Я правильно понимаю, что, при передаче слайса в функцию в качестве параметра, нет необходимости передавать его по ссылке? Можно передать его по значению и, в итоге, будет создан новый слайс, который, однако, ссылается на массив исходного слайса. Т.е., в таком случае, мы не будем копировать значения и расходовать на это память?


    1. ErgoZru
      05.06.2023 03:18
      +1

      на эту тему тут на хабре есть хорошая статейка - https://habr.com/ru/articles/525940/

      если коротко - зависит от ситуации и от того что будет делать функция в которую передается этот слайс.


      1. Dolios
        05.06.2023 03:18

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


  1. bullgare
    05.06.2023 03:18

    Да, append - штука забавная.

    package main
    
    import "fmt"
    
    func main() {
    	t1 := make([]int, 0, 5)
    	t1 = append(t1, 1)
    	_ = append(t1, 2)
    	t2 := append(t1, 3)
    	fmt.Println(t1, t2)
    
    	t3 := t2
    	t3[0] = -1
    	fmt.Println(t1, t2, t3)
    
    	t3 = append(t3, 4)
    	fmt.Println(t1, t2, t3)
    }

    https://go.dev/play/p/cV9tCbSY5cF


  1. itmind
    05.06.2023 03:18

    Можете пояснить следующий момент?:

    Переменная, которую мы инициализировали со значением массива, содержит именно значения массива, а не ссылку на первый элемент массива (как это сделано в C).

    Массив находится в памяти начиная с какого-то адреса. Как может переменная содержать значение массива, а не ссылку на массив? Значения могут быть только в регистрах процессора. Если значение переменной хранится в стеке, то мы обращаемся к ней через указатель на стек+смещение. Если значение хранится в "куче", то обращаемся просто через указатель.