Воркшоп: моделирование школы, дробей и магазина
Этот урок не лекция, а воркшоп: три задачи на структуры, методы и композицию. Ты увидишь три типичные ситуации, и на каждой проиграешь один паттерн моделирования:
- Школа, агрегат с дочерними коллекциями. Когда у тебя есть “одно главное”, внутри которого слайс/мапа подчинённых.
- Обыкновенная дробь, value object без идентичности. Когда два объекта с одинаковыми полями это один и тот же объект.
- Онлайн-магазин, набор взаимодействующих типов с операциями. Когда корзина, продукт, скидка и заказ работают как единое целое.
Воркшоп идёт после 07-structs-methods-iota. Если не помнишь правил выбора receiver-а, вернись и просмотри §2.
1. Школа: агрегат и его дочерние коллекции
Задача: смоделировать школу, где есть учителя, ученики и расписание.
Ключевая идея, школа это aggregate root:
снаружи никто не лезет напрямую в students или teachers, всё идёт
через School-методы.
package school
import (
"errors"
"fmt"
"slices"
)
type Teacher struct {
ID int
Name string
Subject string
}
type Student struct {
ID int
Name string
Grade int // 1-11
Marks []int // оценки по последним работам
}
func (s Student) Avg() float64 {
if len(s.Marks) == 0 {
return 0
}
sum := 0
for _, m := range s.Marks {
sum += m
}
return float64(sum) / float64(len(s.Marks))
}
type School struct {
Name string
teachers []Teacher
students []Student
nextID int
}
func New(name string) *School {
return &School{Name: name}
}
func (s *School) HireTeacher(name, subject string) Teacher {
s.nextID++
t := Teacher{ID: s.nextID, Name: name, Subject: subject}
s.teachers = append(s.teachers, t)
return t
}
func (s *School) Enroll(name string, grade int) (Student, error) {
if grade < 1 || grade > 11 {
return Student{}, fmt.Errorf("invalid grade %d", grade)
}
s.nextID++
st := Student{ID: s.nextID, Name: name, Grade: grade}
s.students = append(s.students, st)
return st, nil
}
func (s *School) AddMark(studentID, mark int) error {
if mark < 1 || mark > 5 {
return fmt.Errorf("mark must be 1..5, got %d", mark)
}
for i := range s.students {
if s.students[i].ID == studentID {
s.students[i].Marks = append(s.students[i].Marks, mark)
return nil
}
}
return errors.New("student not found")
}
func (s *School) TopByGrade(grade, n int) []Student {
var byGrade []Student
for _, st := range s.students {
if st.Grade == grade {
byGrade = append(byGrade, st)
}
}
slices.SortFunc(byGrade, func(a, b Student) int {
switch {
case a.Avg() > b.Avg():
return -1
case a.Avg() < b.Avg():
return 1
default:
return 0
}
})
if len(byGrade) > n {
byGrade = byGrade[:n]
}
return byGrade
}
Что важно увидеть в этом коде:
- Поля
teachers,students,nextIDприватные (с маленькой буквы). Снаружи их не достать, только через методы. Schoolэто*Schoolвсегда. Все методы на pointer receiver, потому что мутируют. Согласованность.Student.Avg()value receiver, не мутирует, маленький.- Возврат
Student{}, errorдля невалидных параметров. Идиома Go. - Никаких сеттеров. Менять оценки можно только через
AddMark, не черезs.students[0].Marks = .... Снаружи в приватные поля лезть нельзя. slices.SortFuncэто Go 1.21+, удобный generic-вариант сортировки по компаратору.
Минимальный пользовательский код:
sch := school.New("Гимназия 42")
math := sch.HireTeacher("Vasya", "Math")
_ = math
al, _ := sch.Enroll("Alice", 9)
sch.Enroll("Bob", 9)
sch.Enroll("Cara", 9)
sch.AddMark(al.ID, 5)
sch.AddMark(al.ID, 4)
top := sch.TopByGrade(9, 3)
for _, s := range top {
fmt.Printf("%s avg=%.2f\n", s.Name, s.Avg())
}
Чек: где бы тебе было больно
- Если бы
studentsбыло публичное (Students []Student), кто угодно снаружи мог бы добавить ученика мимоEnroll, обходя валидацию класса. - Если бы
nextIDбыло публичное, ID можно было бы переустановить и сломать уникальность. - Если бы методы были на value receiver,
s.HireTeacherне сохранил бы учителя в структуре снаружи.
2. Обыкновенная дробь: value object
Задача: тип Fraction для арифметики простых дробей. Главная идея,
дробь это value object:
у дроби нет идентичности, две дроби с одинаковыми полями равны.
package fraction
import (
"errors"
"fmt"
)
type Fraction struct {
n, d int
}
func New(n, d int) (Fraction, error) {
if d == 0 {
return Fraction{}, errors.New("zero denominator")
}
return reduce(Fraction{n: n, d: d}), nil
}
func gcd(a, b int) int {
if a < 0 {
a = -a
}
if b < 0 {
b = -b
}
for b != 0 {
a, b = b, a%b
}
return a
}
func reduce(f Fraction) Fraction {
g := gcd(f.n, f.d)
if g == 0 {
return f
}
f.n /= g
f.d /= g
if f.d < 0 { // нормализуем знак: знаменатель всегда положительный
f.n = -f.n
f.d = -f.d
}
return f
}
func (f Fraction) Add(o Fraction) Fraction {
return reduce(Fraction{n: f.n*o.d + o.n*f.d, d: f.d * o.d})
}
func (f Fraction) Sub(o Fraction) Fraction {
return reduce(Fraction{n: f.n*o.d - o.n*f.d, d: f.d * o.d})
}
func (f Fraction) Mul(o Fraction) Fraction {
return reduce(Fraction{n: f.n * o.n, d: f.d * o.d})
}
func (f Fraction) Div(o Fraction) (Fraction, error) {
if o.n == 0 {
return Fraction{}, errors.New("divide by zero")
}
return reduce(Fraction{n: f.n * o.d, d: f.d * o.n}), nil
}
func (f Fraction) Equal(o Fraction) bool {
return f.n == o.n && f.d == o.d
}
func (f Fraction) Less(o Fraction) bool {
return f.n*o.d < o.n*f.d
}
func (f Fraction) String() string {
if f.d == 1 {
return fmt.Sprintf("%d", f.n)
}
return fmt.Sprintf("%d/%d", f.n, f.d)
}
Что важно:
- Все методы value receiver. Дробь immutable:
f.Add(g)возвращает новую дробь, не меняетf. reduceприватная. Нормализация знака и сокращение, инвариант типа.Add/Sub/Mul/Divсами вызываютreduce. Снаружи нельзя получить не сокращённую дробь.String()метод даёт автоформатирование черезfmt.Println. Этоfmt.Stringer(увидим в 10-interfaces).Equalнужен, потому что==сравнил бы поля побайтно, и если бы где-то остался не сокращённый вид, две одинаковые дроби оказались бы не равны.
Использование:
half, _ := fraction.New(1, 2)
third, _ := fraction.New(1, 3)
sum := half.Add(third) // 5/6
fmt.Println(sum) // "5/6"
fmt.Println(sum.Equal(half)) // false
Чек: где бы тебе было больно
- Если бы
n,dбыли публичные, кто угодно мог бы создатьFraction{2, 4}мимоNew, иEqualначал бы давать ложь. - Если бы методы были на pointer receiver,
f.Add(o)мутировало бы оригинал, и тип перестал бы быть value object. - Если бы не было
String(),fmt.Println(half)печатал бы{1 2}вместо1/2.
премиум-урок
Продолжение в премиуме: 1 200 ₽ в месяц или 10 000 ₽ в год. Вместе с ним серверные проверки задач и школьные токены AI-коуча.
Оплата пока не подключена: премиум и курсы открываются промокодом.