1. Помилки в Go: значення й інтерфейс error
Якщо ви переходите на Go з мов, де є винятки (exceptions), перший культурний шок зазвичай такий: «Чому мені потрібно перевіряти помилку після кожного другого виклику?». Це відчуття цілком нормальне: мозок звик, що помилка — це щось надприродне, а в Go вона максимально приземлена — буквально змінна, якій можна присвоювати значення, повертати з функцій і друкувати.
Один із ключових принципів Go звучить так: errors are values — «помилки є значеннями». Саме тому Go заохочує явну перевірку err, а не «виліт» угору по стеку, де потім хтось спробує «спіймати» виняток. Такий стиль робить потік керування передбачуванішим: видно, де програма може «спіткнутися», і видно, що саме станеться в цьому місці.
На практиці це означає просту річ: якщо функція може не впоратися, наприклад парсить число, читає введення або відкриває файл, вона зазвичай повертає другий результат типу error. Далі ви вирішуєте, що робити: завершити програму, попросити користувача ввести дані ще раз, вибрати значення за замовчуванням, пропустити запис і продовжити. Але рішення ухвалює код, а не «система винятків».
Тип error: що це насправді
Зараз буде важливий момент: error — це тип, причому не просто тип, а інтерфейсний тип. У стандартній бібліотеці Go він визначений приблизно так:
type error interface {
Error() string
}
Якщо ви ще не вивчали інтерфейси системно, усе одно не страшно. Для сьогоднішньої лекції достатньо розуміти інтерфейс дуже прагматично: інтерфейс — це договір «у значення є такі-то методи». Тут договір мінімальний: якщо у значення є метод Error(), що повертає string, то таке значення можна використовувати як error.
І тут починаються гарні новини: це пояснює одразу кілька дивних речей, які новачка зазвичай лякають.
- По-перше, error — не рядок. Він може повертати рядок через Error(), але сам по собі він не string.
- По-друге, помилки бувають різних «внутрішніх» (конкретних) типів: strconv.Atoi повертає один конкретний тип помилки, fmt.Scan може повернути інший тощо. Але зовні ми тримаємо їх у змінній типу error, бо нам достатньо спільного контракту: «можна отримати текст помилки».
- По-третє, саме тому fmt уміє гарно друкувати помилки: він знає, що в помилки є метод Error(), і може його викликати.
Щоб побачити ідею «інтерфейс = договір методів» на простому прикладі, ось крихітна демонстрація:
package main
import "fmt"
type myError struct{}
func (myError) Error() string { // реалізуємо контракт error
return "щось пішло не так"
}
func main() {
var err error = myError{}
fmt.Println(err) // щось пішло не так
}
Тут нуль магії: у myError є метод Error(), отже його можна покласти в змінну типу error.
nil як ознака успіху: zero value для error
Тепер про найпрактичніше правило Go-коду: nil у error означає успіх. А не nil означає, що сталася помилка. Це не «особливий оператор», а домовленість, на якій тримається весь стиль Go.
Важливо, що zero value, тобто значення за замовчуванням, для змінної типу error — це якраз nil. Тобто якщо ви оголосили var err error, то за замовчуванням у вас немає помилки.
package main
import "fmt"
func main() {
var err error // zero value для error — nil
fmt.Println(err == nil) // true
fmt.Println(err) // <nil>
}
Зверніть увагу на другий вивід: fmt.Println(err) виводить <nil>. Це не рядок «nil», а спеціальний формат виводу nil-значення.
Тепер пов’яжемо це з тим, що ми бачили раніше в strconv.Atoi. Якщо парсинг вдався, err буде nil. Якщо не вдався — err буде не nil, і всередині буде конкретний об’єкт помилки.
package main
import (
"fmt"
"strconv"
)
func main() {
n, err := strconv.Atoi("42")
fmt.Println(n) // 42
fmt.Println(err == nil) // true
}
А тепер — приклад із помилкою:
package main
import (
"fmt"
"strconv"
)
func main() {
n, err := strconv.Atoi("4x")
fmt.Println(n) // 0
fmt.Println(err) // strconv.Atoi: parsing "4x": invalid syntax
}
Тут видно одразу два факти.
- Перший: у разі помилки n стає рівним 0 — це zero value для int.
- Другий: помилка містить текст, який пояснює, що саме сталося.
І це підводить нас до дисципліни: якщо err != nil, результат (n) не можна вважати валідним, навіть якщо він виглядає «схожим на правду». Сьогодні це може бути 0, завтра — у інших функціях — порожній рядок або nil-слайс тощо.
Як fmt друкує помилки
Дуже часте запитання новачка: «Чому fmt.Println(err) друкує нормальний текст, хоча err — це не рядок?». Правильна відповідь: fmt друкує помилки, викликаючи метод Error() у значення, яке реалізує error.
Тобто коли ви пишете fmt.Println(err), це концептуально схоже на «надрукуй err.Error()», тільки fmt ще вміє акуратно обробити випадок err == nil.
package main
import (
"fmt"
"strconv"
)
func main() {
_, err := strconv.Atoi("x")
fmt.Printf("як %%v: %v\n", err) // як %v: strconv.Atoi: parsing "x": invalid syntax
fmt.Println("через Println:", err) // через Println: strconv.Atoi: parsing "x": invalid syntax
}
Обидва варіанти спираються на одну й ту саму ідею: fmt бачить значення типу error і розуміє, як перетворити його на текст.
Ще один корисний прийом для діагностики — друк типу через %T. Це особливо корисно, коли ви хочете зрозуміти, яка саме помилка ховається всередині інтерфейсу error:
package main
import (
"fmt"
"strconv"
)
func main() {
_, err := strconv.Atoi("x")
fmt.Printf("тип: %T\n", err) // тип: *strconv.NumError
}
Вивід *strconv.NumError — це підказка: «усередині error лежить конкретна структура помилки з пакета strconv». Корисно пам’ятати, що error — це оболонка (інтерфейс), а всередині може бути що завгодно, аби був метод Error().
err.Error() і чому ми не аналізуємо текст помилки
Коли ви бачите err.Error(), це не «магія», а звичайний виклик методу через крапку: у значення err викликається метод Error. Строго за контрактом error цей метод повертає string.
Іноді вам справді потрібен саме рядок, наприклад, щоб поєднати його з якимось своїм повідомленням або вивести користувачу лише текст. Тоді err.Error() доречний.
package main
import (
"fmt"
"strconv"
)
func main() {
_, err := strconv.Atoi("x")
if err != nil {
fmt.Println("повідомлення:", err.Error()) // повідомлення: strconv.Atoi: parsing "x": invalid syntax
}
}
Але тут є важлива дисципліна: ми не ухвалюємо рішення, аналізуючи текст помилки. Тобто погана ідея — робити так:
// приклад того, як робити НЕ треба (не копіюйте)
if err != nil && strings.Contains(err.Error(), "invalid syntax") {
...
}
Чому це погано? Бо текст помилки — це повідомлення для людини (лог або вивід), а не стабільний API для програми. Сьогодні він один, завтра — інший.
Правильне правило тут просте: рішення «успіх чи помилка» ми ухвалюємо за err == nil або err != nil, а не за рядками.
strconv.Atoi як джерело реальних error
Слово «інтерфейс» інколи звучить занадто абстрактно, тому корисно прив’язати його до найзнайомішого нам джерела помилок — strconv.Atoi. Давайте подивимося на два запуски: «успіх» і «помилка», і в обох випадках надрукуємо і тип, і значення err.
package main
import (
"fmt"
"strconv"
)
func main() {
_, err1 := strconv.Atoi("10")
fmt.Printf("err1: %v (%T)\n", err1, err1) // err1: <nil> (<nil>)
_, err2 := strconv.Atoi("1O") // літера O замість нуля
fmt.Printf("err2: %v (%T)\n", err2, err2) // err2: ... (*strconv.NumError)
}
Зверніть увагу: у err1 тип друкується як <nil>. Це нормально: інтерфейсна змінна error не містить конкретного значення, і тому %T показує <nil>.
У err2 з’являється конкретний тип *strconv.NumError. Це і є «конкретна помилка», захована за інтерфейсом.
І саме тому в Go так зручно жити: вам не потрібно в кожному місці знати, що там *strconv.NumError. Достатньо знати: якщо err != nil, операція не вдалася, і помилку можна надрукувати або передати далі.
2. Мінізастосунок «Облік витрат»
Зараз зберемо невеликий навчальний приклад, який ще знадобиться в цій темі про помилки. Ідея максимально побутова: у нас є список витрат, кожна витрата — це категорія та сума. Ми хочемо порахувати загальний підсумок і суму за категоріями. А ще ми хочемо, щоб програма не вдавала, ніби все добре, якщо користувач увів «сто гривень» замість 100.
Формат введення зробимо простим: спочатку число n — скільки записів, потім n пар: категорія та сума. Сума приходить як рядок, а ми парсимо її через strconv.Atoi, щоб отримати класичну помилку, а не мовчазне перетворення на нуль.
Для початку напишемо маленьку функцію читання одного токена. Це дасть змогу одразу побачити, що навіть fmt.Scan може повернути error.
package main
import "fmt"
func readToken() (string, error) {
var s string
_, err := fmt.Scan(&s)
return s, err
}
Тепер зробимо функцію читання суми: вона читає рядковий токен і намагається перетворити його на int.
package main
import (
"strconv"
)
func readAmount() (int, error) {
token, err := readToken()
if err != nil {
return 0, err
}
return strconv.Atoi(token)
}
Тут важлива сама схема повернення: якщо виникла помилка, ми повертаємо 0 — zero value для int — і саму помилку. Ми поки не змінюємо текст помилки: сьогодні нам важливо зрозуміти, що це просто значення і воно або nil, або ні.
Тепер зберемо main, який читає n, потім n записів і додає суми за категоріями в map[string]int.
package main
import (
"fmt"
)
func main() {
n, err := readAmount()
if err != nil {
fmt.Println("не вдалося прочитати n:", err)
return
}
totals := make(map[string]int)
for i := 0; i < n; i++ {
cat, err := readToken()
if err != nil {
fmt.Println("не вдалося прочитати категорію:", err)
return
}
amount, err := readAmount()
if err != nil {
fmt.Println("не вдалося прочитати суму:", err)
return
}
totals[cat] = totals[cat] + amount
}
fmt.Println("підсумки:", totals)
}
Ця версія навмисно проста: за першої ж помилки ми друкуємо повідомлення й виходимо. З погляду сьогоднішньої теми це ідеально, бо показує головний контракт: поки err == nil, ми продовжуємо; щойно err != nil, ми переходимо в гілку помилки.
Щоб побачити, що програма поводиться передбачувано, уявіть таке введення:
3
food 120
taxi 300
food x
На третьому записі strconv.Atoi("x") поверне помилку, що не дорівнює nil, amount стане 0, і ми не будемо додавати «нульову витрату», наче все нормально, — ми зупинимося й покажемо користувачу проблему.
Зауважте ще одну важливу річ про map: якщо для категорії ще немає суми, totals[cat] дає 0 — zero value для int. Тому вираз totals[cat] = totals[cat] + amount працює і для першого запису, і для наступних.
Ось проста схема потоку керування — це хороший шаблон на майбутнє:
flowchart TD
A[Прочитати токен або число] --> B{err == nil?}
B -- так --> C[Використати значення]
C --> A
B -- ні --> D[Показати помилку]
D --> E[Зупинитися]
3. Типові помилки на перших кроках з error
Помилка № 1: сприймати error як рядок і перевіряти «порожній / непорожній».
Іноді хочеться зробити щось на кшталт if err != "", бо «ну помилка ж текст». Але error — це окремий тип, не string. Коректна перевірка можлива лише через err == nil або err != nil. Якщо вам потрібен текст, його отримують через err.Error(), але це вже наступний крок, а не спосіб визначити факт помилки.
Помилка № 2: друкувати помилку й продовжувати обчислення з невалідним результатом.
Ситуація: n, err := strconv.Atoi(...), потім ви друкуєте помилку, але все одно використовуєте n. Проблема в тому, що в разі помилки n майже завжди матиме zero value — для int це 0, і ви тихо отримаєте неправильний підсумок. Якщо ви вирішили, що помилка критична, то після повідомлення має бути явний перехід потоку керування, наприклад return із main.
Помилка № 3: плутати «успіх» і «помилку» в умові.
Буває, що пишуть if err == nil { ... обробка помилки ... }. Запам’ятайте правило: err != nil — це гілка помилки.
Помилка № 4: не розуміти, що fmt.Println(err) не просто «друкує змінну».
Текст з’являється тому, що fmt викликає метод Error() у помилки. Це корисно пам’ятати, щоб не очікувати, що error сам по собі є рядком.
Помилка № 5: намагатися ухвалювати рішення за текстом err.Error().
Спокуса велика: текст же зрозумілий. Але він не призначений для логіки розгалуження. Звикайте до дисципліни: «є помилка чи немає помилки» вирішуємо через nil / не nil. Текст — для користувача або логу, а не для алгоритму.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ