Язык Go — двумерные слайсы
Как устроены двумерные массивы и слайсы в Go, два способа аллокации, когда использовать каждый и неочевидные ситуации при работе с вложенными слайсами.
Содержание
Массивы и слайсы в Go одномерные. Чтобы получить двумерную структуру, нужно создать массив массивов или слайс слайсов. Звучит просто, но под этим скрывается несколько важных решений о памяти.
Базовые типы
type Transform [3][3]float64 // массив массивов: матрица 3×3, фиксированный размер
type LinesOfText [][]byte // слайс слайсов: каждая строка — независимый []byte
Transform — фиксированная структура. Размер закреплён в типе, всё хранится в одном непрерывном блоке памяти размером 3 * 3 * 8 = 72 байта.
LinesOfText — гибкая структура. Каждый внутренний слайс независим и может иметь свою длину.
Неравные строки
Поскольку внутренние слайсы независимы, каждая «строка» может быть своей длины:
text := LinesOfText{
[]byte("Now is the time"),
[]byte("for all good gophers"),
[]byte("to bring some fun to the party."),
}
for i, line := range text {
fmt.Printf("строка %d (%d байт): %s\n", i, len(line), line)
}
// строка 0 (15 байт): Now is the time
// строка 1 (20 байт): for all good gophers
// строка 2 (31 байт): to bring some fun to the party.
Это типичная ситуация для текста, таблиц с разным числом колонок, графов с разным числом рёбер.
Треугольная матрица — хороший пример, где строки намеренно разной длины:
// нижнетреугольная матрица: строка i содержит i+1 элементов
n := 5
triangle := make([][]int, n)
for i := range triangle {
triangle[i] = make([]int, i+1)
for j := range triangle[i] {
triangle[i][j] = i*10 + j
}
}
for _, row := range triangle {
fmt.Println(row)
}
// [0]
// [10 11]
// [20 21 22]
// [30 31 32 33]
// [40 41 42 43 44]
Два способа аллокации прямоугольной матрицы
Когда все строки одинаковой длины — как пиксели изображения — есть два подхода.
Способ 1: каждая строка отдельно
const XSize, YSize = 320, 240
// выделяем верхний слайс — слайс из YSize строк
picture := make([][]uint8, YSize) // [][]uint8, пока строки не заполнены
// для каждой строки делаем отдельную аллокацию
for i := range picture {
picture[i] = make([]uint8, XSize)
}
Структура в памяти: один слайс из 240 указателей, каждый указывает на свой независимый массив из 320 байт. Всего 241 аллокация.
Способ 2: один большой блок, нарезанный на строки
// выделяем верхний слайс — те же 240 строк
picture := make([][]uint8, YSize)
// выделяем ОДИН большой слайс для всех пикселей
pixels := make([]uint8, XSize*YSize) // 320*240 = 76800 байт, одна аллокация
// нарезаем его на строки
for i := range picture {
picture[i], pixels = pixels[:XSize], pixels[XSize:]
// picture[i] получает первые XSize элементов из pixels
// pixels сдвигается вперёд на XSize — отрезанные элементы больше не включаются
}
Структура в памяти: один непрерывный блок 76800 байт, поверх него — слайс из 240 дескрипторов, каждый указывает на свой участок блока. Всего 2 аллокации.
Как работает нарезка в цикле
Строка picture[i], pixels = pixels[:XSize], pixels[XSize:] — параллельное присвоение:
pixels := []uint8{0, 1, 2, 3, 4, 5, 6, 7, 8, 9} // упрощённый пример, XSize=3
// итерация 0:
// picture[0] = pixels[:3] → [0, 1, 2]
// pixels = pixels[3:] → [3, 4, 5, 6, 7, 8, 9]
// итерация 1:
// picture[1] = pixels[:3] → [3, 4, 5]
// pixels = pixels[3:] → [6, 7, 8, 9]
// итерация 2:
// picture[2] = pixels[:3] → [6, 7, 8]
// pixels = pixels[3:] → [9]
После цикла переменная pixels — просто вспомогательная, она больше не нужна. Все данные живут в исходном блоке памяти, на который ссылаются строки picture.
Какой способ выбрать
Раздельная аллокация — когда строки могут изменять длину или когда нужно заменять строки целиком. Каждая строка независима, изменение одной не затронет соседние.
Единый блок — когда матрица фиксированного размера и нужна производительность. Меньше нагрузки на сборщик мусора, лучше использование кеша процессора, потому что данные лежат непрерывно.
// демонстрация независимости строк при раздельной аллокации
picture1 := make([][]int, 3)
for i := range picture1 {
picture1[i] = make([]int, 3)
}
// можно заменить строку целиком
picture1[1] = []int{10, 20, 30, 40, 50} // строка стала длиннее — это нормально
fmt.Println(picture1[1]) // [10 20 30 40 50]
// при едином блоке строки делят память — менять длину нельзя
picture2 := make([][]int, 3)
block := make([]int, 9)
for i := range picture2 {
picture2[i], block = block[:3], block[3:]
}
// picture2[0] и picture2[1] и picture2[2] — части одного массива
// менять длину строки здесь опасно: picture2[0] = append(picture2[0], 99)
// может затронуть начало picture2[1]
Обращение к элементам и обход
Синтаксис одинаковый для обоих способов:
picture[y][x] = 255 // устанавливаем пиксель (x, y)
val := picture[y][x] // читаем пиксель
// обход всей матрицы
for y, row := range picture {
for x, pixel := range row {
_ = y; _ = x; _ = pixel
}
}
Для производительности важен порядок обхода. Обход по строкам (picture[y][x]) — правильный: данные в строке лежат рядом в памяти, кеш процессора работает эффективно. Обход по столбцам (picture[x][y]) — приводит к промахам кеша на больших матрицах.
Итоги
- Двумерная структура в Go — это всегда слайс слайсов или массив массивов, встроенного 2D-типа нет
- Внутренние слайсы независимы и могут иметь разную длину
- Два способа аллокации: раздельный (много аллокаций, строки независимы) и единый блок (одна аллокация, строки делят память)
- При едином блоке не меняйте длину строк после нарезки — это затронет соседние строки
- Обходите матрицу по строкам, а не по столбцам — это важно для производительности на больших данных