Урок курса

Структуры и указатели: объявление, инициализация и передача по адресу

Golang с нуля

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

Объявление struct и нулевые значения полей

Структура в Go — это способ ввести новый тип, который объединяет несколько полей под одним именем. Объявление выглядит так:

type Person struct {
    Name  string
    Age   int
    Score float64
    Active bool
}

type Person struct { ... } регистрирует Person как самостоятельный тип в пакете. После этого Person можно использовать везде, где допустим любой другой тип: как тип переменной, параметра функции, поля другой структуры.

Каждое поле имеет имя и тип — они могут быть любыми: string, int, float64, bool, другая структура. Нет требования, чтобы типы совпадали.

Что происходит, если просто объявить переменную такого типа без явной инициализации?

var p Person
fmt.Println(p.Name)   // ""
fmt.Println(p.Age)    // 0
fmt.Println(p.Score)  // 0
fmt.Println(p.Active) // false

Go инициализирует каждое поле нулевым значением своего типа: string — пустая строка, числовые типы — 0 или 0.0, boolfalse. Это не «мусор из памяти», а гарантия языка: нулевое значение структуры всегда определено.

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

Позиционный и поимённый литерал инициализации struct

Есть два способа создать значение структуры с непустыми полями.

Позиционный литерал перечисляет значения в том порядке, в котором поля объявлены:

type Point struct {
    X int
    Y int
}

p := Point{10, 20}

Это коротко, но хрупко — сразу по двум причинам.

Добавление или удаление поля немедленно ломает компиляцию: количество значений в литерале перестаёт совпадать с количеством полей, и компилятор выдаёт ошибку. Это хотя бы заметно.

Перестановка полей одного типа — куда опаснее. Представим, что после рефакторинга Point переставили поля:

// было
type Point struct {
    X int
    Y int
}

// стало — поменяли порядок
type Point struct {
    Y int
    X int
}

Позиционный литерал Point{10, 20} при этом спокойно компилируется — число значений и их типы совпадают. Но теперь X получит 20, а Y10. Компилятор молчит, программа молчит, ошибка проявится только в поведении.

Поимённый литерал указывает имя каждого поля явно:

p := Point{X: 10, Y: 20}

Порядок перечисления не важен. Пропущенные поля получают нулевое значение:

p := Point{X: 10}  // Y == 0

Теперь о том, как поимённый литерал ведёт себя при изменении структуры — и где он защищает, а где нет.

Перестановка полей полностью безопасна: поимённый литерал Point{X: 10, Y: 20} присвоит каждому полю именно то значение, которое указано по имени, вне зависимости от порядка объявления.

Добавление нового поля не ломает компиляцию — существующие поимённые литералы остаются валидными, новое поле получит нулевое значение своего типа. Но это именно та ситуация, о которой шла речь в предыдущей секции: нулевое значение не всегда осмысленно. Если новое поле обязательно по смыслу (например, Active bool определяет, работает ли запись), все существующие места создания структуры нужно проверить вручную — компилятор о пропущенном поле не предупредит.

Иными словами: перестановка полей — это случай, где поимённый литерал действительно защищает. Добавление поля — случай, где компиляция проходит, но требуется ревью кода.

Одно жёсткое ограничение: смешивать стили в одном литерале нельзя:

p := Point{10, Y: 20}  // ошибка компиляции
// mixture of field:value and value syntax in struct literal

Выбор прост: позиционный литерал оправдан только для очень маленьких внутренних типов, которые гарантированно не изменятся. В остальных случаях поимённый — надёжнее.

Указатель на struct: &, *, new и автоматическое разыменование полей

В уроке про fmt.Scan вы уже видели оператор & — тогда он нужен был, чтобы передать адрес переменной для записи. Разберём механизм полностью.

&x возвращает адрес переменной x. Тип результата — *T, где T — тип x. *p — обратная операция: читает или записывает значение по адресу, на который указывает p.

x := 42
p := &x      // p имеет тип *int
*p = 100     // записываем по адресу
fmt.Println(x) // 100

Переменная x изменилась, хотя мы писали через p. Это и есть суть указателя: p и x ссылаются на одну ячейку памяти.

Для структур работает то же самое. Получить *Point можно двумя способами:

// Способ 1: & от структурного литерала
pt1 := &Point{X: 3, Y: 4}

// Способ 2: new
pt2 := new(Point)  // *Point, поля X=0, Y=0

new(Point) всегда даёт нулевую структуру. &Point{X: 3, Y: 4} позволяет задать поля сразу. В обоих случаях тип переменной — *Point.

Чтобы прочитать или изменить поле через указатель, формально нужно разыменовать его:

(*pt1).X = 10

Но Go позволяет писать короче:

pt1.X = 10

Компилятор видит, что pt1 — указатель, и автоматически вставляет разыменование. Обе записи абсолютно эквивалентны, pt1.X — просто синтаксическое удобство.

Один важный момент: нулевое значение любого указателя — nil. Попытка разыменовать nil приводит к панике во время выполнения:

var pt *Point        // pt == nil
fmt.Println(pt.X)   // panic: runtime error: invalid memory address or nil pointer dereference

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

Передача struct по значению и по указателю: видимость изменений в вызывающем коде

Возьмём конкретный пример. Есть структура и две функции:

type Point struct {
    X int
    Y int
}

func scale(p Point, f int) {
    p.X *= f
    p.Y *= f
}

func scalePtr(p *Point, f int) {
    p.X *= f
    p.Y *= f
}

Что произойдёт в каждом случае:

func main() {
    a := Point{X: 3, Y: 4}

    scale(a, 10)
    fmt.Println(a) // {3 4} — ничего не изменилось

    scalePtr(&a, 10)
    fmt.Println(a) // {30 40} — изменилось
}

Почему так? При вызове scale(a, 10) Go копирует всё содержимое a в новую переменную p внутри функции. p.X и p.Y — это отдельные поля в отдельной области памяти. Функция умножает их, но оригинальный a об этом ничего не знает.

При вызове scalePtr(&a, 10) функция получает адрес a. Когда внутри функции выполняется p.X *= f, это разворачивается в изменение (*p).X — то есть поля того самого a в вызывающем коде.

Это фундаментальное отличие от срезов, с которыми вы работали раньше. Срез — это заголовок с указателем на массив; копирование заголовка оставляет общий массив. Структура с числовыми полями копируется полностью: после копирования нет никакой связи между оригиналом и копией.

Практическое правило для структур со скалярными полями: если функция должна изменить данные и вы хотите видеть изменения снаружи — передавайте *T. Если функция только читает или должна работать с независимой копией — передавайте T.

Попробуйте решить

Что выведет следующая программа?

type Box struct { Val int }
func double(b Box) { b.Val *= 2 }
func main() {
    b := Box{Val: 5}
    double(b)
    fmt.Println(b.Val)
}

Продолжить с проверкой и прогрессом

Откройте интерактивный раннер с заданиями урока.

Перейти к интерактивному уроку