No history yet

Управление памятью и указатели

Значения против Указателей

В Go переменные передаются в функции по значению. Это означает, что функция получает копию данных, а не прямой доступ к оригиналу. Любые изменения, внесенные в копию, не затрагивают исходную переменную. Это безопасно и предсказуемо.

Рассмотрим структуру User:

package main

import "fmt"

type User struct {
	Name  string
	Level int
}

// Принимает копию User
func levelUpValue(u User) {
	u.Level++
}

func main() {
	u1 := User{Name: "Alice", Level: 1}
	levelUpValue(u1)
	fmt.Println(u1.Level) // Выведет: 1
}

Как видите, уровень u1 не изменился. Функция levelUpValue работала с копией.

Чтобы изменить исходные данные, мы используем указатели. Указатель — это переменная, которая хранит адрес в памяти другой переменной. Передавая указатель, мы даем функции «ключ» к оригиналу.

// Принимает указатель на User
func levelUpPointer(u *User) {
	u.Level++
}

func main() {
	u2 := User{Name: "Bob", Level: 1}
	levelUpPointer(&u2) // Передаем адрес u2
	fmt.Println(u2.Level) // Выведет: 2
}

Здесь levelUpPointer получает адрес u2 и изменяет значение прямо по этому адресу. Выбор между значением и указателем — это компромисс между безопасностью (копии) и производительностью (прямой доступ), особенно при работе с большими структурами данных.

Стек и Куча

Память в программе Go делится на две основные области: стек и кучу.

  • Стек (Stack): Быстрая, организованная память. Используется для локальных переменных функций. Когда функция вызывается, для ее переменных выделяется «кадр» (frame) на вершине стека. Когда функция завершается, ее кадр просто удаляется. Это очень эффективно.

  • Куча (Heap): Более медленная, но гибкая область памяти. Здесь хранятся данные, которые должны пережить вызов функции, например, потому что на них указывает указатель из другой, более долгоживущей области. Память в куче управляется , который автоматически находит и освобождает неиспользуемые объекты.

Как компилятор решает, куда поместить переменную? С помощью процесса, называемого (Escape Analysis). Компилятор анализирует код, чтобы определить, «утекает» ли ссылка на переменную за пределы функции, в которой она была создана. Если нет — переменная остается на быстром стеке. Если да — она перемещается (аллоцируется) в кучу.

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

Мы можем увидеть решения компилятора, используя флаг -m при сборке:

go build -gcflags="-m" ./...

Рассмотрим пример. Эта функция возвращает указатель на локальную переменную. Компилятор увидит это и переместит User в кучу.

package main

type User struct {
	Name string
}

// Возвращает указатель на локальную переменную
func NewUser(name string) *User {
	// u "утекает" в кучу
	u := User{Name: name}
	return &u
}

func main() {
	user := NewUser("Carol")
	_ = user
}

Вывод компилятора подтвердит это, сообщив что-то вроде ./main.go:11:2: moved to heap: u.

Слайсы и структуры изнутри

Слайс — это не просто массив. Внутри это небольшая структура-заголовок, которая содержит три поля:

  • Pointer: Указатель на первый элемент в базовом массиве (underlying array).
  • Length (len): Количество элементов в слайсе.
  • Capacity (cap): Общая емкость базового массива, начиная с указателя.

Когда вы передаете слайс в функцию, копируется только этот заголовок, а не все данные. Это очень быстро. Но есть нюанс с функцией append. Если при добавлении нового элемента длина len превышает емкость cap, Go выделит новый, больший массив в памяти, скопирует туда все старые элементы и добавит новый. Слайс начнет указывать на этот новый массив. Старый массив будет удален сборщиком мусора, если на него больше нет ссылок.

Эта операция копирования может быть дорогой, если слайс большой. Если вы заранее знаете, сколько элементов вам понадобится, всегда создавайте слайс с нужной емкостью:

// Плохо: приведет к нескольким аллокациям и копированиям
var numbers []int
for i := 0; i < 1000; i++ {
	numbers = append(numbers, i)
}

// Хорошо: одна аллокация
numbers := make([]int, 0, 1000) // len=0, cap=1000
for i := 0; i < 1000; i++ {
	numbers = append(numbers, i)
}

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

выравнивание

noun

Процесс, при котором процессор располагает данные в памяти по адресам, кратным размеру этих данных. Например, 8-байтовый int64 будет размещен по адресу, делящемуся на 8. Это позволяет процессору считывать данные за одну операцию.

Рассмотрим две структуры. Они содержат одинаковые поля, но в разном порядке.

import "unsafe"

// Структура с неоптимальным порядком полей
type BadStruct struct {
	IsActive bool   // 1 байт
	Value    int64  // 8 байт
	Marker   byte   // 1 байт
}
// Размер: 24 байта на 64-битной системе

// Структура с оптимальным порядком
type GoodStruct struct {
	Value    int64  // 8 байт
	IsActive bool   // 1 байт
	Marker   byte   // 1 байт
}
// Размер: 16 байт на 64-битной системе

// fmt.Println(unsafe.Sizeof(BadStruct{})) // 24
// fmt.Println(unsafe.Sizeof(GoodStruct{})) // 16

В BadStruct компилятор добавляет 7 байт «набивки» (padding) после IsActive, чтобы выровнять Value по 8-байтовой границе. Затем добавляет еще 7 байт в конце. В GoodStruct два однобайтовых поля идут вместе, и набивка нужна только в конце, чтобы общий размер был кратен 8. Группировка полей по размеру (от большего к меньшему) — простая оптимизация, которая может сэкономить много памяти.

Quiz Questions 1/5

Что произойдет с исходной переменной типа struct, если передать ее в функцию по значению и изменить одно из ее полей внутри этой функции?

Quiz Questions 2/5

Для чего компилятор Go использует "анализ утечки" (Escape Analysis)?

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