JavaRush /Курси /Go SELF /Як обрати контракт API: error, bool або (T, bool)

Як обрати контракт API: error, bool або (T, bool)

Go SELF
Рівень 16 , Лекція 4
Відкрита

1. Вступ

Якби програмування було простим, ми б завжди повертали (T, error) і жили б щасливо. Але життя, як і користувачі, влаштоване складніше: іноді «не вийшло» — це справді помилка, а іноді — цілком нормальний результат, наприклад «нічого не знайдено» або «умова не виконується». Хороший контракт робить код, який викликає функцію, простим, а поганий змушує писати дивні перевірки, зберігати «магічні значення» на кшталт -1 і гадати, що мав на увазі автор функції.

У Go це особливо помітно, бо стиль мови заохочує явні контракти та передбачуваний потік керування. Ще одна причина — помилки є частиною API: щойно ви обрали, що повертати, ви ніби підписали «договір» із тим, хто викликатиме вашу функцію, зокрема й із собою через тиждень, коли забудете, що там було.

Міні‑правило для старту

Поки без тонкощів можна тримати в голові просту думку: bool — це «так/ні», (T, bool) — це «знайшли/не знайшли» (або «вийшло/не вийшло» без деталей), error — це «не вийшло, і важливо чому».

2. Коли повертати bool

bool ідеально підходить, коли функція відповідає на запитання про властивість: чи парне число, чи порожній рядок, чи підходить запис під фільтр, чи перевищено ліміт. У таких задачах false — не аварія і не привід для паніки; це просто відповідь на запитання. Якщо ви спробуєте запхати туди error, то отримаєте код, який виглядатиме так, ніби кожен false — трагедія рівня «сервер упав», хоча насправді це просто математика.

Саме тому в стандартній бібліотеці Go багато місць, де повертають bool і не вважають це проблемою. Наприклад, bufio.Scanner.Scan() повертає bool: доки сканування триває — true, завершилося — false, а помилка перевіряється окремо через scanner.Err(). Це гарний приклад того, що bool може бути частиною охайного API, який не засмічує основний сценарій перевіркою помилки на кожному кроці.

Приклад

Уявімо, що ми пишемо найпростіший «TodoLite» — задачі в пам’яті. Поки що без struct, тож зберігатимемо назви в map[int]string, а статус виконання — у map[int]bool.

Предикат «чи валідний ID задачі» — це типовий bool.

package main

import "fmt"

func IsValidID(id int) bool {
	return id > 0
}

func main() {
	fmt.Println(IsValidID(10))  // true
	fmt.Println(IsValidID(-5))  // false
	fmt.Println(IsValidID(0))   // false
}

Тут немає причини повертати error. Ніхто не оброблятиме питання «чому ID некоректний» на рівні бізнес-логіки; зазвичай достатньо правила «ID має бути більшим за нуль».

Коли bool стає поганою ідеєю

bool починає заважати, коли тому, хто викликає, потрібно зрозуміти причину відмови. Уявіть функцію CanCreateTask(title string) bool. Якщо вона повертає false, у вас лишається питання: заголовок порожній? Надто довгий? Містить заборонений символ? У підсумку ви або дублюєте перевірки зовні, або починаєте вгадувати за непрямими ознаками.

3. Коли повертати (T, bool)

Контракт (T, bool) — це чудовий компроміс для операцій «отримати значення, якщо воно є». Це рівно та ідея, яку ви вже знаєте з map: v, ok := m[key]. Якщо ok == true, значить значення реально знайдено, навіть якщо саме значення дорівнює нульовому значенню (zero value) — наприклад, 0 для int. Якщо ok == false, ключа немає.

Головна перевага (T, bool) у тому, що він позбавляє «магічних значень». У мовах, де прийнято повертати -1 або порожній рядок як «не знайдено», ви постійно тримаєте в голові, що -1 — це не індекс, а сигнал. У Go цей сигнал прийнято робити явним окремим bool.

Поганий приклад: магічне -1

Ось типовий «привіт з інших мов»:

package main

func FindIndexBad(xs []string, target string) int {
	for i, x := range xs {
		if x == target {
			return i
		}
	}
	return -1
}

Працює? Так. Гарно? Не дуже. Той, хто викликає, має пам’ятати про -1, і якщо забуде — отримає сюрприз під час виконання.

Хороший приклад: (int, bool)

package main

import "fmt"

func FindIndex(xs []string, target string) (int, bool) {
	for i, x := range xs {
		if x == target {
			return i, true
		}
	}
	return 0, false
}

func main() {
	words := []string{"go", "rust", "java"}

	i, ok := FindIndex(words, "go")
	fmt.Println(i, ok) // 0 true

	i, ok = FindIndex(words, "python")
	fmt.Println(i, ok) // 0 false
}

Зверніть увагу на «дивину», яка насправді є силою цього підходу: при ok == false ми повертаємо 0. Це не помилка, тому що 0 тут — лише zero value, а сенс результату несе ok.

Приклад із TodoLite: отримати заголовок за ID

package main

import "fmt"

func GetTitleByID(titles map[int]string, id int) (string, bool) {
	title, ok := titles[id]
	return title, ok
}

func main() {
	titles := map[int]string{
		1: "купити молоко",
		2: "прочитати про помилки",
	}

	title, ok := GetTitleByID(titles, 2)
	fmt.Println(title, ok) // прочитати про помилки true

	title, ok = GetTitleByID(titles, 99)
	fmt.Println(title, ok) //  false  (title == "")
}

Тут особливо важливо, що порожній рядок "" — це валідне значення типу string (zero value). Якби ми повертали лише string, то не могли б відрізнити «задачі немає» від «задача є, але заголовок порожній» (хоч у нашій логіці це й заборонено — усе одно контракт має бути чесним).

Коли (T, bool) не підходить

Якщо «не знайдено» — це не нормальна ситуація, а помилка сценарію, то (T, bool) буде надто мовчазним. Наприклад, якщо користувач явно попросив «познач задачу 99 виконаною», а такої задачі немає — користувачеві потрібне пояснення, а нам — можливість підняти цю проблему вище.

4. Коли повертати error

error потрібен, коли відмова має сенс, а причини важливі. Насамперед — коли ви виконуєте операції, що можуть завершитися невдало з різних причин: парсинг, валідація, обчислення з обмеженнями, взаємодія із системою (файли й мережа — це буде пізніше, але ідея та сама).

Тут корисно пам’ятати думку зі стилю Go: добре спроєктований пакет описує, на що можна покладатися під час помилок. Найпростіша специфікація — «успіх: nil, неуспіх: не-nil». І часто цього достатньо: нам не потрібно вгадувати за bool, що зламалося, ми отримуємо конкретне повідомлення й можемо або показати його користувачеві, або обгорнути контекстом вище.

Приклад: валідація заголовка задачі

Уявімо, що в TodoLite не можна створювати задачу з порожнім заголовком і не можна мати заголовок довший за 50 символів. Це не просто false: це різні причини, і їх корисно донести.

package main

import (
	"errors"
	"fmt"
)

func ValidateTitle(title string) error {
	if len(title) == 0 {
		return errors.New("заголовок порожній")
	}
	if len(title) > 50 {
		return fmt.Errorf("заголовок завеликий: %d символів", len(title))
	}
	return nil
}

Тут error виграє у bool, тому що несе пояснення. А пояснення — це половина UX (і половина вашої швидкості налагодження).

Приклад: команда «позначити виконаною» як error

Тепер важливий момент дизайну: що вважати «помилкою» у вашому домені.

Якщо в нас є команда «позначити задачу виконаною», і задачу не знайдено, то це логічно вважати помилкою команди (користувач попросив неможливу дію).

package main

import (
	"errors"
	"fmt"
)

func MarkDone(done map[int]bool, titles map[int]string, id int) error {
	if _, ok := titles[id]; !ok {
		return errors.New("задачу не знайдено")
	}
	done[id] = true
	return nil
}

func main() {
	titles := map[int]string{1: "купити молоко"}
	done := map[int]bool{}

	err := MarkDone(done, titles, 2)
	fmt.Println(err) // задачу не знайдено
}

Якби ми повернули лише bool, користувач побачив би «не вийшло» й усе. А потім пішов би в чат підтримки (тобто до вас), щоб спитати: «А чому?»

5. Критерії вибору контракту

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

Ситуація Що повертати Чому
Функція відповідає на запитання про властивість («парне?», «валідне?»)
bool
false — нормальна відповідь, причини зазвичай не потрібні
Операція «отримати/знайти, якщо є», а «не знайдено» — нормальний результат
(T, bool)
Явно відділяємо значення від факту наявності, уникаємо магічних значень
Операція може не вийти, і важливо чому (валідація, парсинг, команди)
error
або
(T, error)
Причина важлива тому, хто викликає, і/або користувачеві
«Не знайдено» — це помилка сценарію (очікували, що буде)
error
Потрібне зрозуміле повідомлення й можливість перервати сценарій
Результат типу T має «небезпечне» нульове значення (0, "", nil) і його не можна відрізнити від «немає результату»
(T, bool)
або
(T, error)
Контракт має бути однозначним

Ця таблиця виглядає простою, але вона реально економить час: ви менше сперечаєтеся із собою й більше пишете код.

Блок-схема рішення

Щоб закріпити вибір не текстом, а маршрутом, ось невелика блок-схема. Її зручно тримати в голові, коли ви пишете нову функцію, а рука сама тягнеться до «ну давай error, так безпечніше».

flowchart TD
    A[Потрібен контракт функції] --> B{Функція відповідає на запитання про властивість?}
    B -- так --> C[Повернути bool]
    B -- ні --> D{Функція «отримує/шукає» значення?}
    D -- так --> E{Відсутність значення — нормальний результат?}
    E -- так --> F["Повернути (T, bool)"]
    E -- ні --> G["Повернути (T, error) або error"]
    D -- ні --> H{Важливо знати причину відмови?}
    H -- так --> I["Повернути error або (T, error)"]
    H -- ні --> J["Часто все одно краще (T, bool), ніж магія"]

Так, схеми теж бувають корисні, і ні, це не «корпоративщина»: це спосіб не плодити хаос у коді.

6. Приклад: TodoLite і послідовний API

Зараз зберемо кілька функцій так, щоб вони виглядали як маленька бібліотека для нашого TodoLite. Ми спеціально зробимо різні контракти, щоб на практиці побачити різницю.

IsValidID — bool

Ми вже бачили:

func IsValidID(id int) bool {
	return id > 0
}

Це предикат. bool — ідеально.

GetTitleByID — (string, bool)

Отримання значення «якщо є»:

func GetTitleByID(titles map[int]string, id int) (string, bool) {
	title, ok := titles[id]
	return title, ok
}

Тут «не знайдено» може бути нормальним, наприклад під час пошуку.

ParseID — (int, error)

Дуже типова функція для застосунків: парсимо ID з рядка (введення користувача).

package main

import (
	"fmt"
	"strconv"
)

func ParseID(s string) (int, error) {
	id, err := strconv.Atoi(s)
	if err != nil {
		return 0, fmt.Errorf("не вдалося розібрати ID %q: %v", s, err)
	}
	if id <= 0 {
		return 0, fmt.Errorf("ID має бути додатним, отримано %d", id)
	}
	return id, nil
}

Якщо повернути bool, ми втратимо корисну інформацію: «це не число» і «число, але некоректне» — різні ситуації.

TryMarkDone — bool або error (залежить від сенсу)

Ось тут починається справжнє «API-мислення».

Якщо TryMarkDone — це внутрішня функція, яку використовують як «спробувати зробити й не шуміти» (наприклад, під час повторної синхронізації даних), то можна зробити так:

func TryMarkDone(done map[int]bool, titles map[int]string, id int) bool {
	if _, ok := titles[id]; !ok {
		return false
	}
	done[id] = true
	return true
}

Але якщо це команда користувача «познач задачу виконаною», то мовчазний false — поганий UX. Тоді потрібен error, як ми робили вище, щоб можна було пояснити причину відмови.

Це важливий момент: контракт залежить не від типу даних, а від сенсу операції.

Нюанс: bool і (T, bool) іноді кращі, ніж error

Є спокуса думати, що error завжди «правильніший», бо «це ж Go, тут усюди помилки». Але Go якраз про інше: про те, що помилки — це значення. А раз це значення, то іноді доречнішим буде інший результат, не обов’язково error.

Коли «неуспіх» — це очікувана гілка (пошук, фільтрація, перевірка), bool і (T, bool) роблять код простішим. Ви не створюєте помилки, не тягнете рядки, не змушуєте того, хто викликає, вирішувати: «Це справді помилка чи просто не знайшли?»

А коли проблема справді є помилкою — тоді error робить код чеснішим, бо підіймає причину нагору й дозволяє зупинити сценарій або показати повідомлення.

7. Типові помилки під час вибору error / bool / (T, bool)

Помилка №1: повернути лише T і кодувати «не знайдено» через магічне значення.
Найчастіше це виглядає як return -1 для індексу, return "" для рядка або return 0 для числа. Спочатку здається зручним, але потім ви обов’язково забудете перевірити магічне значення, і баг проявиться десь далеко — як завжди, у пʼятницю ввечері. У Go майже завжди краще повернути (T, bool) і зробити факт наявності значення явним.

Помилка №2: використовувати bool там, де користувачеві потрібна причина.
Функція на кшталт CreateTask(title) bool перетворює будь-яке «не вийшло» на мовчазне «ну не доля». Потім вам доводиться додавати додаткові функції «а чому» або дублювати перевірки в кількох місцях. Якщо причина важлива (валідація, парсинг, команда користувача) — повертайте error, щоб повідомлення було поруч із місцем, де ухвалено рішення.

Помилка №3: повертати error для предикатів і «нормальних негативних відповідей».
Якщо ви зробите IsEven(n int) error, то для непарного числа повертатимете помилку "not even". Логічно це правда, але за змістом це не помилка програми. У підсумку код, який викликає функцію, виглядатиме так, ніби він обробляє аварії, хоча насправді просто перевіряє властивість. Це погіршує читабельність і робить код важковаговим.

Помилка №4: змішувати семантику ok у (T, bool) у різних місцях.
Якщо в одному місці ok означає «знайшли», в іншому — «успішно записали», а в третьому — «валідно», то читання коду перетворюється на детектив. У Go зазвичай ok використовують для «знайшли/присутнє» (особливо поруч із map-подібною поведінкою). Для «успішно виконали команду» частіше обирають error, щоб не ховати причину відмови.

Помилка №5: при (T, bool) намагатися використовувати T, не перевіривши ok.
Це родич класичної помилки «використати результат до перевірки err». Якщо ви отримали (title, ok) і ігноруєте ok, то рано чи пізно приймете "" за реальний заголовок і почнете друкувати порожні рядки у звіті або шукати, звідки взялися задачі без назв. Правило те саме: спочатку перевірка, потім використання.

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