Давайте поговорим о Go Slices

Давайте поговорим о Go Slices

13 июня 2023 г.

Сегодня мы познакомимся с фрагментами, одной из основных структур данных в Go. Это гибкая и мощная структура, которая помогает разработчикам работать с коллекциями элементов. Однако охватить эту тему можно только с базовым пониманием массивов, потому что срез по сути является абстракцией над массивами в Go.

:::информация Отказ от ответственности. Эта статья была задумана не только как попытка структурировать и поделиться информацией, но и как попытка попрактиковаться в английском языке. Во время работы над статьей я использовал официальную документацию и инструменты искусственного интеллекта, такие как Grammarly, чтобы исправить свои ошибки. Итак, весь текст прошел через этот инструмент и был откорректирован согласно рекомендациям.

:::

Массивы

Массив — это структура данных фиксированного размера, в которой может храниться набор элементов одного типа. Например, тип [4]int представляет собой массив из четырех целых чисел. А размер массива определен и не может быть изменен.

 var a [4]int // an array of 4 integers with four zero-value items 
 a[1] = 5 // [0 5 0 0] assigning 5 to the second element 

Кроме того, литерал массива может быть указан следующим образом:

ar := [4]int{1, 2} 
// [1 2 0 0] array of 4 elements - 2 predefined and 2 zero-value elements    

Нам не нужно явно инициализировать все элементы; он всегда сохраняет нулевое значение для всех неинициализированных элементов. И в представлении [4]int в памяти у нас есть четыре целочисленных значения, расположенных последовательно:

arrays in Go

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

package main

import "fmt"

func main() {
    var a [4]int
    a[1] = 5
    fmt.Printf("before a array: %p, %v n", &a, a)
    // before a array: 0xc0000b2000, [0 5 0 0]
    checkArray(a)
    fmt.Printf("after a array: %p, %v n", &a, a)
    // after a array: 0xc0000b2000, [0 5 0 0]
}

func checkArray(ar [4]int) {
    ar[2] = 4
    fmt.Printf("func array: %p, %v n", &ar, ar)
    // func array: 0xc0000b2060, [0 5 4 0]
}

Как видите, значения массивов в функции main и checkArray совпадают, но указатели (0xc00…) разные. Это означает, что в функции checkArray мы имеем дело с копией исходного массива.

Несмотря на это ограничение, массивы в Go по-прежнему являются мощным инструментом для работы с коллекциями данных, особенно когда размер коллекции известен заранее и не может быть изменен, например, UUID — acde070d-8c4c-4f0d -9d8a-162843c10333. Однако когда дело доходит до работы с коллекциями, размер которых нужно изменять или управлять ими динамически, Go предоставляет структуру данных срезов как более гибкую альтернативу массивам.

Срезы

Итак, теперь мы имеем общее представление о массивах и можем попытаться понять, что такое слайсы и как с ними работать. Основное преимущество и различие между слайсами и массивами в Go заключается в том, что слайсы динамически изменяют размер и могут увеличиваться в размере при необходимости. И вы можете увидеть это, когда укажете слайс: var nums []int. Это будет набор целых чисел с динамическим размером.

n Литерал среза может быть объявлен аналогично массиву, но без указания размера коллекции:

letters := []string{"a", "b", "c", "d"}

Кроме того, срезы можно объявить с помощью встроенной функции make. make принимает два обязательных параметра: тип и длину, а также один необязательный — емкость.

Вот пример создания фрагмента с помощью функции make:

nums := make([]int, 5, 8) // [0 0 0 0 0]
len(nums) // 5 - len: built-in function shows the length of nums
cap(nums) // 8 - cap: built-in function shows the capacity of nums

Когда вызывается функция make, она выделяет массив и возвращает ссылки на этот массив. Давайте углубимся и посмотрим, как это выглядит под капотом.

В Go срез — это структура данных, состоящая из трех компонентов: указателя на базовый массив, длины и емкости среза. Используя предоставленный вами пример, давайте разберем структуру фрагмента:

slice in Go

Указатель ссылается на первый элемент базового массива, относящегося к срезу. Длина представляет количество элементов в срезе, то есть фактическое количество элементов, используемых в данный момент. Емкость представляет собой максимальное количество элементов, которое может содержать срез, прежде чем потребуется изменить его размер.

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

Итак, теперь мы знаем как минимум три способа объявить пустой фрагмент:

var nums1 []int
nums2 := []int{}
nums3 := make([]int, 0, 4)

А иногда вам нужно проверить, пуст ли слайс. Поскольку нулевое значение среза равно nil, вы можете решить, что сравнение с nil — это хороший способ проверить, пуст ли срез, но здесь нужно быть осторожным. . Для этого лучше использовать встроенную функцию len.

Давайте проверим:

package main

import (
    "fmt"
)

func main() {
    var nums1 []int
    nums2 := []int{}
    nums3 := make([]int, 0, 4)
    fmt.Println("nums1 is nil:", nums1 == nil)
    // nums1 is nil: true
    fmt.Println("nums2 is nil:", nums2 == nil)
    // nums2 is nil: false
    fmt.Println("nums3 is nil:", nums3 == nil)
    // nums3 is nil: false

    fmt.Println("nums1 is empty:", len(nums1) == 0)
    // nums1 is empty: true
    fmt.Println("nums2 is empty:", len(nums2) == 0)
    // nums2 is empty: true
    fmt.Println("nums3 is empty:", len(nums3) == 0)
    // nums3 is empty: true
}

Давайте рассмотрим другой способ объявления среза.

Его также можно сформировать путем «нарезки» существующего среза или массива. Нарезку можно выполнить, указав полуоткрытый диапазон с двумя индексами, разделенными двоеточием. Например, выражение nums[2:5] создает срез, включающий элементы со 2 по 4 из nums (индексы результирующего среза будут от 0 до 2).< /p>

"Slicing" slice in Go

nums1 := make([]int, 5, 8)
nums1[0] = 0
nums1[1] = 1
nums1[2] = 2
nums1[3] = 3
nums1[4] = 4
nums2 := nums1[2:5]
// nums1 - [0 1 2 3 4] len - 5, cap - 8
// nums2 -     [2 3 4] len - 3, cap - 6

Есть еще один вариант создания новой структуры со своим указателем, длиной и емкостью, но базовый массив будет таким же, как и для исходного слайса. Основное отличие состоит в том, что указатель ссылается на другой элемент массива. Зная это, мы можем легко объяснить следующее поведение:

package main

import "fmt"

func main() {
    nums1 := make([]int, 5, 8) // [0 0 0 0 0]
    nums2 := nums1[2:5]        //     [0 0 0]
    nums2[2] = 99

    fmt.Println("nums1: ", nums1) // nums1: [0 0 0 0 99]
    fmt.Println("nums2: ", nums2) // nums2:     [0 0 99]
}

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

Теперь нам нужно поговорить об одной из самых полезных функций слайсов Go, функции append, которая позволяет динамически добавлять элементы в слайс. Функция append — это встроенная функция, которая принимает фрагмент и один или несколько элементов в качестве входных данных и возвращает новый фрагмент с добавленными в конец дополнительными элементами. В этой статье будет подробно рассмотрена функция append, включая то, как она работает, распространенные варианты использования и потенциальные ловушки, которых следует избегать. Он имеет подпись:

func append(s []T, x ...T) []T

где T обозначает тип элемента среза. Давайте проверим, как это работает.

package main

import "fmt"

func main() {
    nums1 := []int{}
    fmt.Println("nums1: ", nums1, "len: ", len(nums1), "cap: ", cap(nums1))
    nums1 = append(nums1, 1)
    fmt.Println("nums1: ", nums1, "len: ", len(nums1), "cap: ", cap(nums1))
    nums1 = append(nums1, 2)
    fmt.Println("nums1: ", nums1, "len: ", len(nums1), "cap: ", cap(nums1))
    nums1 = append(nums1, 3)
    fmt.Println("nums1: ", nums1, "len: ", len(nums1), "cap: ", cap(nums1))
    nums1 = append(nums1, 4, 5) 
    fmt.Println("nums1: ", nums1, "len: ", len(nums1), "cap: ", cap(nums1))
}

// nums1:  []          len:  0 cap:  0
// nums1:  [1]         len:  1 cap:  1
// nums1:  [1 2]       len:  2 cap:  2
// nums1:  [1 2 3]     len:  3 cap:  4
// nums1:  [1 2 3 4 5] len:  5 cap:  8
}

Итак, как видно из приведенного выше примера, длина и емкость увеличиваются всякий раз, когда мы добавляем элемент в срез. По сути, это означает, что в этом случае каждый раз, когда мы добавляем элемент, мы проверяем, достаточно ли места в базовом массиве, и если да, то просто помещаем элемент в следующий свободный слот в массиве. Однако, если свободных слотов нет, Go выделяет память для массива двойного размера, копирует все элементы из исходного массива в новый, а затем добавляет новый элемент в новый резервный массив. Вот почему мы видим, как емкость удваивается всякий раз, когда мы достигаем предела базового массива. Это также означает, что если мы знаем размер слайса, лучше выделить достаточно памяти при создании слайса, например, с третьим параметром — capacity — функции make функция.

И снова мы должны помнить, что добавление к «нарезанному» фрагменту может изменить исходный фрагмент. Давайте посмотрим на это «хитрое» поведение в следующем примере с кодом:

nums1 := make([]int, 6, 6) // [0 0 0 0 0 0] len: 6, cap: 6
// let's fill the slice with numbers
for i := 0; i < len(nums1); i++ {
    nums1[i] = i
}                   // nums1 -> [0 1 2 3 4 5]  
nums2 := nums1[2:5] // nums2 ->     [2 3 4]  len: 3, cap: 4

nums2 = append(nums2, 22)
// nums2 ->     [2 3 4 22]
// nums1 -> [0 1 2 3 4 22] - we also changed the original slice

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

Давайте вернемся к приведенному выше примеру с обоими фрагментами на пределе емкости.

nums1 = append(nums1, 6)
// nums1 -> [0 1 2 3 4 22 6]
// nums2 ->     [2 3 4 22]

nums2 = append(nums2, 33)
// nums1 -> [0 1 2 3 4 22 6]  len: 7, cap: 12
// nums2 ->     [2 3 4 22 33] len: 5, cap: 8

Когда мы добавляем к каждому слайсу, мы выделяем новый массив для каждого из слайсов, поскольку базовые массивы для них разные. Но в любом случае поведение с sliced-slice выглядит нелогичным, и есть способ избежать этого.

Исправим:

nums1 := make([]int, 6, 6) // [0 0 0 0 0 0] len: 6, cap: 6
// let's fill the slice with numbers
for i := 0; i < len(nums1); i++ {
    nums1[i] = i
}                     // nums1 -> [0 1 2 3 4 5]
nums2 := nums1[2:5:5] // nums2 ->     [2 3 4]  len: 3, cap: 3

nums2 = append(nums2, 22)
// nums1 -> [0 1 2 3 4 5]
// nums2 ->     [2 3 4 22] len: 4, cap: 6

В этом примере мы используем полное выражение среза для создания среза nums2. Он имеет аннотацию a[low : high : max] и создает срез со следующими атрибутами: low в основном показывает индекс первого элемента, len = high — low, а cap = max - низкий. И теперь, когда мы присоединяемся к nums2, мы выделяем новый резервный массив, и модификация слайса nums2 больше не влияет на исходный слайс.

Давайте посмотрим на другой пример использования фрагментов:

package main

import "fmt"

func main() {
    nums := []int{1, 2, 3, 4}
    fmt.Printf("nums before: %p, %v n", &nums, nums)
    modify(nums)
    fmt.Printf("nums after: %p, %v n", &nums, nums)  
}

func modify(list []int) {
    fmt.Printf("modifying list: %p, %v n", &list, list) 
    list[2] = 100
}

// nums before:    0xc000010030, [1 2 3 4]
// modifying list: 0xc000010060, [1 2 3 4] 
// nums after:     0xc000010030, [1 2 100 4]

Что здесь произошло?

Мы создали слайс — nums — и передали его функции modify по значению. Как видите, указатель на слайс был изменен во втором выводе, что означает, что мы скопировали слайс, но снова слайс — это просто структура с тремя компонентами — указатель, длина и емкость. Go скопировал эту структуру, но не резервный массив, и теперь, когда мы изменяем этот скопированный фрагмент, мы меняем базовый массив для исходного фрагмента. Чтобы решить эту проблему, мы можем создать новый слайс и скопировать все элементы в этот слайс, например, с помощью другой встроенной функции - copy.

package main

import "fmt"

func main() {
    nums := []int{1, 2, 3, 4}
    fmt.Printf("nums before: %p, %v n", &nums, nums)
    modify(nums)
    fmt.Printf("nums after: %p, %v n", &nums, nums)
}

func modify(list []int) {
    newSlice := make([]int, len(list))
    copy(newSlice, list)
    fmt.Printf("before list: %p, %v n", &newSlice, newSlice)
    newSlice[2] = 100
    fmt.Printf("after list: %p, %v n", &newSlice, newSlice) 
}

// nums before: 0xc0000a8018, [1 2 3 4] 
// before list: 0xc0000a8048, [1 2 3 4] 
// after list:  0xc0000a8048, [1 2 100 4]
// nums after:  0xc0000a8018, [1 2 3 4] 

Теперь мы скопировали срез и базовый массив, и проблема исчезла.

Заключение

Срезы — это мощные структуры данных в Go, обеспечивающие гибкий и эффективный способ работы с коллекциями элементов. Благодаря встроенной поддержке изменения размера и богатому набору методов срезы предлагают универсальную альтернативу массивам, особенно хорошо подходящую для динамических данных. Однако, как и в случае с любой программной конструкцией, при работе со срезами существуют подводные камни, о которых следует помнить. В этой статье мы рассмотрели наиболее распространенные ловушки, связанные со слайсами, такие как непонимание того, как они реализованы, копирование слайсов по значению и т. д. Поняв эти подводные камни и следуя рекомендациям по работе со слайсами, вы сможете избежать распространенных ошибок и максимизировать эта мощная структура данных в ваших программах Go.

:::информация Главное изображение для этой статьи было создано генератором изображений HackerNoon AI Image Generator с помощью подсказки «Slices»

:::


Оригинал