Структуры (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, как импортировать чужой код и публиковать свой. Это шаг от учебных программ к реальным проектам!