· 8 мин 👁 1.6k Начинающий

Язык Go — двумерные слайсы

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

слайсы2dмассивыпамятьаллокация
Содержание

Массивы и слайсы в 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-типа нет
  • Внутренние слайсы независимы и могут иметь разную длину
  • Два способа аллокации: раздельный (много аллокаций, строки независимы) и единый блок (одна аллокация, строки делят память)
  • При едином блоке не меняйте длину строк после нарезки — это затронет соседние строки
  • Обходите матрицу по строкам, а не по столбцам — это важно для производительности на больших данных