$ sudo teach IT
МОДУЛЬ 3 · УРОК 3.4

Структуры (structs)

Учимся объединять данные в единый тип, добавлять методы, вкладывать структуры друг в друга и размечать поля тегами

За три предыдущих урока мы научились хранить коллекции однотипных данных: массивы и срезы для упорядоченных списков, map для пар «ключ-значение», строки для текста. Но в реальных программах данные редко бывают однотипными. У студента есть имя (строка), возраст (число), список оценок (срез). У товара — название, цена, количество на складе. Чтобы описать такой составной объект, в Go используются структуры (struct). Это один из важнейших инструментов языка, и сегодня мы разберём его до самого основания: от объявления и инициализации до методов, вложенных структур и тегов полей.

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

Структура — это пользовательский тип данных, который объединяет несколько полей разных типов под одним именем. Представьте себе анкету: на одном листе записаны имя, фамилия, дата рождения и номер телефона. Структура — это такая анкета в коде.

Объявляется структура с помощью ключевых слов type и struct:

package main

import "fmt"

// Объявляем новый тип — структуру Person
type Person struct {
    Name string
    Age  int
    City string
}

func main() {
    // Создаём переменную типа Person
    var p Person
    fmt.Println(p) // { 0 } — нулевые значения полей
}

Когда мы объявляем переменную типа Person без явной инициализации, Go автоматически заполняет все поля нулевыми значениями: пустая строка для string, 0 для int. Это знакомое поведение — мы уже видели его для срезов и map.

💡 Подсказка: Имена полей структуры принято писать с заглавной буквы (Name, а не name). В Go заглавная буква означает, что поле экспортируется — доступно из других пакетов. Мы подробно разберём это в модуле 4. Пока просто используйте заглавные буквы.

🔧 Способы создания структуры

Go предлагает несколько способов инициализации структуры. Рассмотрим каждый:

package main

import "fmt"

type Person struct {
    Name string
    Age  int
    City string
}

func main() {
    // Способ 1: по именам полей (рекомендуемый!)
    p1 := Person{
        Name: "Анна",
        Age:  25,
        City: "Москва",
    }
    fmt.Println(p1) // {Анна 25 Москва}

    // Способ 2: по порядку полей (хрупкий — не рекомендуется)
    p2 := Person{"Иван", 30, "Казань"}
    fmt.Println(p2) // {Иван 30 Казань}

    // Способ 3: частичная инициализация
    p3 := Person{Name: "Мария"}
    fmt.Println(p3) // {Мария 0 } — Age = 0, City = ""

    // Способ 4: через var + присваивание полей
    var p4 Person
    p4.Name = "Олег"
    p4.Age = 22
    p4.City = "Новосибирск"
    fmt.Println(p4) // {Олег 22 Новосибирск}
}

Способ 1 (по именам полей) — самый надёжный. Если вы позже добавите в структуру новое поле, код не сломается.

Способ 2 (по порядку) — опасный. Стоит поменять порядок полей в определении структуры, и ваш код начнёт подставлять значения не в те поля.

Способ 3 — удобен, когда не все данные известны сразу. Неуказанные поля получат нулевые значения.

Обратите внимание на запятую после последнего поля при инициализации по именам (City: "Москва",). В Go она обязательна, если закрывающая фигурная скобка стоит на отдельной строке. Компилятор выдаст ошибку, если вы её забудете.

Доступ к полям осуществляется через точку:

fmt.Println(p1.Name) // Анна
fmt.Println(p1.Age)  // 25

p1.Age = 26 // изменяем поле
fmt.Println(p1.Age)  // 26

🔗 Указатели на структуры

Когда вы передаёте структуру в функцию, Go создаёт её копию. Это значит, что изменения внутри функции не затронут оригинал. Если вы хотите изменить структуру — передавайте указатель.

package main

import "fmt"

type Person struct {
    Name string
    Age  int
}

// Принимает КОПИЮ — оригинал не изменится
func birthdayCopy(p Person) {
    p.Age++
    fmt.Println("Внутри функции:", p.Age)
}

// Принимает УКАЗАТЕЛЬ — изменит оригинал
func birthdayPointer(p *Person) {
    p.Age++
    fmt.Println("Внутри функции:", p.Age)
}

func main() {
    person := Person{Name: "Анна", Age: 25}

    birthdayCopy(person)
    fmt.Println("После birthdayCopy:", person.Age)   // 25 — не изменился!

    birthdayPointer(&person)
    fmt.Println("После birthdayPointer:", person.Age) // 26 — изменился!
}

Вывод программы:

Внутри функции: 26
После birthdayCopy: 25
Внутри функции: 26
После birthdayPointer: 26

Обратите внимание: при работе с указателем на структуру Go позволяет обращаться к полям напрямую через точку (p.Age), без явного разыменования ((*p).Age). Это синтаксический сахар, который делает код чище.

Создать указатель на структуру можно также через & при инициализации:

p := &Person{Name: "Иван", Age: 30}
// p имеет тип *Person
fmt.Println(p.Name) // Иван — работает без разыменования

⚙️ Методы на структурах

В Go нет классов, но есть методы. Метод — это обычная функция, привязанная к определённому типу. Привязка задаётся через получатель (receiver), который пишется в скобках перед именем функции.

package main

import "fmt"

type Rectangle struct {
    Width  float64
    Height float64
}

// Метод Area привязан к типу Rectangle
// (r Rectangle) — это получатель (receiver)
func (r Rectangle) Area() float64 {
    return r.Width * r.Height
}

func (r Rectangle) Perimeter() float64 {
    return 2 * (r.Width + r.Height)
}

func main() {
    rect := Rectangle{Width: 10, Height: 5}
    fmt.Println("Площадь:", rect.Area())       // 50
    fmt.Println("Периметр:", rect.Perimeter()) // 30
}

Метод вызывается через точку, как обращение к полю. Синтаксис (r Rectangle) перед именем функции означает: «эта функция принадлежит типу Rectangle». Переменная r внутри метода — это экземпляр структуры, на котором метод вызван.

Теперь — ключевой вопрос: value receiver vs pointer receiver.

Value receiver (r Rectangle) — метод получает копию структуры. Не может изменить оригинал. Используется для чтения данных (геттеры, вычисления).

Pointer receiver (r *Rectangle) — метод получает указатель на структуру. Может изменять оригинал. Используется, когда нужно менять состояние.

Вот пример, который показывает разницу наглядно:

package main

import "fmt"

type Counter struct {
    Value int
}

// Value receiver — работает с копией
func (c Counter) IncrementCopy() {
    c.Value++ // изменяет КОПИЮ, оригинал не тронут
}

// Pointer receiver — работает с оригиналом
func (c *Counter) Increment() {
    c.Value++ // изменяет ОРИГИНАЛ
}

func (c Counter) GetValue() int {
    return c.Value
}

func main() {
    counter := Counter{Value: 0}

    counter.IncrementCopy()
    fmt.Println("После IncrementCopy:", counter.GetValue()) // 0 — не изменился!

    counter.Increment()
    fmt.Println("После Increment:", counter.GetValue())     // 1 — изменился!

    counter.Increment()
    counter.Increment()
    fmt.Println("После ещё двух Increment:", counter.GetValue()) // 3
}

💡 Когда что выбирать? Если метод должен изменять структуру — используйте pointer receiver. Если метод только читает данные — подойдёт value receiver. На практике часто все методы делают с pointer receiver для единообразия, особенно если структура большая (копирование будет дорогим).

Go автоматически подставляет & при вызове метода с pointer receiver. Не нужно писать (&counter).Increment() — достаточно counter.Increment(). Компилятор сам разберётся.

🏗 Функция-конструктор

В Go нет встроенных конструкторов, как в Java или Python. Вместо этого принято писать обычную функцию, которая создаёт и возвращает структуру. По соглашению такая функция начинается со слова New:

package main

import "fmt"

type User struct {
    Name  string
    Email string
    Age   int
}

// Функция-конструктор — возвращает указатель на новую структуру
func NewUser(name, email string, age int) *User {
    return &User{
        Name:  name,
        Email: email,
        Age:   age,
    }
}

func (u User) String() string {
    return fmt.Sprintf("%s (%s), %d лет", u.Name, u.Email, u.Age)
}

func main() {
    user := NewUser("Мария", "maria@example.com", 28)
    fmt.Println(user.String()) // Мария (maria@example.com), 28 лет
}

Функция-конструктор удобна тем, что в ней можно добавить валидацию, значения по умолчанию или любую другую логику инициализации. Она возвращает *User (указатель), а не User (копию) — это общепринятая практика в Go.

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

Структуры можно вкладывать друг в друга. Это позволяет моделировать более сложные объекты. Например, у пользователя есть адрес, а адрес — это тоже структура:

package main

import "fmt"

type Address struct {
    City   string
    Street string
    Zip    string
}

type Employee struct {
    Name    string
    Age     int
    Address Address // вложенная структура
}

func main() {
    emp := Employee{
        Name: "Алексей",
        Age:  35,
        Address: Address{
            City:   "Санкт-Петербург",
            Street: "Невский проспект",
            Zip:    "190000",
        },
    }

    fmt.Println(emp.Name)           // Алексей
    fmt.Println(emp.Address.City)   // Санкт-Петербург
    fmt.Println(emp.Address.Street) // Невский проспект
}

Доступ к полям вложенной структуры идёт через цепочку точек: emp.Address.City.

Go поддерживает ещё один приём — встраивание (embedding). Если указать тип без имени поля, его поля «всплывают» на уровень внешней структуры:

package main

import "fmt"

type Address struct {
    City   string
    Street string
}

type Employee struct {
    Name string
    Age  int
    Address // встроенная структура — без имени поля!
}

func main() {
    emp := Employee{
        Name: "Алексей",
        Age:  35,
        Address: Address{
            City:   "Санкт-Петербург",
            Street: "Невский проспект",
        },
    }

    // Можно обращаться напрямую — без промежуточного Address
    fmt.Println(emp.City)   // Санкт-Петербург
    fmt.Println(emp.Street) // Невский проспект

    // Но длинный путь тоже работает
    fmt.Println(emp.Address.City) // Санкт-Петербург
}

💡 Подсказка: Встраивание — это не наследование. Go не поддерживает наследование в классическом понимании. Встраивание — это удобный способ «одолжить» поля и методы другой структуры. Если во встроенной структуре есть метод, он тоже станет доступен напрямую.

📚 Срез структур

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

package main

import "fmt"

type Product struct {
    Name  string
    Price float64
}

func main() {
    products := []Product{
        {Name: "Ноутбук", Price: 75000},
        {Name: "Мышь", Price: 1500},
        {Name: "Клавиатура", Price: 3500},
        {Name: "Монитор", Price: 25000},
    }

    // Найдём самый дорогой товар
    maxProduct := products[0]
    for _, p := range products {
        if p.Price > maxProduct.Price {
            maxProduct = p
        }
    }
    fmt.Printf("Самый дорогой: %s (%.0f руб.)\n", maxProduct.Name, maxProduct.Price)

    // Посчитаем общую стоимость
    total := 0.0
    for _, p := range products {
        total += p.Price
    }
    fmt.Printf("Общая стоимость: %.0f руб.\n", total)
}

Вывод:

Самый дорогой: Ноутбук (75000 руб.)
Общая стоимость: 105000 руб.

🏷 Теги структур

Поля структуры могут иметь теги — строковые метаданные, которые не влияют на логику программы напрямую, но используются библиотеками для настройки поведения. Самый распространённый пример — тег json, который указывает, как поле называется при сериализации в JSON:

package main

import (
    "encoding/json"
    "fmt"
)

type User struct {
    Name  string `json:"name"`
    Email string `json:"email"`
    Age   int    `json:"age"`
}

func main() {
    user := User{
        Name:  "Анна",
        Email: "anna@example.com",
        Age:   25,
    }

    // Структура → JSON
    data, _ := json.Marshal(user)
    fmt.Println(string(data))
    // {"name":"Анна","email":"anna@example.com","age":25}
}

Без тегов JSON-ключи совпадали бы с именами полей: Name, Email, Age (с заглавной буквы). Теги позволяют задать нужные имена: name, email, age.

Тег пишется в обратных кавычках после типа поля. Можно указать несколько тегов:

type Product struct {
    ID    int     `json:"id" db:"product_id"`
    Name  string  `json:"name" db:"product_name"`
    Price float64 `json:"price" db:"price"`
}

json:"name" — имя поля при сериализации в JSON (пакет encoding/json)

db:"product_id" — имя колонки в базе данных (используется ORM-библиотеками)

json:"-" — поле будет пропущено при сериализации (полезно для паролей, секретов)

Вот пример с пропуском поля и omitempty:

package main

import (
    "encoding/json"
    "fmt"
)

type User struct {
    Name     string `json:"name"`
    Password string `json:"-"`              // не попадёт в JSON!
    Phone    string `json:"phone,omitempty"` // пропустить, если пустое
}

func main() {
    user := User{
        Name:     "Олег",
        Password: "secret123",
        Phone:    "",
    }

    data, _ := json.Marshal(user)
    fmt.Println(string(data))
    // {"name":"Олег"} — пароля нет, телефона нет (пустой)
}

💡 Подсказка: Сейчас вам не нужно знать теги досконально. Главное — понимать, что они существуют и зачем нужны. Вы будете активно использовать их при работе с JSON API и базами данных в следующих модулях.

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

Структуры в Go можно сравнивать оператором ==, если все их поля сравнимы (числа, строки, bool). Сравнение происходит поле за полем:

package main

import "fmt"

type Point struct {
    X int
    Y int
}

func main() {
    a := Point{X: 1, Y: 2}
    b := Point{X: 1, Y: 2}
    c := Point{X: 3, Y: 4}

    fmt.Println(a == b) // true  — все поля совпадают
    fmt.Println(a == c) // false — поля отличаются
}

Если в структуре есть несравнимые поля (срез, map, функция), то оператор == использовать нельзя — будет ошибка компиляции. В таких случаях нужно писать собственную функцию сравнения.

🛠 Практика: структура «Студент» с подсчётом среднего балла

Пришло время собрать всё воедино. Напишем программу, которая моделирует студентов: у каждого есть имя, возраст и список оценок. Мы создадим методы для подсчёта среднего балла, добавления оценки и вывода информации.

package main

import "fmt"

// Структура Студент
type Student struct {
    Name   string
    Age    int
    Grades []float64
}

// Конструктор — создаёт нового студента
func NewStudent(name string, age int) *Student {
    return &Student{
        Name:   name,
        Age:    age,
        Grades: []float64{},
    }
}

// Pointer receiver — добавляет оценку (изменяет структуру)
func (s *Student) AddGrade(grade float64) {
    if grade < 0 || grade > 100 {
        fmt.Println("Ошибка: оценка должна быть от 0 до 100")
        return
    }
    s.Grades = append(s.Grades, grade)
}

// Value receiver — вычисляет средний балл (не изменяет структуру)
func (s Student) Average() float64 {
    if len(s.Grades) == 0 {
        return 0
    }
    sum := 0.0
    for _, g := range s.Grades {
        sum += g
    }
    return sum / float64(len(s.Grades))
}

// Value receiver — находит лучшую оценку
func (s Student) BestGrade() float64 {
    if len(s.Grades) == 0 {
        return 0
    }
    best := s.Grades[0]
    for _, g := range s.Grades {
        if g > best {
            best = g
        }
    }
    return best
}

// Value receiver — проверяет, сдал ли студент (средний балл >= 60)
func (s Student) IsPassing() bool {
    return s.Average() >= 60
}

// Value receiver — красивый вывод информации
func (s Student) Info() string {
    status := "НЕ СДАЛ"
    if s.IsPassing() {
        status = "СДАЛ"
    }
    return fmt.Sprintf(
        "%s, %d лет | Оценки: %v | Средний: %.1f | Лучшая: %.0f | %s",
        s.Name, s.Age, s.Grades, s.Average(), s.BestGrade(), status,
    )
}

func main() {
    // Создаём студентов
    alice := NewStudent("Алиса", 20)
    alice.AddGrade(85)
    alice.AddGrade(92)
    alice.AddGrade(78)
    alice.AddGrade(95)

    bob := NewStudent("Борис", 21)
    bob.AddGrade(55)
    bob.AddGrade(42)
    bob.AddGrade(60)

    clara := NewStudent("Клара", 19)
    clara.AddGrade(100)
    clara.AddGrade(98)
    clara.AddGrade(95)
    clara.AddGrade(100)

    // Собираем в срез
    students := []*Student{alice, bob, clara}

    // Выводим информацию о каждом
    fmt.Println("=== Ведомость студентов ===")
    fmt.Println()
    for _, s := range students {
        fmt.Println(s.Info())
    }

    // Находим лучшего студента
    fmt.Println()
    best := students[0]
    for _, s := range students {
        if s.Average() > best.Average() {
            best = s
        }
    }
    fmt.Printf("Лучший студент: %s (средний балл %.1f)\n", best.Name, best.Average())
}

Вывод программы:

=== Ведомость студентов ===

Алиса, 20 лет | Оценки: [85 92 78 95] | Средний: 87.5 | Лучшая: 95 | СДАЛ
Борис, 21 лет | Оценки: [55 42 60] | Средний: 52.3 | Лучшая: 60 | НЕ СДАЛ
Клара, 19 лет | Оценки: [100 98 95 100] | Средний: 98.2 | Лучшая: 100 | СДАЛ

Лучший студент: Клара (средний балл 98.2)

Разберём ключевые моменты:

AddGrade использует pointer receiver (*Student), потому что изменяет срез Grades. Если бы receiver был value — изменения бы не сохранились.

Average, BestGrade, IsPassing, Info используют value receiver (Student), потому что только читают данные. Они ничего не меняют.

NewStudent возвращает *Student (указатель). Это позволяет вызывать на нём методы с pointer receiver.

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

Попробуйте самостоятельно добавить: метод WorstGrade(), подсчёт количества отличных оценок (выше 90), или метод RemoveGrade(index int) для удаления оценки по индексу.

📋 Итоги урока

  • Структура (struct) — пользовательский тип, объединяющий поля разных типов
  • Инициализация по именам полей Person{Name: "Анна", Age: 25} — самый надёжный способ
  • Структура передаётся в функцию по значению (копия); для изменения передавайте указатель *Person
  • Методы привязываются к типу через получатель: func (r Rectangle) Area()
  • Value receiver (r Rectangle) — для чтения, pointer receiver (r *Rectangle) — для изменения
  • Функция-конструктор NewUser(...) — общепринятый способ создания структур с инициализацией
  • Вложенные структуры позволяют моделировать сложные объекты; встраивание — для «всплытия» полей
  • Теги полей (`json:"name"`) — метаданные для библиотек (JSON, базы данных)
  • Структуры можно сравнивать через ==, если все поля сравнимы
  • Мы написали полноценную структуру «Студент» с методами и валидацией

Этим уроком мы завершаем Модуль 3 «Структуры данных». Вы освоили массивы, срезы, map, строки и структуры — фундамент для любой программы на Go. В следующем модуле мы перейдём к организации кода. Урок 4.1 — Пакеты и модули: как разделить программу на файлы и пакеты, что такое go mod, как импортировать чужой код и публиковать свой. Это шаг от учебных программ к реальным проектам!