15. Массивы и их особенности

В динамических языках программирования (Python, PHP, JavaScript) словом «массив» привыкли называть гибкую, эластичную ленту, куда можно динамически добавлять разношерстные данные и менять ее длину по щелчку пальцев. В мире C и C++ массив при передаче в функцию и в большинстве выражений неявно превращается в указатель на первый элемент непрерывного блока памяти (длина при этом теряется), и компилятор беззастенчиво разыменовывает его по правилам адресной арифметики.

В языке Go массив — это совершенно иная сущность: монолитная, строгая и статически зафиксированная по размеру конструкция. В реальном продакшен-коде на Go вы редко будете объявлять массивы напрямую, почти всегда отдавая предпочтение их динамической обертке — срезам (slices).

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


Длина — это часть типа

Фундаментальное правило системы типов Go звучит так: размер массива жестко зашит в его тип на этапе компиляции.

Типы [3]int и [5]int для компилятора Go являются двумя абсолютно разными, несовместимыми типами, точно так же, как несовместимы int и string. Вы не можете присвоить массив одной длины массиву другой длины или передать [3]int в аргумент функции, ожидающей [5]int:

Go
package main

func main() {
    var a [3]int
    var b [5]int

    // a = b // Ошибка компиляции! Cannot use 'b' (type [5]int) as type [3]int
    _ = a
    _ = b
}

Благодаря такой строгости компилятор на этапе сборки вычисляет точный размер структуры в байтах. Небольшой локальный массив, который не убегает из функции, он размещает во фрейме стека без единой динамической аллокации. (Явные локальные переменные больше 128 КБ и массив, на который убегает указатель, уйдут в кучу.)

Синтаксис инициализации

Go предоставляет несколько лаконичных способов создания массивов:

Go
// Массив инициализирован нулевыми значениями (Zero Values)
var zeros [5]int 

// Инициализация с явным заданием значений
primes := [4]int{2, 3, 5, 7}

// Компилятор сам посчитает длину благодаря оператору [...]
magic := [...]string{"Go", "Rust", "C++"} // Тип будет выведен как [3]string

// Инициализация по индексам (остальные элементы будут заполнены нулями 0)
sparse := [10]int{0: 42, 9: 100} 

Mechanical Sympathy: Идеальная структура для CPU

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

Если вы объявляете массив var arr [4]int64, компилятор выделит в памяти ровно 32 байта (4 элемента по 8 байт):

flowchart LR
    classDef process  fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
    classDef success  fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
    classDef error    fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
    classDef warning  fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
    classDef entry    fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
    classDef aux      fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
    classDef system   fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
    classDef data     fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
    classDef network  fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
    classDef accent   fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
    A["arr[0]<br>(8 байт)"] --- B["arr[1]<br>(8 байт)"] --- C["arr[2]<br>(8 байт)"] --- D["arr[3]<br>(8 байт)"]
  

Массивы передаются по значению

В классическом C имя массива автоматически распадается (decays) до обычного указателя на его нулевой элемент. Поэтому в C вы всегда передаете в функцию легковесный 8-байтный адрес.

В Go массивы обладают семантикой значений (Value Semantics).
При присваивании массива новой переменной или при передаче его в функцию компилятор производит полную побитовую копию всего массива:

Go
func modify(arr [3]int) {
    arr[0] = 999 // Меняем КОПИЮ
}

func main() {
    original := [3]int{1, 2, 3}
    modify(original)
    fmt.Println(original[0]) // Выведет 1, оригинал не изменился!
}

Если передача массива необходима без копирования, передают явный указатель на массив *[3]int. Язык Go избавляет от необходимости писать громоздкое разыменование (*arr)[0] — синтаксис индексатора автоматически работает по указателю:

Go
// Передача указателя на массив
func modifyPtr(arr *[3]int) {
    arr[0] = 999 
    // В Go не нужно писать (*arr)[0], синтаксис позволяет обращаться к индексу напрямую
}

Где массивы реально используются?

Если в 99% случаев в Go-коде применяются срезы, зачем массивы вообще сохранены в языке?

  1. Криптография и безопасность:
    В криптографических алгоритмах размер хэша строго фиксирован математически. Функция sha256.Sum256 из стандартной библиотеки возвращает именно фиксированный массив [32]byte, а не динамический слайс. Популярная библиотека работы с UUID (github.com/google/uuid) хранит идентификатор внутри структуры как массив [16]byte. Это дает железную гарантию компилятора, что размер хэша не изменится и не будет случайно урезан в рантайме.
  2. Нулевые аллокации (Zero Allocation):
    Поскольку длина массива известна на этапе компиляции, компилятор размещает небольшой локальный массив прямо на быстром стеке горутины — если массив не убегает из функции. Временный буфер вида [4096]byte на стеке не создает ни малейшей нагрузки на сборщик мусора (Garbage Collector). Но передача buf[:] в метод интерфейса (io.Reader.Read, net.Conn) обычно отправляет буфер в кучу: компилятор не знает, что сделает с ним реализация. Проверяйте через -gcflags=-m.
  3. Фундамент для слайсов:
    Срез (slice) не может существовать сам по себе. Под капотом любого среза всегда располагается непрерывный базовый массив.

Итог

  1. Длина — часть типа: Тип [3]int несовместим с [5]int. Размер массива неизменяем после компиляции.
  2. Идеальная топология памяти: Массив представляет собой монолитный блок в оперативной памяти, оптимизированный под работу аппаратного предвыборщика CPU (Hardware Prefetcher).
  3. Value Semantics: Передача массива означает побитовое копирование всех его элементов. Избегайте передачи больших массивов без указателей.
  4. Сравнение: Массивы сравниваемы через ==, в отличие от срезов.
  5. Применение: Криптографические хэши ([32]byte), стековые буферы (если они не убегают) для исключения аллокаций и базовый фундамент для слайсов.

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

В следующей лекции мы переходим к главной рабочей лошадке языка Go: 16. Slice. Главная структура данных в Go.