Раздел 22 · Go

Структуры, методы, iota: ООП по-Go-вски

junior~60 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Перед этим уроком

Структуры, методы, iota: ООП по-Go-вски

В Go нет классов, нет наследования, нет конструкторов. Но есть struct, есть методы, есть композиция, и есть iota для перечислений. Этот набор покрывает 95% того, ради чего ты в других языках использовал классы. И он проще: меньше иерархий, меньше “магии”, меньше способов сделать одно и то же.

После этого урока тебе будет с чем идти в 08-modeling-workshop и моделировать школу, дроби и магазин на структурах.

1. Что такое struct

struct это именованный набор полей:

type User struct {
    ID    int
    Name  string
    Email string
}

Создать значение:

u1 := User{ID: 1, Name: "Vasya", Email: "v@mail"} // именованные поля
u2 := User{2, "Lena", "l@mail"}                   // позиционные, хрупко
u3 := User{}                                       // zero value: 0, "", ""
u4 := &User{Name: "Bob"}                           // указатель на User в heap

Доступ к полям через точку, и через указатель тоже точка (Go автоматом разыменовывает):

fmt.Println(u1.Name)   // "Vasya"
fmt.Println(u4.Name)   // "Bob", не нужно (*u4).Name

Zero value, важная идея

В Go нет конструкторов: zero value структуры это полезный default. Именно поэтому в stdlib часто встречаешь типы, которые работают сразу:

var sb strings.Builder // готов к использованию, без NewBuilder()
sb.WriteString("hi")

var mu sync.Mutex      // готов к использованию
mu.Lock()
defer mu.Unlock()

Идиома “make zero value useful” это Go Proverb: если ты можешь сделать zero value полезным, делай. Это убирает кучу конструктор-функций.

Anonymous structs

Иногда нужна структура только в одном месте, имя ей не нужно:

opts := struct {
    Verbose bool
    Retries int
}{
    Verbose: true,
    Retries: 3,
}

Чаще всего ты увидишь анонимные структуры в табличных тестах:

cases := []struct {
    name string
    in   string
    want int
}{
    {"empty", "", 0},
    {"one", "a", 1},
}

Struct tags

К полю можно прицепить тэг, строку с метаданными:

type User struct {
    ID   int    `json:"id"`
    Name string `json:"name,omitempty"`
}

Тэги читаются через рефлексию (encoding/json, gorm, validator и другие). Сами по себе они не делают ничего, ровно текст. Подробно в 22-encoding-compression.

2. Методы: функции с особым параметром

Методы прикрепляются к типу снаружи, не внутри блока определения:

type Counter struct {
    n int
}

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

func (c *Counter) Inc() {
    c.n++
}

(c Counter) или (c *Counter) это receiver (приёмник), аналогичный this/self в других языках. Имя у него любое, чаще короткое (одна-две буквы). Конвенция: первая буква имени типа.

Вызов выглядит как метод:

c := Counter{}
c.Inc()
fmt.Println(c.Value()) // 1

Go сам разыменовывает указатели и берёт адрес, когда это безопасно:

c := Counter{}
c.Inc()       // на самом деле (&c).Inc()

cp := &Counter{}
cp.Value()    // на самом деле (*cp).Value()

Value receiver vs pointer receiver

Ключевой выбор. Шпаргалка:

БерёшьКогда
(c Counter) valueметод не меняет состояние, структура маленькая
(c *Counter) pointerметод меняет состояние или структура большая

Подробное правило (Effective Go):

  1. Если метод мутирует, всегда pointer receiver. Иначе мутация уйдёт в копию и потеряется.
  2. Если структура содержит mutex, sync.Map, что-то “не копируемое”, всегда pointer receiver.
  3. Если структура большая (десятки полей или массив), pointer receiver, чтобы не копировать.
  4. Если у типа есть хоть один pointer receiver, делай все остальные тоже pointer. Согласованность важнее эстетики.

Пример мутации, которая теряется:

type V struct{ n int }

func (v V) Bad()  { v.n = 99 } // меняет копию
func (v *V) Good(){ v.n = 99 } // меняет оригинал

a := V{}
a.Bad()
fmt.Println(a.n) // 0, мутация ушла

b := V{}
b.Good()
fmt.Println(b.n) // 99

Методы у не-struct

Метод можно прицепить к любому типу, объявленному в том же пакете:

type Celsius float64

func (c Celsius) Fahrenheit() float64 {
    return float64(c)*9/5 + 32
}

t := Celsius(100)
fmt.Println(t.Fahrenheit()) // 212

Это часто используют чтобы дать смысл “просто числу” или “просто строке”. Не нужно создавать struct, если хватает обёртки над примитивом.

3. Композиция вместо наследования

В Go нет наследования. Но есть embedding, встраивание:

type Animal struct {
    Name string
}

func (a Animal) Greet() string {
    return "hello, I am " + a.Name
}

type Dog struct {
    Animal     // встроенное поле, без имени
    Breed string
}

d := Dog{Animal: Animal{Name: "Rex"}, Breed: "lab"}
fmt.Println(d.Greet())   // "hello, I am Rex" -- вызвался метод Animal
fmt.Println(d.Name)      // "Rex" -- поле тоже доступно "наружу"

Что это даёт:

  • Делегирование без явного forwarding. Все методы Animal автоматически доступны на Dog.
  • Поле тоже встраивается, d.Name это сахар для d.Animal.Name.
  • Можно перекрыть метод: если у Dog будет свой Greet, он победит Animal.Greet. Но это не “переопределение”, Animal.Greet всё ещё доступен явно как d.Animal.Greet().

Что это не наследование:

  • нет иерархии, нет “is-a”, нет полиморфизма по типу;
  • нет super, нет автоматического вызова base-метода;
  • если перекрыл метод, base сам не вызовется, надо явно дёрнуть.

Канонический пример из stdlib, bufio.Reader и *os.File:

type LoggedReader struct {
    io.Reader // встроили интерфейс
}

func (l LoggedReader) Read(p []byte) (int, error) {
    n, err := l.Reader.Read(p)
    fmt.Fprintf(os.Stderr, "read %d bytes\n", n)
    return n, err
}

LoggedReader сам реализует io.Reader, потому что встроил его. И при этом перекрыл Read, добавив логирование. Готовый decorator.

премиум-урок

Продолжение в премиуме: 1 200 ₽ в месяц или 10 000 ₽ в год. Вместе с ним серверные проверки задач и школьные токены AI-коуча.

Оплата пока не подключена: премиум и курсы открываются промокодом.