Раздел 22 · Go

Воркшоп: моделирование школы, дробей и магазина

junior~70 мин

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

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

Воркшоп: моделирование школы, дробей и магазина

Этот урок не лекция, а воркшоп: три задачи на структуры, методы и композицию. Ты увидишь три типичные ситуации, и на каждой проиграешь один паттерн моделирования:

  1. Школа, агрегат с дочерними коллекциями. Когда у тебя есть “одно главное”, внутри которого слайс/мапа подчинённых.
  2. Обыкновенная дробь, value object без идентичности. Когда два объекта с одинаковыми полями это один и тот же объект.
  3. Онлайн-магазин, набор взаимодействующих типов с операциями. Когда корзина, продукт, скидка и заказ работают как единое целое.

Воркшоп идёт после 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-коуча.

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