Урок курса
Структуры и указатели: объявление, инициализация и передача по адресу
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, bool — false. Это не «мусор из памяти», а гарантия языка: нулевое значение структуры всегда определено.
Практически это означает: если вы забыли заполнить поле, вы не получите случайные данные — вы получите предсказуемый ноль. Иногда это ровно то, что нужно; иногда — источник трудноуловимого бага, когда программа молчит, а поле тихо остаётся пустым.
Позиционный и поимённый литерал инициализации 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, а Y — 10. Компилятор молчит, программа молчит, ошибка проявится только в поведении.
Поимённый литерал указывает имя каждого поля явно:
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)
}
Продолжить с проверкой и прогрессом
Откройте интерактивный раннер с заданиями урока.
