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{"купити молоко", "читати книжку"}

	titles = append(titles, "вивчати Go") // забули додати id!
	fmt.Println(len(ids), len(titles))  // 2 3
}

Проблема тут не в слайсах. Проблема в тому, що зв’язок між «полями» тримається у вашій голові, а не в коді. Компілятор не зобов’язаний читати думки — і, чесно кажучи, це на краще.

Щоб думка була наочною, можна уявити таку схему:

flowchart LR
    A["id[0]=1"] --- B["title[0]='купити молоко'"]
    C["id[1]=2"] --- D["title[1]='читати книжку'"]
    E["id[2]=???"] --- F["title[2]='вивчати 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("id задачі має бути додатним цілим числом")
	}
	return TaskID(n), nil
}

Зверніть увагу на важливу деталь: ми повертаємо 0 як значення при помилці. Це зручно, бо 0 стає явно невалідним варіантом для TaskID. Тобто нульове значення типу починає мати зміст: «id не створено» або «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("заголовок порожній")
	}
	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 — ключ, і за цим ключем ми оновлюємо обидві map. Тобто зв’язок полів не «плаває», як у паралельних слайсах.

Щоб вивід був стабільним, ми йдемо по 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, він валідний, інакше я б сюди не дійшов». Це і є практичний зміст моделювання.

І тут знову зручно пам’ятати про обгортання помилок: коли ви повертаєте помилку нагору, ви можете додавати контекст, наприклад «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. Краще домовитися: нульове значення або допустиме й осмислене, або вважається «не створеним». Тоді створюємо значення лише через функцію-конструктор, яка не пропустить нуль.

Помилка № 5: спроба «моделювати все» вже зараз і втонути в типах.
Моделювання — це не конкурс «хто напише більше типів». Робіть це там, де зміст справді важливий: ідентифікатори, статуси, ключові рядки (назва, e‑mail тощо). Якщо ви почнете створювати тип type UsernameChar rune, то це буде вже не моделювання, а тихе хобі, і колеги можуть почати хвилюватися.

1
Опитування
Повторення й систематизація, рівень 20, лекція 4
Недоступний
Повторення й систематизація
Повторення й систематизація
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ