среда, 12 февраля 2020 г.

Срезы в Golang: внутреннее устройство и использование

Тип среза (slice) в Go предоставляет удобный и эффективный способ работы с последовательностями типизированных данных. Срезы аналогичны массивам на других языках, но имеют некоторые необычные свойства. В этом посте мы рассмотрим, что такое срезы и как они используются.

Массивы

Тип среза (slice) - это абстракция, построенная поверх типа массива Go, и поэтому для понимания срезов мы должны сначала понять массивы.

Определение типа массива определяет длину и тип элемента. Например, тип [4]int представляет массив из четырех целых чисел. Размер массива фиксирован; его длина является частью его типа ([4]int и [5]int являются различными несовместимыми типами). Массивы можно индексировать обычным способом, поэтому выражение s[n] обращается к n-му элементу, начиная с нуля.

var a [4]int
a[0] = 1
i := a[0]
// i == 1

Массивы не нужно явно инициализировать; нулевое значение массива - это готовый массив, элементы которого сами обнуляются:

// a[2] == 0, нулевое значение типа int

Представление [4]int в памяти - это всего лишь четыре целочисленных значения, расположенных последовательно:

Массивы в Go являются значениями. Переменная массива обозначает весь массив; это не указатель на первый элемент массива (как в случае с C). Это означает, что когда вы присваиваете или передаете значение массива, вы будете делать копию его содержимого. (Чтобы избежать копирования, вы можете передать указатель на массив, но тогда это указатель на массив, а не массив.) Один из способов представить себе массивы - считать их некоей структурой, но с индексированными, а не именованными полями: составное значение с жестко заданным размером.

Литерал массива может быть указан так:

b := [2]string{"Ivan", "Anton"}

Или вы можете сделать так, чтобы компилятор подсчитал для вас элементы массива:

b := [...]string{"Ivan", "Anton"}

В обоих случаях тип b - это [2]string.

Срезы

Массивы имеют свое место, но они немного негибкие, поэтому вы не видите их слишком часто в коде Go. Срезы, однако, есть везде. Они основаны на массивах, чтобы обеспечить большую мощность и удобство.

Спецификация типа для среза: []T, где T - тип элементов среза. В отличие от типа массива, тип среза не имеет заданной длины.

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

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

Срез может быть создан с помощью встроенной функции под названием make, которая имеет сигнатуру,

func make([]T, len, cap) []T

где T обозначает тип элемента среза, который будет создан. Функция make принимает тип, длину и дополнительную емкость. При вызове make выделяет массив и возвращает срез, который ссылается на этот массив.

var s []byte
s = make([]byte, 5, 5)
// s == []byte{0, 0, 0, 0, 0}

Если аргумент емкости (cap) опущен, по умолчанию используется указанная длина. Вот более краткая версия того же кода:

s := make([]byte, 5)

Длину и емкость среза можно проверить с помощью встроенных функций len и cap.

len(s) == 5
cap(s) == 5

В следующих двух разделах обсуждается связь между длиной и емкостью.

Нулевое значение среза равно nil. Функции len и cap возвращают 0 для нулевого среза.

Срез также может быть сформирован путем "нарезки" существующего среза или массива. Разрезание выполняется путем указания полуоткрытого диапазона с двумя индексами, разделенными двоеточием. Например, выражение b [1:4] создает срез, включающий элементы с 1 по 3 из b (индексы полученного среза будут от 0 до 2).

b := []byte{'g', 'o', 'l', 'a', 'n', 'g'}
// b[1:4] == []byte{'o', 'l', 'a'} 
// используя то же хранилище, что и b

Начальный и конечный индексы выражения среза являются необязательными; по умолчанию они равны нулю и длине среза соответственно:

// b[:2] == []byte{'g', 'o'}
// b[2:] == []byte{'l', 'a', 'n', 'g'}
// b[:] == b

Это также синтаксис для создания среза по массиву:

x := [3]string{"Лайка", "Белка", "Стрелка"}
s := x[:] // срез, ссылающийся на хранилище x

Внутренности среза

Срез - это дескриптор сегмента массива. Он состоит из указателя на массив, длины сегмента и его емкости (максимальной длины сегмента).

Наша переменная s, созданная ранее make([]byte, 5), имеет следующую структуру:

Длина (len) - это количество элементов, на которые ссылается срез. Емкость (cap) - это количество элементов в базовом массиве (начиная с элемента, на который указывает указатель среза). Различие между длиной и емкостью станет понятным, когда мы рассмотрим следующие несколько примеров.

Когда мы срезаем s, наблюдаем за изменениями в структуре данных среза и их отношением к базовому массиву:

s = s[2:4]

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

d := []byte{'r', 'o', 'a', 'd'}
e := d[2:] 
// e == []byte{'a', 'd'}
e[1] = 'm'
// e == []byte{'a', 'm'}
// d == []byte{'r', 'o', 'a', 'm'}

Ранее мы нарезали s до длины, меньшей его емкости. Мы можем увеличить s до его емкости, нарезав его снова:

s = s[:cap(s)]

Срез не может быть выращен за пределами его емкости. Попытка сделать это вызовет панику во время выполнения (runtime panic), так же как и при индексировании вне границ среза или массива. Точно так же срезы нельзя повторно разрезать ниже нуля для доступа к более ранним элементам в массиве.

Растущие срезы (функции copy и append)

Чтобы увеличить емкость среза, нужно создать новый, больший срез и скопировать в него содержимое исходного среза. Эта техника - то, как реализации динамического массива из других языков работают за кулисами. В следующем примере удваивается емкость s, создавая новый фрагмент t, копируя содержимое s в t, а затем присваивая значение фрагмента t к s:

// +1 в случае cap(s) == 0
t := make([]byte, len(s), (cap(s)+1)*2) 
for i := range s {
        t[i] = s[i]
}
s = t

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

func copy(dst, src []T) int

Функция copy поддерживает копирование между срезами разной длины (она будет копировать только до меньшего количества элементов). Кроме того, copy может обрабатывать срезы источника и назначения, которые совместно используют один и тот же базовый массив, правильно обрабатывая перекрывающиеся срезы.

Используя copy, мы можем упростить приведенный выше фрагмент кода:

t := make([]byte, len(s), (cap(s)+1)*2)
copy(t, s)
s = t

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

func AppendByte(slice []byte, data ...byte) []byte {
    m := len(slice)
    n := m + len(data)
    // если необходимо, перераспределяем
    if n > cap(slice) { 
        // выделяем вдвое больше, чем нужно, 
        // для будущего роста.
        newSlice := make([]byte, (n+1)*2)
        copy(newSlice, slice)
        slice = newSlice
    }
    slice = slice[0:n]
    copy(slice[m:n], data)
    return slice
}

Можно использовать AppendByte следующим образом:

p := []byte{2, 3, 5}
p = AppendByte(p, 7, 11, 13)
// p == []byte{2, 3, 5, 7, 11, 13}

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

Но большинству программ не требуется полный контроль, поэтому Go предоставляет встроенную функцию append, которая подходит для большинства целей; ее сигнатура

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

Функция append добавляет элементы x в конец среза s и увеличивает его, если требуется большая емкость.

a := make([]int, 1)
// a == []int{0}
a = append(a, 1, 2, 3)
// a == []int{0, 1, 2, 3}

Чтобы добавить один фрагмент к другому, используйте ..., чтобы развернуть второй аргумент в список аргументов.

a := []string{"John", "Paul"}
b := []string{"George", "Ringo", "Pete"}

// эквивалентно "append(a, b[0], b[1], b[2])"
a = append(a, b...) 

// a == []string{"John", "Paul", "George", "Ringo", "Pete"}

Поскольку нулевое значение среза (nil) действует как срез нулевой длины, вы можете объявить переменную среза и затем добавить ее в цикл:

// Filter возвращает новый срез, содержащий только
// элементы s, которые удовлетворяют fn()
func Filter(s []int, fn func(int) bool) []int {
    var p []int // == nil
    for _, v := range s {
        if fn(v) {
            p = append(p, v)
        }
    }
    return p
}

Трюк со срезом

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

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

var digitRegexp = regexp.MustCompile("[0-9]+")

func FindDigits(filename string) []byte {
    b, _ := ioutil.ReadFile(filename)
    return digitRegexp.Find(b)
}

Этот код ведет себя как объявлено, но возвращенный []byte указывает на массив, содержащий весь файл. Поскольку срез ссылается на исходный массив, пока срез хранится вокруг сборщика мусора, он не может освободить массив; несколько полезных байтов файла сохраняют все содержимое в памяти.

Чтобы решить эту проблему, можно скопировать интересные данные в новый срез перед возвратом:

func CopyDigits(filename string) []byte {
    b, _ := ioutil.ReadFile(filename)
    b = digitRegexp.Find(b)
    c := make([]byte, len(b))
    copy(c, b)
    return c
}


Читайте также:


Комментариев нет:

Отправить комментарий