Go: полный курс — от основ до современного языка · Глава 7 из 12

Глава 7. Структуры и методы

Прогресс сохранится в этом браузере (войдите, чтобы синхронизировать).

Введение

До этого момента ты работал с отдельными значениями: числами, строками, слайсами, картами. Но реальные данные почти всегда составные. Пользователь — это не просто строка, это имя, e-mail, возраст, дата регистрации и десяток других полей, которые логически принадлежат одному объекту. В Go для описания таких «сложных данных, склеенных из простых» служит struct — структура. Структура — это фундамент моделирования данных в Go: на ней держатся модели базы данных, тела HTTP-запросов, конфигурация приложения, доменные сущности. В этой главе ты научишься объявлять структуры, создавать их значения разными способами, вешать на них поведение через методы, понимать критическую разницу между value- и pointer-receiver и заменять наследование встраиванием. И в самом конце — сериализовать структуры в JSON и обратно, потому что 90% реальной работы с Go-сервисом сводится именно к этому.

Что такое структура и зачем она нужна

Структура — это именованный тип, который группирует набор полей. Каждое поле имеет своё имя и свой тип. Думай о структуре как о бланке анкеты: сам тип — это пустой бланк с графами «Имя», «Фамилия», «Возраст», а конкретное значение структуры — это заполненный экземпляр анкеты. Без структур пришлось бы таскать связанные данные по отдельным переменным (firstName, lastName, age) и следить, чтобы они не разъехались. Структура склеивает их в одно целое, которое можно передать в функцию одним аргументом, вернуть, положить в слайс или карту.

package main

import "fmt"

// Объявляем новый тип-структуру person.
// Это описание «бланка»: три поля с их типами.
type person struct {
    first string
    last  string
    age   int
}

func main() {
    // Заполняем бланк: создаём конкретное значение структуры.
    // Значения идут в том же порядке, что и поля.
    p1 := person{"James", "Bond", 20}
    fmt.Println(p1.first, p1.last, p1.age) // James Bond 20
}

Обрати внимание: person — это тип, такой же полноценный, как int или string. Мы объявили его один раз, а дальше можем создавать сколько угодно значений этого типа. Доступ к полю — через точку: p1.first.

Инициализация по имени поля

Позиционная инициализация (person{"James", "Bond", 20}) компактна, но хрупка: стоит поменять местами поля в объявлении типа — и все значения молча «поедут». Поэтому в реальном коде почти всегда используют инициализацию по именам полей. Она многословнее, но самодокументируется и устойчива к перестановке полей.

p2 := person{
    first: "Miss",
    last:  "Moneypenny",
    age:   18,
}

// Поля, которые ты не указал, получают нулевое значение своего типа:
// для строки — "", для int — 0, для bool — false.
p3 := person{first: "Q"} // last == "", age == 0
fmt.Printf("%+v
", p3)  // {first:Q last: age:0}

Глагол %+v в Printf печатает структуру вместе с именами полей — незаменимая вещь при отладке. Просто %v напечатает только значения, а %#v — ещё и имя типа в Go-синтаксисе.

Нулевое значение структуры

В Go у каждого типа есть «нулевое значение» — то, чем переменная инициализируется, если ты не задал ничего явно. Для структуры это структура, где каждое поле имеет своё нулевое значение. Это очень удобно: объявленную через var структуру можно сразу использовать, она не «мусорная», как в C.

var p person // готовое к работе нулевое значение
fmt.Println(p.first == "") // true
fmt.Println(p.age == 0)    // true

Анонимные структуры

Иногда структура нужна ровно один раз — например, чтобы сгруппировать пару значений для одного ответа или временно распарсить кусочек JSON. Заводить под это именованный тип избыточно. Тогда пригодится анонимная структура: тип и значение объявляются на месте, «на лету».

// Тип не назван — он описан прямо перед фигурными скобками значения.
config := struct {
    Host string
    Port int
}{
    Host: "localhost",
    Port: 8080,
}
fmt.Println(config.Host, config.Port) // localhost 8080

Анонимные структуры часто встречаются в тестах (таблица тест-кейсов) и при разборе внешнего JSON, когда нужно вытащить два-три поля из большого ответа.

Методы: пришиваем поведение к данным

Структура описывает данные. Но данные обычно умеют что-то делать: пользователь может представиться полным именем, заказ — посчитать сумму, прямоугольник — вычислить площадь. В Go поведение добавляется через методы — это функции со специальным «получателем» (receiver), который указывается в скобках перед именем функции. Метод как бы «привязан» к типу.

type person struct {
    first string
    last  string
}

// (p person) — это receiver. Метод fullName принадлежит типу person.
// Внутри метода p — это конкретное значение, на котором его вызвали.
func (p person) fullName() string {
    return p.first + " " + p.last
}

func main() {
    p := person{"James", "Bond"}
    fmt.Println(p.fullName()) // James Bond
}

Ключевая идея: в Go нет классов. Метод — это просто функция, у которой есть получатель. Можно было бы написать fullName(p person) string как обычную функцию, но метод p.fullName() читается естественнее и лучше моделирует «объект умеет что-то делать».

Value receiver против pointer receiver

Это одна из тех тем, на которых спотыкаются почти все новички, поэтому разберём медленно. Receiver бывает двух видов: значение ((p person)) и указатель ((p *person)). Разница принципиальная.

Когда receiver — значение, метод получает копию структуры. Любые изменения полей внутри метода происходят с копией и не видны снаружи. Когда receiver — указатель, метод получает адрес оригинала и может его менять.

type counter struct {
    value int
}

// Value receiver: работает с КОПИЕЙ. Изменение не сохранится.
func (c counter) incWrong() {
    c.value++ // меняем копию — бесполезно
}

// Pointer receiver: работает с ОРИГИНАЛОМ через адрес.
func (c *counter) incRight() {
    c.value++ // меняем оригинал — сохранится
}

func main() {
    c := counter{}
    c.incWrong()
    fmt.Println(c.value) // 0 — изменения потерялись

    c.incRight()
    fmt.Println(c.value) // 1 — изменение сохранилось
}

Заметь: мы вызвали c.incRight() на обычном значении, а не на указателе — Go сам автоматически взял адрес &c, потому что c адресуемая переменная. Это синтаксический сахар, но помнить о нём стоит.

Практическое правило выбора:

  • Нужно менять поля структуры внутри метода — только pointer receiver.
  • Структура большая (много полей) — pointer receiver, чтобы не копировать её на каждый вызов.
  • Тип должен реализовывать интерфейс с изменяющими методами — pointer receiver.
  • Для консистентности: если хотя бы один метод типа использует pointer receiver, обычно все методы делают pointer receiver, чтобы не смешивать.

Указатель на структуру

Указатель на структуру создаётся через & прямо на литерале. Это очень частая идиома в Go: конструкторы почти всегда возвращают *T.

// p — это уже *person (указатель на структуру), а не person.
p := &person{first: "James", last: "Bond"}
fmt.Printf("%T
", p) // *main.person

// Обращаться к полям можно напрямую через точку —
// Go сам разыменует указатель: p.first вместо (*p).first.
fmt.Println(p.first) // James

В Go не нужно писать (*p).first, как в C — язык автоматически разыменовывает указатель при доступе к полю. Это делает код с указателями на структуры почти неотличимым от кода со значениями.

Встраивание (embedding) вместо наследования

В Go нет классов и нет наследования. Вместо «класс B наследует класс A» здесь используют встраивание: одну структуру помещают внутрь другой без имени поля. Поля и методы встроенной структуры «поднимаются» (promotion) на уровень внешней, как будто они её собственные.

type person struct {
    First string
    Last  string
}

func (p person) fullName() string {
    return p.First + " " + p.Last
}

// secretAgent встраивает person — без имени поля, просто тип.
type secretAgent struct {
    person        // встроенное поле
    LicenseToKill bool
}

func main() {
    a := secretAgent{
        person:        person{First: "James", Last: "Bond"},
        LicenseToKill: true,
    }

    // Поле First «поднято»: обращаемся напрямую, минуя person.
    fmt.Println(a.First)        // James — promotion полей
    fmt.Println(a.fullName())   // James Bond — promotion метода
    fmt.Println(a.person.First) // James — можно и явно
}

Философия Go: композиция вместо наследования. Ты не строишь глубокие иерархии «животное → млекопитающее → собака», а собираешь тип из кусочков поведения. Это гибче и меньше связывает код.

Переопределение поднятых методов

Если внешняя структура объявит метод с тем же именем, что и встроенная, вызов на внешнем типе пойдёт к внешнему методу — внутренний «затенён», но доступен явно.

func (p person) greeting() string { return "Обычный человек" }

type secretAgent struct {
    person
}

// Тот же метод greeting, но на secretAgent — он затеняет person.greeting.
func (a secretAgent) greeting() string { return "Агент на службе" }

func main() {
    a := secretAgent{person{"James", "Bond"}}
    fmt.Println(a.greeting())        // Агент на службе
    fmt.Println(a.person.greeting()) // Обычный человек — явный доступ
}

Теги полей

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

type person struct {
    First string `json:"first_name"`
    Last  string `json:"last_name"`
    Age   int    `json:"age,omitempty"` // omitempty: пропустить, если 0
    pass  string `json:"-"`             // "-": никогда не сериализовать
}

Тег json:"first_name" говорит: в JSON это поле называется first_name, а не First. Опция omitempty убирает поле из вывода, если оно равно нулевому значению. Дефис - полностью исключает поле (удобно для паролей и внутренних полей).

Сериализация в JSON через encoding/json

Пакет encoding/json из стандартной библиотеки превращает структуры в JSON (Marshal) и обратно (Unmarshal). Есть важное правило: сериализуются только экспортируемые поля — те, что начинаются с заглавной буквы. Поле с маленькой буквы (pass) пакет не увидит, потому что рефлексия не имеет доступа к неэкспортируемым полям чужого пакета.

package main

import (
    "encoding/json"
    "fmt"
)

type person struct {
    First string `json:"first_name"`
    Last  string `json:"last_name"`
    Age   int    `json:"age,omitempty"`
}

func main() {
    p := person{First: "James", Last: "Bond", Age: 20}

    // Marshal: структура -> []byte с JSON.
    bs, err := json.Marshal(p)
    if err != nil {
        fmt.Println("ошибка:", err)
        return
    }
    fmt.Println(string(bs))
    // {"first_name":"James","last_name":"Bond","age":20}
}
// Обратное направление: JSON -> структура.
// Передаём УКАЗАТЕЛЬ &p, чтобы Unmarshal мог заполнить поля оригинала.
data := []byte(`{"first_name":"Miss","last_name":"Moneypenny","age":18}`)

var p person
if err := json.Unmarshal(data, &p); err != nil {
    fmt.Println("ошибка разбора:", err)
    return
}
fmt.Printf("%+v
", p) // {First:Miss Last:Moneypenny Age:18}

Именно поэтому Unmarshal берёт &p: без указателя функция получила бы копию и заполнила бы её, а твой оригинал остался бы пустым. Это прямое следствие разговора про value vs pointer из середины главы.

Сравнение структур

Структуры в Go можно сравнивать оператором ==, если все их поля сравнимы (числа, строки, булевы, указатели, массивы из сравнимых типов). Две структуры равны, когда попарно равны все их поля. Это удобно для проверок в тестах и для использования структур как ключей карты.

type point struct {
    X, Y int
}

a := point{1, 2}
b := point{1, 2}
c := point{3, 4}
fmt.Println(a == b) // true — все поля совпали
fmt.Println(a == c) // false

// Раз структура сравнима, её можно использовать КАК КЛЮЧ карты.
seen := map[point]bool{}
seen[a] = true
fmt.Println(seen[b]) // true — b равна a, значит тот же ключ

Важная оговорка: если в структуре есть несравнимое поле — слайс, карта или функция, — оператор == для неё не скомпилируется. В таких случаях сравнивают вручную поле за полем или через reflect.DeepEqual (медленнее, для тестов).

Структуры в слайсах и картах

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

type user struct {
    Name string
    Age  int
}

// Слайс структур: элемент по индексу адресуем — меняем поле напрямую.
users := []user{{"Анна", 30}, {"Борис", 25}}
users[0].Age = 31 // ОК, изменение сохранится
fmt.Println(users[0]) // {Анна 31}

// Карта структур: элемент НЕ адресуем.
byName := map[string]user{"Анна": {"Анна", 30}}
// byName["Анна"].Age = 31 // ОШИБКА КОМПИЛЯЦИИ

// Правильно: достать копию, изменить, положить обратно.
u := byName["Анна"]
u.Age = 31
byName["Анна"] = u
fmt.Println(byName["Анна"]) // {Анна 31}

Если структуры в карте меняются часто, обычно хранят не значения, а указатели: map[string]*user. Тогда byName["Анна"].Age = 31 работает, потому что ты меняешь структуру по указателю, а не элемент карты.

Конструкторы: паттерн New

В Go нет специальных конструкторов, как в других языках. Вместо этого принято писать обычную функцию с именем New или NewТип, которая создаёт значение, задаёт разумные значения по умолчанию, проверяет входные данные и возвращает *T (и, если нужно, error). Это идиома, а не требование языка, но её придерживаются повсеместно.

type account struct {
    Owner   string
    balance int // неэкспортируемое — снаружи не подделать
}

// NewAccount — конструктор. Проверяет вход и возвращает *account.
func NewAccount(owner string, initial int) (*account, error) {
    if owner == "" {
        return nil, fmt.Errorf("владелец не может быть пустым")
    }
    if initial < 0 {
        return nil, fmt.Errorf("начальный баланс не может быть отрицательным")
    }
    return &account{Owner: owner, balance: initial}, nil
}

func main() {
    acc, err := NewAccount("Иван", 100)
    if err != nil {
        fmt.Println("ошибка:", err)
        return
    }
    fmt.Printf("%+v
", acc) // &{Owner:Иван balance:100}
}

Приём с неэкспортируемым полем balance и экспортируемым конструктором — это инкапсуляция по-гошному: снаружи пакета баланс нельзя задать напрямую, только через контролируемый NewAccount и методы. Так структура сама следит за своими инвариантами.

Вложенные структуры и JSON

Реальный JSON почти всегда вложенный: у заказа есть покупатель со своим адресом, у адреса — город и индекс. Структуры вкладываются друг в друга так же естественно, и encoding/json разбирает вложенность автоматически.

type address struct {
    City string `json:"city"`
    Zip  string `json:"zip"`
}

type customer struct {
    Name    string  `json:"name"`
    Address address `json:"address"` // вложенная структура
}

func main() {
    data := []byte(`{
        "name": "Иван",
        "address": {"city": "Москва", "zip": "101000"}
    }`)

    var c customer
    if err := json.Unmarshal(data, &c); err != nil {
        fmt.Println(err)
        return
    }
    fmt.Println(c.Name, c.Address.City) // Иван Москва
}

Обрати внимание: здесь addressименованное поле, а не встраивание. Если бы мы встроили address без имени, его поля «поднялись» бы на уровень customer, и JSON стал бы плоским. Именованное вложенное поле сохраняет вложенность и в структуре, и в JSON — выбирай по форме данных.

Кейс из реального проекта

Ты пишешь HTTP-сервис для интернет-магазина. Приходит POST-запрос с телом заказа в JSON, тебе нужно его распарсить, посчитать сумму и вернуть ответ. Структуры здесь — центральный инструмент: они описывают и входные данные, и выходные, а методы вешают на них бизнес-логику.

package main

import (
    "encoding/json"
    "fmt"
)

// Позиция заказа. Теги задают имена полей в JSON.
type item struct {
    Name     string  `json:"name"`
    Price    float64 `json:"price"`
    Quantity int     `json:"quantity"`
}

// Заказ встраивает данные покупателя и содержит слайс позиций.
type order struct {
    Customer string `json:"customer"`
    Items    []item `json:"items"`
}

// Метод-логика с value receiver: он ничего не меняет, только считает.
func (o order) total() float64 {
    var sum float64
    for _, it := range o.Items {
        sum += it.Price * float64(it.Quantity)
    }
    return sum
}

func main() {
    // Так выглядит тело входящего запроса.
    body := []byte(`{
        "customer": "Иван",
        "items": [
            {"name": "Клавиатура", "price": 2500, "quantity": 2},
            {"name": "Мышь", "price": 900, "quantity": 1}
        ]
    }`)

    var o order
    if err := json.Unmarshal(body, &o); err != nil {
        fmt.Println("некорректный JSON:", err)
        return
    }

    fmt.Printf("Заказ от %s на сумму %.2f
", o.Customer, o.total())
    // Заказ от Иван на сумму 5900.00

    // Формируем ответ отдельной структурой — так чище, чем править входную.
    resp := struct {
        Customer string  `json:"customer"`
        Total    float64 `json:"total"`
    }{
        Customer: o.Customer,
        Total:    o.total(),
    }
    out, _ := json.Marshal(resp)
    fmt.Println(string(out)) // {"customer":"Иван","total":5900}
}

Здесь видно всё вместе: структуры моделируют данные, теги управляют JSON, метод total() инкапсулирует логику, а анонимная структура удобно описывает форму ответа, которую не хочется заводить как отдельный тип.

Типичные ошибки

Ждать изменений от value receiver

Самая частая ловушка. Ты пишешь метод с (c counter), меняешь в нём поле, а снаружи ничего не меняется — потому что метод работал с копией. Если метод должен что-то менять, receiver обязан быть указателем: (c *counter).

Забыть про заглавную букву при JSON

Ты объявил поле name string с маленькой буквы и удивляешься, почему в JSON оно не появляется. Пакет encoding/json видит только экспортируемые (заглавные) поля. Пиши Name string и, если нужно другое имя в JSON, добавляй тег json:"name".

Передавать структуру в Unmarshal без указателя

Вызов json.Unmarshal(data, p) вместо json.Unmarshal(data, &p) либо не скомпилируется, либо ничего не заполнит. Unmarshal должен получить адрес, чтобы записать в твою переменную. Всегда передавай &p.

Строить глубокие иерархии встраивания

Соблазн собрать «наследование» в пять уровней вложенности через embedding приводит к неочевидным конфликтам имён и хрупкому коду. Встраивание — для переиспользования небольших кусочков поведения, а не для имитации классов. Держи иерархии плоскими.

Практика

  1. Объяви структуру book с полями Title, Author, Pages. Создай значение тремя способами: позиционно, по именам полей и через var с последующим заполнением. Напечатай каждое через %+v.
  2. Добавь к book метод isLong() bool, который возвращает true, если страниц больше 500. Выбери правильный тип receiver и объясни в комментарии почему.
  3. Сделай структуру wallet с полем balance int и методами deposit(n int) и withdraw(n int) error. Убедись, что баланс реально меняется между вызовами (подсказка: pointer receiver).
  4. Создай структуру author и встрой её в book. Проверь, что метод автора «поднимается» и вызывается прямо на книге.
  5. Опиши структуру product с тегами json (включая omitempty и -), сериализуй её в JSON, затем распарси обратно и убедись, что данные совпали.

Итог

Структуры — это способ Go описывать составные данные, а методы — способ добавлять к ним поведение без классов и наследования. Ты научился объявлять структуры, инициализировать их четырьмя способами (позиционно, по именам, через var, анонимно) и понял, почему нулевое значение структуры сразу готово к работе. Главный водораздел главы — value receiver против pointer receiver: копия против оригинала, и именно из этого различия растёт правило «хочешь менять — бери указатель». Встраивание заменяет наследование композицией, а теги и encoding/json связывают твои структуры с внешним миром через JSON. Всё это — базовый словарь, на котором написан любой реальный Go-сервис: в следующей главе мы добавим к структурам интерфейсы и получим полиморфизм.

Содержание серии (12)
{# Звук celebration (этап 4.3):