Структуры, методы, 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):
- Если метод мутирует, всегда pointer receiver. Иначе мутация уйдёт в копию и потеряется.
- Если структура содержит mutex, sync.Map, что-то “не копируемое”, всегда pointer receiver.
- Если структура большая (десятки полей или массив), pointer receiver, чтобы не копировать.
- Если у типа есть хоть один 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-коуча.
Оплата пока не подключена: премиум и курсы открываются промокодом.