JavaRush /Курси /Go SELF /Обгортання помилок через %w та ланцюжок причин

Обгортання помилок через %w та ланцюжок причин

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

1. Wrapping у fmt.Errorf

Коли ви тільки починаєте, здається, що достатньо повернути fmt.Errorf("що-небудь пішло не так") — і на цьому все. Але реальний код швидко стає багатоповерховим: main викликає run, той викликає parse, а той викликає strconv.Atoi, і десь усередині все падає. У підсумку ви бачите помилку, але не розумієте, у якій саме операції це сталося і якою була першопричина.

Уявіть, що помилка — це посилка, яка їде ланцюжком функцій угору. Кожна функція — як сортувальний пункт. Їй хочеться наклеїти свій стікер: «я зараз розбирав parse», «я зараз завантажував конфіг», «я зараз перевіряв введення». Так з’являється контекст. Але якщо клеїти стікери неправильно, ви можете втратити інформацію про те, що лежить усередині коробки.

У Go 1.13 (і, звісно, у Go 1.25) для цього є стандартний механізм: wrapping — обгортання помилки так, щоб вона залишалася доступною «всередині» й могла бути витягнута стандартними засобами. Це робиться через fmt.Errorf і спеціальний плейсхолдер %w.

%v: контекст додали, але причину сховали в тексті

Коли ми пишемо:

return fmt.Errorf("parse age: %v", err)

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

Це важливо зрозуміти саме як механіку: форматування через %v не створює розгорнутого ланцюжка причин. До появи %w результат fmt.Errorf із %v не можна було «розгорнути» — errors.Unwrap повертає nil, тобто початкова помилка недоступна як структура.

Подивімося на мікроприклад: ми додамо контекст, а потім спробуємо «зняти один шар».

package main

import (
	"errors"
	"fmt"
)

func main() {
	base := errors.New("bad input")
	w := fmt.Errorf("parse: %v", base)

	fmt.Println(w)                 // parse: bad input
	fmt.Println(errors.Unwrap(w))  // <nil>
}

Пояснення просте: "parse: bad input" виглядає нормально, але з точки зору програми це одна пласка помилка, без посилання на base. Саме тому %v — це «контекст для людини», але не «контекст зі збереженням причини».

%w: той самий текст, але з’являється внутрішня причина

Тепер найцікавіше: %w зовні поводиться майже так само, як %v (текст буде схожий), але всередині створюється обгортка, яка вміє повернути першопричину через метод Unwrap.

Формально: якщо у fmt.Errorf присутній %w, то помилка, що повертається, отримує Unwrap() error, який повертає початкову помилку-аргумент.

Подивімося той самий приклад, але з %w:

package main

import (
	"errors"
	"fmt"
)

func main() {
	base := errors.New("bad input")
	w := fmt.Errorf("parse: %w", base)

	fmt.Println(w)                // parse: bad input
	fmt.Println(errors.Unwrap(w)) // bad input
}

Ключова думка: контекст («parse») додали, а причину («bad input») не втратили. Це і є wrapping.

Важливо сприймати wrapping як створення нової помилки, яка «містить» стару. Це як матрьошка: зверху напис «parse», а всередині — початкова помилка. У матрьошки можна відкрити один шар і побачити, що всередині.

3. Ланцюжок причин і errors.Unwrap

Кілька обгорток поспіль

У реальному застосунку помилка рідко проходить рівно через одну функцію. Зазвичай вона піднімається вгору через кілька рівнів, і кожен рівень додає свій контекст.

Go називає послідовність «помилка → її причина → причина причини → …» ланцюжком причин. Повторний «unwrap» — тобто зняття одного шару — дозволяє крок за кроком пройти вниз цим ланцюжком.

Зберімо ланцюжок із трьох рівнів:

package main

import (
	"errors"
	"fmt"
)

func main() {
	base := errors.New("bad input")
	w1 := fmt.Errorf("parse: %w", base)
	w2 := fmt.Errorf("run: %w", w1)

	fmt.Println(w2)                // run: parse: bad input
	fmt.Println(errors.Unwrap(w2)) // parse: bad input
}

Зверніть увагу, що errors.Unwrap(w2) знімає лише один шар. Це очікувано й правильно: unwrap — це «відкрий один шар матрьошки», а не «розбери все до ядра».

Щоб було простіше тримати це в голові, можна уявити таку схему:

flowchart TD
    A["базова помилка: bad input"] --> B["w1: parse: %w(base)"]
    B --> C["w2: run: %w(w1)"]

Як знімати шари вручну

У прикладному коді люди зазвичай не хочуть вручну ходити ланцюжком, але розуміти механіку дуже корисно: це допомагає читати чужі помилки, акуратно логувати їх і не ламати контракт функцій.

У стандартній бібліотеці є функція errors.Unwrap(err), яка повертає результат err.Unwrap(), а якщо метод Unwrap() не підтримується — повертає nil.

Напишемо мініфункцію, яка друкує ланцюжок причин построково. Це корисно як інструмент налагодження (і так, це той самий «printf-debugging», тільки з повагою до структури).

package main

import (
	"errors"
	"fmt"
)

func printChain(err error) {
	for err != nil {
		fmt.Println("-", err)
		err = errors.Unwrap(err)
	}
}

І тепер спробуємо:

package main

import (
	"errors"
	"fmt"
)

func main() {
	base := errors.New("bad input")
	err := fmt.Errorf("parse: %w", base)
	err = fmt.Errorf("run: %w", err)

	printChain(err)
	// - run: parse: bad input
	// - parse: bad input
	// - bad input
}

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

4. Практика: CLI і хороші повідомлення про помилки

Каркас застосунку

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

Нехай застосунок розуміє команду add <title>. Ввід читаємо як два слова через fmt.Scan (так, заголовок без пробілів — це обмеження, але для сьогоднішньої теми достатньо).

Спочатку зробімо базову «заготовку» з run():

package main

import (
	"fmt"
)

func run() error {
	var cmd, arg string
	fmt.Scan(&cmd, &arg)
	fmt.Println("cmd =", cmd, "arg =", arg) // cmd = add arg = test
	return nil
}

func main() {
	_ = run()
}

Перевірка команди й обгортання причини

Тепер додамо перевірку команди та обробку помилок. Ми хочемо, щоб run() повертав помилку нагору, а main() друкував її як фінальний результат. Це хороший стиль: «всередині» ми повертаємо помилки, а «ззовні» вирішуємо, що показати користувачу.

Зробімо функцію parseCommand:

package main

import (
	"errors"
	"fmt"
)

var ErrBadInput = errors.New("bad input")

func parseCommand(cmd string) error {
	if cmd != "add" {
		return fmt.Errorf("unknown command %q: %w", cmd, ErrBadInput)
	}
	return nil
}

Зверніть увагу: ми повертаємо контекст («unknown command ...») і обгортаємо ErrBadInput через %w. Це означає, що причина "bad input" збережена всередині.

Підключімо це в run():

package main

import "fmt"

func run() error {
	var cmd, arg string
	fmt.Scan(&cmd, &arg)

	if err := parseCommand(cmd); err != nil {
		return fmt.Errorf("run: %w", err)
	}

	_ = arg
	return nil
}

І нарешті — main, який друкує помилку:

package main

import "fmt"

func main() {
	if err := run(); err != nil {
		fmt.Println("ERROR:", err) // ERROR: run: unknown command "list": bad input
	}
}

Виглядає просто, але всередині в нас уже ланцюжок: runparseCommandErrBadInput.

%w vs %v: коротке порівняння

Коли ви тільки починаєте, дуже легко переплутати: «обидва ж друкують помилку, навіщо два різні?». Різниця не в красі рядка, а в тому, чи зберігаємо ми причину як структуру.

Що робимо Приклад Що побачить людина Чи можна зняти причину через errors.Unwrap
Додали текст, але сховали причину в рядок
fmt.Errorf("parse: %v", err)
parse: <текст err> Ні (Unwrap поверне nil)
Додали текст і зберегли причину
fmt.Errorf("parse: %w", err)
Майже те саме Так (Unwrap поверне err)

І тут з’являється важлива інженерна думка: рішення «обгортати чи ні» — це рішення про контракт, а не про логування. Обгортаючи, ви робите внутрішню причину доступною для коду, що викликає; не обгортаючи — приховуєте деталі заради абстракції.

Як писати хороший контекст

Контекст помилки — це не місце для роману на кшталт «Війна і мир: розділ 1, у якому я намагався розпарсити рядок». Хороший контекст — це коротка відповідь на запитання: яку операцію ми виконували, коли все зламалося.

Наприклад, такі контексти зазвичай допомагають:

  • читати вхідні дані
  • розібрати вік
  • перевірити команду
  • зберегти задачу

А такі контексти майже марні:

  • помилка
  • щось пішло не так
  • не вдалося

Доопрацюємо наш CLI, додавши перевірку аргументу. Нехай назва задачі не може бути порожньою (для fmt.Scan це майже не трапляється, але корисно як приклад):

package main

import (
	"errors"
	"fmt"
)

var ErrBadInput = errors.New("bad input")

func parseTitle(arg string) (string, error) {
	if arg == "" {
		return "", fmt.Errorf("title: %w", ErrBadInput)
	}
	return arg, nil
}

Тепер у run() додамо контекст кожного кроку:

package main

import "fmt"

func run() error {
	var cmd, arg string
	fmt.Scan(&cmd, &arg)

	if err := parseCommand(cmd); err != nil {
		return fmt.Errorf("parse command: %w", err)
	}

	title, err := parseTitle(arg)
	if err != nil {
		return fmt.Errorf("parse title: %w", err)
	}

	fmt.Println("added:", title) // added: test
	return nil
}

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

Коли %w використовувати не потрібно

Дуже легко спокуситися правилом: «завжди пишемо %w». Але Go спеціально не робить із цього догму: абсолютні правила на кшталт «завжди %w» хибні, бо іноді ви не хочете розкривати внутрішню причину.

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

Для нашого маленького CLI це можна відчути так. Припустімо, десь усередині в нас з’явилася функція, яка «читає конфіг» (зараз це просто імітація):

package main

import (
	"errors"
	"fmt"
)

func loadConfig() error {
	internal := errors.New("low-level config format error")
	return fmt.Errorf("load config: %v", internal) // не розкриваємо внутрішні деталі
}

Тут ми свідомо використовуємо %v: людині ми все пояснили ("load config ..."), а от «причину як структуру» назовні не віддаємо. Таке рішення буває корисним на межах модулів, щоб зовнішній код не починав залежати від ваших внутрішніх деталей.

5. Типові помилки під час wrapping через %w

Помилка №1: використовувати %v і очікувати, що причина збережеться як «структура».
Ззовні рядок виглядає майже так само («parse: bad input»), тому новачок щиро думає, що все зробив правильно. Але %v просто вставляє текст, і errors.Unwrap не зможе дістати початкову помилку. Якщо ви хочете ланцюжок причин — використовуйте %w.

Помилка №2: писати контекст «ні про що».
Фрази на кшталт fmt.Errorf("error: %w", err) додають шум, але не додають сенсу. Контекст має відповідати на запитання «що робили»: parse title, read input, load config. Тоді з одного повідомлення можна зрозуміти, де шукати проблему, не відкриваючи весь проєкт.

Помилка №3: дублювати причину в контексті.
Іноді виходить щось на кшталт fmt.Errorf("bad input: %w", ErrBadInput). Це виглядає як «bad input: bad input» — ніби помилка заїла платівку. Контекст і причина мають доповнювати одне одного: контекст — про операцію, причина — про тип проблеми.

Помилка №4: обгортати nil і дивуватися, чому «помилки немає».
У Go часто пишуть wrapping усередині if err != nil { ... }. Це не ритуал заради краси: якщо бездумно намагатися обгорнути помилку, не перевіряючи, що вона справді є, можна отримати дивні ефекти. Звичка проста: спочатку перевірили err != nil, потім обгорнули.

Помилка №5: перетворювати %w на «завжди й усюди», ламаючи межі абстракцій.
Wrapping робить причину доступною для зовнішнього коду. Іноді це саме те, що потрібно, але інколи ви розкриваєте внутрішні деталі реалізації і випадково перетворюєте їх на частину контракту. Рішення «обгортати чи ні» залежить від того, чи хочете ви, щоб код, який викликає, міг перевірити причину.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ