1. Почему “просто struct” — ещё не модель
Когда вы впервые создаёте структуру, очень хочется думать так: “Ну всё, у меня есть Task, значит задача всегда корректная”. Увы, реальность обычно язвительнее компилятора. Структура — это контейнер для полей, а модель — это контейнер + правила. Эти правила и называются инвариантами.
Что такое инварианты модели простыми словами
Инвариант — это условие, которое должно быть истинным для каждого “правильного” значения вашего типа. Например, если у вас есть задача, то почти наверняка “пустой заголовок” — это не задача, а печаль.
И тут важная мысль: zero value структуры технически корректен, но модельно может быть мусором. Task{} компилируется и живёт, но это не значит, что его стоит сохранять в память или показывать пользователю.
Представьте, что struct — это коробка, а инварианты — наклейка “хрупкое, верх/низ, не кантовать”. Коробка без наклейки тоже коробка, но шанс, что внутри доедут осколки, заметно выше.
2. Инварианты на примере: модель “Задача” для мини‑CLI
Чтобы не говорить абстрактно, давайте договоримся о маленькой предметной области. Пусть мы пишем учебное консольное приложение “список задач”: добавляем задачу, печатаем список, отмечаем выполненной. Мы не делаем “идеальную архитектуру”, нам важно научиться защищать данные.
Мини-модель Task и её смысловые правила
Начнём с простого: у задачи есть ID, заголовок и статус. В Go это естественно выразить структурой и набором констант для статуса.
package main
import "fmt"
type Status int
const (
StatusTodo Status = iota + 1
StatusDone
)
type Task struct {
ID int
Title string
Status Status
}
func main() {
t := Task{ID: 1, Title: "прочитать про инварианты", Status: StatusTodo}
fmt.Println(t.ID, t.Title, t.Status) // 1 прочитать про инварианты 1
}
Тут уже можно сформулировать инварианты (правила корректности), не заглядывая в “следующие темы” и не городя сложные штуки:
ID должен быть положительным (ID > 0), заголовок не должен быть пустым после TrimSpace, а статус должен быть только из разрешённых значений (StatusTodo или StatusDone). Это звучит банально, но именно из таких банальностей и растут “необъяснимые баги”.
Табличка “инвариант → зачем → где проверять”
Немного структуры (простите за каламбур) помогает мозгу.
| Инвариант | Зачем он нужен | Где проверять |
|---|---|---|
|
отличаем “реальную” запись от мусора и дефолтов | при создании задачи и при чтении ID из ввода |
после |
“пустая задача” — обычно ошибка пользователя | при создании/обновлении заголовка |
|
иначе состояние задачи становится непредсказуемым | при создании и при изменении статуса |
3. Валидация “на границе”: где именно проверять данные
Новички часто валидируют данные “где-то потом”. Это как обещание “я потом помою посуду”: иногда да, но чаще в раковине появляется новая цивилизация. В программировании удобнее и надёжнее валидировать на границе — в момент, когда данные входят в вашу систему.
Что считается “границей”
В консольном приложении граница — это ввод пользователя (stdin) и любые внешние источники данных. Сейчас мы работаем только с вводом, но принцип универсальный: чем раньше вы проверили данные, тем меньше мест в программе могут “взорваться”.
Схема выглядит так:
flowchart TD
A[stdin: строка от пользователя] --> B[parse: числа/строки]
B --> C[normalize: TrimSpace и т.п.]
C --> D[validate: инварианты]
D --> E[create/execute: бизнес-логика]
E --> F[stdout: результат]
Если валидация стоит после бизнес-логики, то бизнес-логика начинает работать с мусором. А бизнес-логика, работающая с мусором, обычно и сама начинает вести себя как мусор.
4. Паттерны валидации в Go: Validate и New
Теперь самое практичное: как оформить проверку. В Go очень любят простой контракт: функция возвращает error, а nil означает “всё ок”. Это продолжение идеи “ошибки — значения” из Go‑мира: мы создаём ошибку и возвращаем её наверх, если что-то не так.
Контракт валидации: ValidateX(x X) error
Сделаем функцию валидации. Она либо возвращает nil, либо объясняет, что именно не так. Ошибки делаем короткими и конкретными: так их проще показывать пользователю и тестировать.
package main
import (
"errors"
"strings"
)
type Status int
const (
StatusTodo Status = iota + 1
StatusDone
)
type Task struct {
ID int
Title string
Status Status
}
func ValidateTask(t Task) error {
if t.ID <= 0 {
return errors.New("id must be positive")
}
if strings.TrimSpace(t.Title) == "" {
return errors.New("title is empty")
}
if t.Status != StatusTodo && t.Status != StatusDone {
return errors.New("unknown status")
}
return nil
}
func main() {}
Обратите внимание: мы специально проверяем заголовок через TrimSpace, потому что строка " " технически не пустая, но по смыслу — пустая.
Почему error, а не bool
Иногда хочется сделать ValidateTask(t Task) bool и вернуть true/false. Но тогда вы теряете ответ на вопрос “почему невалидно?”. В реальном приложении это превращается в грустный UX: “ошибка” и всё. error позволяет рассказать причину, а Go‑подход прямо поощряет добавлять контекст, когда вы возвращаете ошибку выше.
Паттерн “создать и проверить”: NewX(...)(X, error)
Проверять можно не только готовую структуру, но и сам процесс создания оформить так, чтобы “неправильную задачу” было трудно получить случайно. Для этого часто делают функцию-конструктор (не метод, просто функция), которая создаёт значение и валидирует его.
Здесь же удобно сделать нормализацию (например, TrimSpace) ровно один раз — и больше не помнить об этом в других местах.
package main
import (
"errors"
"fmt"
"strings"
)
type Status int
const (
StatusTodo Status = iota + 1
StatusDone
)
type Task struct {
ID int
Title string
Status Status
}
func ValidateTask(t Task) error {
if t.ID <= 0 {
return errors.New("id must be positive")
}
if strings.TrimSpace(t.Title) == "" {
return errors.New("title is empty")
}
if t.Status != StatusTodo && t.Status != StatusDone {
return errors.New("unknown status")
}
return nil
}
func NewTask(id int, title string) (Task, error) {
t := Task{
ID: id,
Title: strings.TrimSpace(title),
Status: StatusTodo,
}
if err := ValidateTask(t); err != nil {
return Task{}, err
}
return t, nil
}
func main() {
t, err := NewTask(1, " Learn Go ")
if err != nil {
fmt.Println("error:", err)
return
}
fmt.Printf("%+v\n", t) // {ID:1 Title:Learn Go Status:1}
}
Здесь важный момент: если NewTask возвращает ошибку, мы возвращаем Task{} (zero value) вместе с err. Это дисциплинирует вызывающий код: “если ошибка — не трогай значение, оно пустое”.
5. Валидация ввода пользователя: “read → parse → validate → build”
Сейчас у нас есть модель и её правила. Следующий шаг — применить их на границе, то есть при вводе. В консольных задачах классика — читать строку, парсить, проверять.
Пример: создать задачу из двух строк ввода
Пусть пользователь вводит id и title. Мы читаем их как строки, парсим id, затем вызываем NewTask.
package main
import (
"bufio"
"fmt"
"os"
"strconv"
"strings"
)
type Status int
const (
StatusTodo Status = iota + 1
StatusDone
)
type Task struct {
ID int
Title string
Status Status
}
func ValidateTask(t Task) error {
if t.ID <= 0 {
return fmt.Errorf("id must be positive")
}
if strings.TrimSpace(t.Title) == "" {
return fmt.Errorf("title is empty")
}
if t.Status != StatusTodo && t.Status != StatusDone {
return fmt.Errorf("unknown status")
}
return nil
}
func NewTask(id int, title string) (Task, error) {
t := Task{ID: id, Title: strings.TrimSpace(title), Status: StatusTodo}
if err := ValidateTask(t); err != nil {
return Task{}, err
}
return t, nil
}
func main() {
in := bufio.NewReader(os.Stdin)
fmt.Print("id: ")
idLine, _ := in.ReadString('\n')
fmt.Print("title: ")
titleLine, _ := in.ReadString('\n')
id, err := strconv.Atoi(strings.TrimSpace(idLine))
if err != nil {
fmt.Println("invalid id:", err) // пример: invalid id: strconv.Atoi: parsing "x": invalid syntax
return
}
t, err := NewTask(id, titleLine)
if err != nil {
fmt.Println("invalid task:", err)
return
}
fmt.Printf("created: %+v\n", t)
}
Тут мы используем fmt.Errorf, потому что это удобно для формирования ошибок с текстом. В Go это стандартный способ “собрать ошибку как значение”.
Если хочется сделать сообщения ещё более дружелюбными, можно разделить “ошибку для пользователя” и “ошибку для разработчика”, но это уже отдельная взрослая тема. Пока наша цель — не выпускать мусор внутрь программы.
6. Допустимые значения и изменения модели
Многие ожидают, что если статус — это type Status int и у нас есть константы, то других значений “не бывает”. Бывает. Компилятор не запрещает написать Status(123) и сохранить это в поле. Поэтому инвариант “статус только из набора” — реальная необходимость.
Допустимые значения: почему “enum через int” всё равно нужно валидировать
package main
import (
"fmt"
)
type Status int
const (
StatusTodo Status = iota + 1
StatusDone
)
type Task struct {
ID int
Title string
Status Status
}
func ValidateTask(t Task) error {
if t.Status != StatusTodo && t.Status != StatusDone {
return fmt.Errorf("unknown status: %d", t.Status)
}
return nil
}
func main() {
t := Task{ID: 1, Title: "oops", Status: Status(42)}
err := ValidateTask(t)
fmt.Println(err) // unknown status: 42
}
Это выглядит смешно (“кто вообще так сделает?”), пока вы не начнёте парсить ввод, читать данные из файла или получать их из сети. Тогда внезапно оказывается, что “42” — это вполне себе реальная угроза вашему спокойному сну.
Инварианты и обновления: проверяйте не только создание, но и изменения
Даже если вы идеально создали структуру, дальше вы можете её испортить. Например, поменять Title на пустой или статус на мусор. Поэтому валидация — это не только “на входе”, но и в местах изменения.
Мы не используем методы (это отдельная тема), но можем сделать обычную функцию, которая принимает указатель на задачу.
package main
import (
"errors"
"fmt"
"strings"
)
type Status int
const (
StatusTodo Status = iota + 1
StatusDone
)
type Task struct {
ID int
Title string
Status Status
}
func ValidateTask(t Task) error {
if t.ID <= 0 {
return errors.New("id must be positive")
}
if strings.TrimSpace(t.Title) == "" {
return errors.New("title is empty")
}
if t.Status != StatusTodo && t.Status != StatusDone {
return errors.New("unknown status")
}
return nil
}
func MarkDone(t *Task) error {
if t == nil {
return errors.New("task is nil")
}
t.Status = StatusDone
return ValidateTask(*t)
}
func main() {
task := Task{ID: 1, Title: "finish lecture", Status: StatusTodo}
_ = MarkDone(&task)
fmt.Println(task.Status) // 2
}
Почему мы валидируем после изменения? Потому что изменения — это тоже “граница”, только внутренняя. И да, иногда это кажется занудством. Но занудство валидации дешевле, чем занудство отладки “почему оно иногда падает”.
7. Как писать ошибки валидации, чтобы они помогали
Ошибка валидации — это маленькое сообщение, которое потом кто-то будет читать. Часто этим “кто-то” будете вы же через неделю. Поэтому сообщения ошибок — это часть интерфейса вашего кода.
Практичные правила
Сообщение должно быть коротким и конкретным: "title is empty" лучше, чем "invalid task". Лучше не лепить туда точку в конце и не начинать с "Error:", потому что ошибку и так обычно печатают с префиксом, и в логах это начинает выглядеть комично (“Error: Error: Error…”).
Go-стиль в целом строится вокруг того, что error — значение, которое удобно передавать вверх, добавляя контекст через fmt.Errorf.
Если у вас цепочка функций, каждая из которых добавляет контекст (например “parse id”, “create task”), то потом в main вы можете вывести это одним fmt.Println(err) и получить достаточно понятную картину.
8. Типичные ошибки
Ошибка №1: путать zero value и “валидное значение модели”.
Task{} существует, компилируется и спокойно печатается, но чаще всего нарушает ваши бизнес-правила: ID = 0, Title = "", статус не задан. Лечится очень просто: отдельно держите в голове “технически допустимо” и “логически корректно”, и проверяйте второе через ValidateTask.
Ошибка №2: валидировать “где-то потом”, уже после того как данные попали в бизнес-логику.
Так появляются баги уровня “иногда список задач пустой” или “иногда задача не отмечается выполненной”. Когда валидация стоит на границе (ввод/парсинг/создание), мусор не успевает разойтись по программе, и отлаживать становится в разы проще.
Ошибка №3: возвращать слишком общую ошибку, из которой непонятно, что чинить.
Сообщение "invalid task" заставляет читать код и гадать. Сообщение "title is empty" сразу говорит, что нужно исправить. Ошибки — это интерфейс, и Go поощряет делать их информативными, потому что error — обычное значение, которое вы печатаете, сравниваете и передаёте дальше.
Ошибка №4: считать, что “enum-константы” автоматически защищают от неправильных значений.
Тип Status и константы помогают читаемости, но не запрещают сделать Status(42). Если статус приходит из ввода или внешнего источника, проверка “только из набора” обязательна, иначе в модели появляются состояния, о которых код не подозревает.
Ошибка №5: при ошибке возвращать “частично заполненную” структуру и потом случайно её использовать.
Если NewTask вернул ошибку, а вы всё равно где-то сохранили Task{ID: 5, Title: "", ...}, вы сами себе подложили мину. Практичный паттерн: при ошибке возвращать Task{} и error, а вызывающий код обязан проверить err != nil.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ