Урок курса

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

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.

Задача 1

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

type Box struct { Val int }
func double(b Box) { b.Val *= 2 }
func main() {
    b := Box{Val: 5}
    double(b)
    fmt.Println(b.Val)
}
Выберите один вариант
Без регистрации · результат не сохраняется

Задача 2

Какой из следующих литералов корректно создаёт переменную типа *Point (указатель на структуру Point с полями X и Y)?

Выберите один вариант
Без регистрации · результат не сохраняется

Задача 3

Дан код:

type Pair struct { A int; B int }
p := new(Pair)
p.A = 7

Каков тип переменной p и каково значение p.B после выполнения этих строк?

Выберите один вариант
Без регистрации · результат не сохраняется

Задача 4

Списание со склада

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

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

Что нужно сделать

Допишите draftAfterWithdrawal и withdraw. Обе функции получают складскую позицию и количество списания. Первая должна вернуть расчёт для копии позиции, не меняя переменную в main. Вторая работает с оригиналом и сообщает, было ли списание принято. Количество нельзя списывать, если оно отрицательно или превышает остаток; в таком случае позиция остаётся без изменений.

Формат ввода

В одной строке: название позиции без пробелов, исходный остаток и количество для списания.

Формат вывода

Три строки: draft=NAME:Q, final=NAME:Q и accepted=true либо accepted=false.

Пример

Ввод:
bolts 10 3

Вывод:
draft=bolts:7
final=bolts:7
accepted=true
РешениеGo

Без регистрации. Гостевая проверка не сохраняет прогресс. Для сохранения перейдите к уроку.

Отправьте решение, чтобы увидеть результаты тестов.

Задача 5

Последовательная поправка датчика

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

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

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

Что уже дано

В проекте есть редактируемый файл solution.go с типом Sensor и функцией ApplyCorrections. Публичные Go-тесты создают структуру по именам полей и вызывают функцию с адресом датчика.

Что нужно сделать

Реализуйте ApplyCorrections(sensor *Sensor, corrections []int). Она должна последовательно прибавить каждую поправку к полю Temperature исходного датчика. Идентификатор ID менять нельзя. При пустом списке состояние не меняется.

Ввод и вывод

stdin и stdout не используются. Проверяющая система запускает тесты проекта и проверяет изменённую структуру после вызова функции.

Открыть задание в уроке →

Продолжить с сохранением прогресса

Перейдите к уроку в учебном кабинете.

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