JavaRush /Курсы /Go SELF /Моделирование данных

Моделирование данных

Go SELF
20 уровень , 4 лекция
Открыта

1. Еще раз проговорим создание модели данных

В начале программирования кажется, что данные — это просто значения: int для чисел, string для текста, bool для «да/нет». И это правда… ровно до тех пор, пока вы не поймаете баг вида «в задаче отрицательный id» или «пустой заголовок прошёл в логику, потому что где-то забыли TrimSpace». Моделирование данных — это способ сделать так, чтобы часть ошибок “не могла произойти” или хотя бы ловилась в одном месте, а не по всему коду.

Представьте, что мы пишем маленькое консольное приложение “список задач” (to‑do). На уровне здравого смысла задача состоит из нескольких кусочков:

  • идентификатор (id),
  • заголовок (title),
  • статус (todo/done).

Даже если мы пока не используем «упаковку» этих частей в одно значение, мы уже можем договориться, что это одна сущность.

Здесь важно слово «договориться». Программа — штука строгая: если мы не зафиксируем правила явно, она будет молча принимать всё подряд, как слишком вежливый официант.

2. Поля одной сущности и связь между ними

Когда говорят «поля», обычно имеют в виду «части одной сущности»: у задачи есть id, title, status. Новички часто начинают хранить эти части «как получится»: где-то один слайс, где-то второй, где-то ещё переменная. На маленьком примере это выглядит нормально, а потом внезапно одна часть обновилась, а другая — нет, и вы получаете задачу‑франкенштейна: заголовок от одной, статус от другой.

Давайте специально сделаем немного неудобный (но очень поучительный) вариант хранения задач через «параллельные слайсы». Это тот случай, когда код компилируется, но здравый смысл плачет тихо в уголке.

package main

import "fmt"

func main() {
	ids := []int{1, 2}
	titles := []string{"buy milk", "read book"}

	titles = append(titles, "learn go") // забыли добавить id!
	fmt.Println(len(ids), len(titles))  // 2 3
}

Проблема здесь не в слайсах. Проблема в том, что связь между «полями» держится в вашей голове, а не в коде. Компилятор не обязан читать мысли (и, честно говоря, это к лучшему).

Чтобы мысль была наглядной, можно представить такую схему:

flowchart LR
    A["id[0]=1"] --- B["title[0]='buy milk'"]
    C["id[1]=2"] --- D["title[1]='read book'"]
    E["id[2]=???"] --- F["title[2]='learn go'"]

Связь «id и title с одним индексом — это одна задача» ломается от одного невинного append.

Что можно сделать уже сейчас, не вводя новые сложные конструкции? Держать связь через ключ (id). То есть хранить данные в map, где ключ — идентификатор задачи. Тогда «поле» заголовка для задачи id=7 не может случайно уехать к id=8, потому что у них разные ключи.

3. Инварианты данных

Инвариант — это правило, которое должно оставаться истинным для «валидного» значения. Звучит немного академично, но на практике это очень бытовая штука. Например: «id задачи всегда больше нуля» или «заголовок задачи не пустой после удаления пробелов». Если вы один раз формулируете инварианты, вы резко упрощаете себе жизнь: теперь вы знаете, что именно проверять и где.

Инварианты удобно записывать не в голове, а в виде маленькой таблицы — это почти как техпаспорт для ваших данных:

Сущность/тип Инвариант Почему это важно Где проверять
TaskID
> 0
0 и отрицательные значения обычно означают «ошибка/не задано» при парсинге/создании
TaskTitle
после нормализации не пустой пустые заголовки ломают UX и фильтры при создании заголовка
Status
только из набора допустимых иначе вывод/логика «что делать» начинает угадывать при установке статуса

Ключевая идея дня: инварианты надо проверять на границе системы. Граница — это место, где данные приходят «снаружи»: из stdin, аргументов, файла, сети, пользователя, да хоть из космоса. Внутри программы лучше жить в мире, где данные уже нормальные и предсказуемые.

4. Ответственность типов

Очень частая ошибка новичка: считать, что типы — это только «чтобы компилятор не ругался». На самом деле типы — это ещё и способ выразить смысл. Если у вас есть TaskID и ProjectID, и оба являются числами, то на уровне int вы легко перепутаете их местами. А вот на уровне именованных типов компилятор уже станет вашим занудным, но полезным коллегой: «нет, это не тот смысл».

Начнём с простого: заведём тип TaskID поверх int. Это не делает программу «более объектной» (не бойтесь), это делает её менее случайной.

package main

import "fmt"

type TaskID int

func main() {
	var id TaskID = 10
	fmt.Println(id) // 10
}

Сам по себе тип ещё не гарантирует инвариант id > 0. Но он создаёт “контейнер смысла”: теперь у нас есть место, где этот смысл можно закрепить.

И тут появляется понятие «ответственность типа». Если TaskID — это идентификатор задачи, то логично, чтобы правило «он должен быть > 0» проверялось рядом с этим типом, а не размазывалось по 17 местам в коде, где мы «на всякий случай» пишем if id <= 0.

5. Функции‑создатели и нормализация

Функция‑создатель — это функция, которая принимает «сырой» ввод, нормализует/проверяет его и возвращает либо корректное значение, либо ошибку. Это один из самых практичных паттернов Go: он дружит и с контрактом (T, error), и с привычкой ранних возвратов, и с тем, что ошибки в Go — это обычные значения.

Начнём с парсинга идентификатора задачи из строки. Внутри мы делаем две вещи: превращаем строку в число и проверяем инвариант. Если что-то пошло не так, возвращаем ошибку.

package main

import (
	"errors"
	"strconv"
	"strings"
)

type TaskID int

func ParseTaskID(s string) (TaskID, error) {
	n, err := strconv.Atoi(strings.TrimSpace(s))
	if err != nil || n <= 0 {
		return 0, errors.New("task id must be a positive integer")
	}
	return TaskID(n), nil
}

Обратите внимание на важную деталь: мы возвращаем 0 как значение при ошибке. Это удобно, потому что 0 становится «явным невалидным» вариантом для TaskID. То есть zero value у типа начинает иметь смысл: «id не создан/невалиден».

Если вы хотите добавлять контекст к ошибкам так, чтобы сохранялась причина и её можно было проверять по цепочке, Go обычно делает это через wrapping (оборачивание) ошибок. Этот подход подробно описан в материалах про работу с ошибками и цепочки причин.

Теперь сделаем то же самое для заголовка задачи. Здесь мы используем нормализацию пробелов: удаляем пробелы по краям и «схлопываем» множественные пробелы внутри.

package main

import (
	"errors"
	"strings"
)

type TaskTitle string

func NewTaskTitle(s string) (TaskTitle, error) {
	parts := strings.Fields(strings.TrimSpace(s))
	if len(parts) == 0 {
		return "", errors.New("title is empty")
	}
	return TaskTitle(strings.Join(parts, " ")), nil
}

Заметьте, насколько это удобно в остальном коде: дальше вы работаете не с «какой-то строкой», а с TaskTitle, который уже нормализован. То есть вы один раз «прибрали комнату», а потом не спотыкаетесь об носки в каждом углу.

Нормализация ввода

Нормализация — это отдельный шаг, который часто забывают, потому что «ну пользователь же нормальный». Спойлер: пользователь нормальный, но пробел у него может быть табом, а буквы могут быть в разном регистре, и вообще он пишет как человек, а не как компилятор. Если вы нормализуете строки на входе, дальше логика становится проще: сравнения работают стабильно, поиск работает стабильно, тесты меньше флакнут.

Покажем маленький пример на команде нашего CLI‑приложения. Допустим, пользователь вводит строку вида add   BUY   milk. Мы хотим получить команду и заголовок.

package main

import (
	"fmt"
	"strings"
)

func main() {
	raw := "  add   BUY   milk  "
	parts := strings.Fields(raw)
	fmt.Printf("%q\n", parts) // ["add" "BUY" "milk"]
}

Теперь мы можем привести команду к нижнему регистру, а заголовок собрать обратно «красиво»:

package main

import (
	"fmt"
	"strings"
)

func main() {
	parts := strings.Fields("  add   BUY   milk  ")
	cmd := strings.ToLower(parts[0])
	title := strings.Join(parts[1:], " ")
	fmt.Println(cmd, title) // add BUY milk
}

Да, это всё ещё «ручная» работа. Но смысл в том, что нормализация — не украшение, а способ сделать модель данных устойчивой. Хорошая модель не должна зависеть от того, поставил ли пользователь два пробела или пять.

6. Мини‑модель задач: хранение и границы валидации

Сейчас мы соберём маленькую модель хранения задач так, чтобы она была предсказуемой и не требовала «магии». Мы пока не будем вводить новые тяжёлые конструкции; вместо этого используем уже знакомые map и слайсы, но добавим туда смысл через именованные типы и инварианты. Это будет выглядеть чуть более многословно, зато намного более честно: вы видите, где что хранится и кто за что отвечает.

Сделаем три доменных типа: TaskID, TaskTitle, Status. Для Status зададим допустимые значения через константы. Обратите внимание: StatusUnknown оставим нулём — это удобно как «не задано».

package main

import "fmt"

type Status int

const (
	StatusUnknown Status = iota
	StatusTodo
	StatusDone
)

func main() {
	fmt.Println(StatusTodo, StatusDone) // 1 2
}

Теперь определимся, как хранить «поля» задачи. Мы сделаем так:

  • titles — map от id к заголовку,
  • statuses — map от id к статусу,
  • order — слайс id в порядке добавления (чтобы вывод был стабильным и не зависел от range по map).

Это не единственный вариант, но он хорошо показывает идею «поля связаны через ключ».

package main

type TaskID int
type TaskTitle string
type TaskTitles map[TaskID]TaskTitle

func main() {
	// здесь позже появится хранение и логика
}

Добавим ещё тип для статусов:

package main

type TaskID int
type Status int

type TaskStatuses map[TaskID]Status

func main() {
	// здесь позже появится хранение и логика
}

Теперь напишем маленькую функцию добавления задачи. Она будет принимать «хранилище полей» и возвращать обновлённый order. Обратите внимание: мы не пытаемся «мутировать слайс внутри» незаметно — мы возвращаем результат, как и принято со слайсами после append.

package main

type TaskID int
type TaskTitle string
type Status int

const (
	StatusUnknown Status = iota
	StatusTodo
	StatusDone
)

func AddTask(
	order []TaskID,
	titles map[TaskID]TaskTitle,
	st map[TaskID]Status,
	id TaskID,
	t TaskTitle,
) []TaskID {
	titles[id] = t
	st[id] = StatusTodo
	return append(order, id)
}

Важно другое: id — ключ, и по этому ключу мы обновляем обе карты. То есть связь полей не плавает, как в параллельных слайсах.

Чтобы вывод был стабильным, мы идём по order, а не по range titles. Это тот же принцип «детерминированного вывода», который вы уже видели на map: порядок range не является контрактом, и полагаться на него — как полагаться на настроение кота.

Где ловить ошибки: границы системы

Сейчас самое важное — не написать «идеальную архитектуру», а научиться ставить проверки в правильном месте. Для нашего мини‑приложения правильное место — на входе команд: там, где мы читаем строку, разбиваем на части и превращаем в доменные значения (TaskID, TaskTitle). Внутренняя логика должна получать уже валидные значения, иначе она будет вынуждена защищаться бесконечными if и превращаться в полосу препятствий.

Покажем маленькую схему потока данных:

flowchart TD
    A["stdin: сырая строка"] --> B["нормализация: TrimSpace/Fields"]
    B --> C["парсинг: ParseTaskID / NewTaskTitle"]
    C -->|ok| D["логика: AddTask/DoneTask/ListTasks"]
    C -->|error| E["печать ошибки пользователю и выход/пропуск"]

Если вы дисциплинированно держите эту границу, то внутри приложения можно думать проще: «если у меня TaskID, он валидный, иначе я бы сюда не дошёл». Это и есть практический смысл моделирования.

И здесь снова удобно помнить про wrapping ошибок: когда вы возвращаете ошибку наверх, вы можете добавлять контекст (например, “parse id”, “read command”), сохраняя причину. Такой подход встроен в стандартные практики работы с ошибками в Go.

7. Типичные ошибки при моделировании данных

Когда вы начинаете моделировать данные, баги становятся «более честными»: они либо ловятся на входе, либо их становится меньше. Но появляются другие соблазны: усложнить модель ради красоты, размазать правила по коду или, наоборот, забить на инварианты и надеяться, что «пронесёт». Этот раздел — про те грабли, на которые наступают чаще всего, и почему они звучат как “шлёп” даже в тихом офисе.

Ошибка №1: инварианты существуют только в комментариях или в голове.
Иногда пишут: «id должен быть > 0», а проверку ставят один раз в одном месте и забывают во втором. Через неделю появляется новый путь ввода, и туда уже пролезает 0. Лечится просто: инвариант проверяется централизованно, через функцию‑создатель или парсер.

Ошибка №2: одна и та же сущность выражена голыми int/string по всему коду.
Когда id — это просто int, его легко перепутать с чем угодно: индексом, количеством, годом рождения (не спрашивайте). Именованный тип вроде type TaskID int не делает программу длиннее в два раза, но делает её намного менее случайной: компилятор начинает помогать.

Ошибка №3: нормализация делается “где-нибудь потом”, а потом не наступает.
Если вы сравниваете строки до TrimSpace/Fields, вы получаете «призрачные баги»: вроде всё одинаково, но один ввод с двумя пробелами внезапно не находит задачу. Практика проще: нормализуем на входе, а дальше работаем только с нормализованными значениями.

Ошибка №4: zero value не имеет смысла, и из-за этого проверки расползаются.
Если TaskID(0) у вас иногда «валидный», иногда «ошибка», то в коде появляются бесконечные if id == 0. Лучше договориться: zero value либо допустим и осмыслен, либо считается «не создано», и тогда создаём значение только через функцию‑создатель, которая не пропустит ноль.

Ошибка №5: попытка “моделировать всё” уже сейчас и утонуть в типах.
Моделирование — это не конкурс “кто напишет больше типов”. Делайте это там, где смысл реально важен: идентификаторы, статусы, ключевые строки (название, e‑mail и т.д.). Если вы начнёте создавать тип type UsernameChar rune, то это будет уже не моделирование, а тихое хобби, и коллеги могут начать переживать.

1
Задача
Go SELF, 20 уровень, 4 лекция
Недоступна
Номер заявки
Номер заявки
1
Задача
Go SELF, 20 уровень, 4 лекция
Недоступна
Заголовок из черновика
Заголовок из черновика
1
Задача
Go SELF, 20 уровень, 4 лекция
Недоступна
Сканер статусов
Сканер статусов
1
Опрос
Повторение и систематизация, 20 уровень, 4 лекция
Недоступен
Повторение и систематизация
Повторение и систематизация
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ