1. Множинні результати в Go
Якщо ви прийшли в Go зі світу мов, де помилки передаються через винятки (exceptions), то множинні результати спершу можуть видатися дивною традицією: «чому не можна просто повернути одне значення й не відволікати мене цією вашою помилкою?». Але Go влаштований так, що помилки — це звичайні значення, а не магічні «камені з неба».
Тож у нас зʼявляється простий і чесний контракт: функція або повертає корисний результат, або повідомляє про помилку через error. Ця ідея дуже давня й глибоко вбудована в стандартну бібліотеку: error — це інтерфейс із методом Error() string, тобто будь-яка помилка має вміти пояснити себе текстом.
Уявіть, що функція — це кур’єр. Іноді він приносить посилку (результат), а іноді телефонує й каже: «Вибачте, будинок не знайдено» (помилка). У Go кур’єр приносить і посилку, і записку одночасно — ви самі вирішуєте, що робити із запискою.
Множинні результати: синтаксис без містики
Коли ми кажемо «множинний результат», це буквально означає: функція може повернути кілька значень через один return. У сигнатурі такі результати записують у круглих дужках.
Спочатку подивімося на приклад, який узагалі не про помилки, — просто щоб звикнути до механіки:
package main
import "fmt"
func swap(a, b string) (string, string) {
return b, a
}
func main() {
left, right := swap("L", "R")
fmt.Println(left, right) // R L
}
Тут важливо відчути два правила.
По-перше, порядок має значення: що написано в сигнатурі (string, string), те й повертаємо в тому самому порядку. По-друге, ліворуч під час присвоювання ми теж пишемо два ідентифікатори: left, right := swap(...).
Як приймати кілька результатів: a, b := f() і _
У реальному коді ви найчастіше побачите таку форму:
a, b := f()
І це не «спеціальна штука для помилок», а звичайний синтаксис мови.
Іноді другий результат нам не потрібен, але компілятор не дозволяє просто про нього забути. Тоді використовують спеціальний ідентифікатор _ (підкреслення) — ним свідомо відкидають значення.
Невеликий приклад — суто для знайомства з _:
package main
import "fmt"
func pair() (int, int) {
return 10, 20
}
func main() {
x, _ := pair()
fmt.Println(x) // 10
}
Важлива ремарка: відкидати помилку через _ — це майже завжди погана ідея. Можна, але так ви самі собі копаєте яму. Сьогодні ми якраз вчимося робити навпаки: акуратно приймати помилки й перевіряти їх.
2. Контракт (value, error) у Go
Тепер переходимо до нашої зірки дня — функцій, які можуть не впоратися із завданням.
У Go для цього є стандартна форма: функція повертає (T, error), де T — корисний результат, а error — або nil (успіх), або не nil (помилка). Сам тип error — це інтерфейс, який вимагає метод Error() string, щоб помилку можна було вивести як текст.
Найпростіший спосіб створити помилку — errors.New("повідомлення"). Стандартна бібліотека прямо показує цей підхід у прикладах.
Давайте зробимо функцію «безпечне ділення», яка вміє сказати: «не можна ділити на нуль».
package main
import (
"errors"
"fmt"
)
func safeDiv(a, b int) (int, error) {
if b == 0 {
return 0, errors.New("division by zero")
}
return a / b, nil
}
func main() {
q, err := safeDiv(10, 0)
if err != nil {
fmt.Println("error:", err) // error: division by zero
return
}
fmt.Println("q =", q)
}
Тут у нас зʼявляється дисципліна: одразу після виклику перевіряємо err, і лише потім використовуємо результат.
Це не стилістика заради стилістики. Це буквально домовленість із майбутнім собою: через тиждень ви відкриєте код і не будете гадати, де саме сталася помилка — усе поруч і лінійно.
3. Чому при помилці повертають zero value
У контракті (T, error) є дуже важлива традиція: якщо помилка не nil, то значення T зазвичай повертають як zero value (значення за замовчуванням).
Чому так? Бо це передбачувано й безпечно: код, який викликає функцію, не має випадково використати «сміттєве» значення, що виглядає правдоподібно. Нуль — одразу підозрілий, порожній рядок — теж, false — теж. Навіть якщо ви забудете (не треба, але раптом) перевірити err, шанс помітити проблему все одно вищий.
Ось мінітаблиця, щоб не гадати, що таке zero value:
| Тип | Zero value | Коментар |
|---|---|---|
|
|
Нуль за замовчуванням |
|
|
Теж нуль, тільки «з крапкою» |
|
|
Порожній рядок |
|
|
Хиба |
Саме тому в safeDiv при помилці ми повернули 0, errors.New(...), а при успіху — a/b, nil.
4. Де ви вже бачили (value, error): приклад із strconv.Atoi
Найімовірніше, ви вже використовували або бачили strconv.Atoi. Він перетворює рядок на число й повертає (int, error).
Це класика Go: ніби проста операція, але вона може не вийти. Рядок може бути "42", а може бути "сорок два", і Go не зобов’язаний вгадувати.
Ми можемо загорнути strconv.Atoi у свою функцію, щоб потренувати стиль:
package main
import (
"fmt"
"strconv"
)
func parseInt(s string) (int, error) {
n, err := strconv.Atoi(s)
if err != nil {
return 0, err
}
return n, nil
}
func main() {
n, err := parseInt("12a")
if err != nil {
fmt.Println("parse error:", err) // parse error: strconv.Atoi: parsing "12a": invalid syntax
return
}
fmt.Println("n =", n)
}
Зверніть увагу: ми не намагаємося вгадувати всередині parseInt. Ми просто чесно кажемо: або все вийшло, або повертаємо помилку далі.
І це дуже в дусі Go: помилки — це значення, їх можна повертати, зберігати, друкувати, передавати далі. У Go навіть є відоме формулювання: “Errors are values” — тобто помилки не є особливою магією, а є даними, з якими ми працюємо звичайним кодом.
5. Раннє повернення: робимо код лінійним
Зараз ми підійдемо до прийому, який ви писатимете постійно: раннього повернення при помилці.
Ідея проста. Замість: «якщо помилки немає — роби, а якщо є — обробляй її в глибокій вкладеності»
ми пишемо: «якщо помилка є — одразу виходимо; далі лишається “щасливий шлях” без зайвих відступів».
Подивімося, як це виглядає на прикладі функції, що рахує середнє двох чисел, але отримує їх як рядки:
package main
import (
"fmt"
"strconv"
)
func averageFromStrings(a, b string) (float64, error) {
x, err := strconv.Atoi(a)
if err != nil {
return 0, err
}
y, err := strconv.Atoi(b)
if err != nil {
return 0, err
}
return (float64(x) + float64(y)) / 2, nil
}
func main() {
avg, err := averageFromStrings("10", "20")
if err != nil {
fmt.Println("error:", err)
return
}
fmt.Printf("avg = %.1f\n", avg) // avg = 15.0
}
Тут добре видно дві важливі домовленості:
- Ми завжди повертаємо (value, nil) при успіху.
- Ми завжди повертаємо (zeroValue, err) при помилці.
І зверніть увагу: навіть якщо обчислення повертає float64, при помилці ми повертаємо 0 — він автоматично вважається 0.0 потрібного типу. Це нормально й читабельно.
6. Схема мислення: «викликав → перевірив err → використав результат»
Щоб закріпити це як рефлекс, корисно тримати в голові короткий сценарій виконання.
flowchart TD
A["Викликали функцію f()"] --> B{err != nil?}
B -- так --> C[Обробили помилку / return]
B -- ні --> D[Використовуємо результат value]
D --> E[Продовжуємо роботу]
У Go це не просто «гарний стиль». Це допомагає не робити прихованих багів, коли ви продовжуєте обчислення з некоректними даними.
7. Вбудовуємо (value, error) у консольний застосунок
Зараз ми акуратно «прикрутимо» все до невеликого застосунку, який у нас поступово розвивається впродовж курсу. Нехай це буде проста консольна утиліта CalcBox, яка читає два числа й друкує їхнє середнє та результат ділення. Поки без складних меню й колекцій — ми ще не проходили такі теми, та й не потрібно.
Зробимо маленькі функції зі зрозумілими контрактами.
Читаємо два рядки: (a, b, error)
Так, функція може повернути навіть три значення: два «value» і одну «error». Це теж нормальна практика, якщо так зрозуміліше.
package main
import (
"fmt"
)
func readTwoStrings() (string, string, error) {
var a, b string
_, err := fmt.Scan(&a, &b)
if err != nil {
return "", "", err
}
return a, b, nil
}
Тут ми повертаємо два рядки й помилку. При помилці — два zero value для рядків (порожні рядки) і сам err.
Парсимо рядок у int: (int, error)
Винесемо парсинг окремо — так код буде зручнішим і для повторного використання, і для читання:
package main
import (
"strconv"
)
func parseInt(s string) (int, error) {
n, err := strconv.Atoi(s)
if err != nil {
return 0, err
}
return n, nil
}
Рахуємо середнє: (float64, error)
Середнє саме по собі безпечне, але спершу треба перетворити вхідні дані:
package main
func averageFromStrings(a, b string) (float64, error) {
x, err := parseInt(a)
if err != nil {
return 0, err
}
y, err := parseInt(b)
if err != nil {
return 0, err
}
return (float64(x) + float64(y)) / 2, nil
}
Безпечне ділення: (int, error)
І знову — класичний приклад, бо ділення на нуль трапляється навіть у хороших людей:
package main
import "errors"
func safeDiv(a, b int) (int, error) {
if b == 0 {
return 0, errors.New("division by zero")
}
return a / b, nil
}
Збираємо все в main: «щасливий шлях» унизу
Тепер найприємніше: main перетворюється на лінійну історію. Він майже читається як текст: «прочитай → порахуй → виведи».
package main
import (
"fmt"
)
func main() {
a, b, err := readTwoStrings()
if err != nil {
fmt.Println("input error:", err)
return
}
avg, err := averageFromStrings(a, b)
if err != nil {
fmt.Println("avg error:", err)
return
}
x, err := parseInt(a)
if err != nil {
fmt.Println("parse error:", err)
return
}
y, err := parseInt(b)
if err != nil {
fmt.Println("parse error:", err)
return
}
q, err := safeDiv(x, y)
if err != nil {
fmt.Println("div error:", err) // наприклад, division by zero
return
}
fmt.Printf("avg = %.2f\n", avg)
fmt.Println("div =", q)
}
Цей код здається трохи довгим, але він дуже чесний. Кожен крок або успішний, або акуратно завершує програму.
І головне: тепер ви можете винести ще один крок — наприклад, функцію readTwoInts() (int, int, error), яка всередині використовує readTwoStrings + parseInt. Ми це зробимо пізніше, коли говоритимемо про декомпозицію глибше, але вже зараз видно, що архітектура до цього готова.
8. Чому error зазвичай другий
Іноді хочеться запитати: «а чому не (error, value)?». Технічно можна і так, але за домовленістю в Go результат іде першим, а помилка — другою. Це робить читання коду рівнішим: ви бачите, що повертається «головне значення», а поруч — «статус».
Плюс є практична причина: іноді результат хочеться ігнорувати (_, err := ...), а іноді — навпаки, ігнорувати другий результат. Якщо error стоїть другим, зазвичай зрозуміліше: _ ставлять на місце результату, коли він не потрібен, але помилку втрачати не можна.
Ця домовленість настільки закріпилася, що багато API стандартної бібліотеки побудовані навколо неї. А в навчальних прикладах з обробки помилок у Go постійно підкреслюють перевірку err != nil як найзвичніший і очікуваний шлях.
9. Типові помилки під час роботи з (value, error)
Помилка №1: використовувати результат до перевірки err.
Це найчастіша помилка новачків. Код компілюється, інколи навіть «працює», а потім раптово ви отримуєте дивні числа, порожні рядки або ділення на нуль у несподіваному місці. Звичка має бути механічною: викликали функцію — одразу поруч перевірка err != nil, і лише після цього робота з результатом.
Помилка №2: повертати «ліве» значення при помилці.
Іноді новачок думає: «ну нехай при помилці буде -1, так зручніше». Це ламає передбачуваність. У Go прийнято повертати zero value результату при помилці: 0, "", false. Тоді в коду, який викликає функцію, є зрозуміла модель: «якщо err не nil — значенню не можна довіряти».
Помилка №3: друкувати помилку, але продовжувати виконання так, ніби нічого не сталося.
Таке часто буває в main: вивели fmt.Println("error:", err) і продовжили виконання. Це майже завжди означає, що ви зараз працюватимете з неправильними даними. Якщо помилка фатальна для поточного сценарію — краще зробити return і завершити виконання «чисто». Саме тому раннє повернення таке популярне: воно економить нерви й робить контроль потоку очевидним.
Помилка №4: ігнорувати err через _, бо «заважає».
Так, _ існує. Але коли ви пишете n, _ := strconv.Atoi(s), ви фактично кажете: «мені байдуже, що рядок може бути не числом». Зазвичай це неправда — просто хочеться, щоб компілятор перестав лаятися. У цей момент краще зупинитися й подумати: що має робити програма при неправильному введенні?
Помилка №5: змішувати обробку помилок і «щасливий шлях» в один клубок.
Коли перевірки помилок розкидані по функції, читати стає боляче: ви стрибаєте очима між обчисленнями й повідомленнями про помилку. Намагайтеся тримати стиль: «перевірка помилки одразу після виклику», «раннє повернення», «далі — чисті обчислення». Так код виглядає лінійним, а не як лабіринт, який охороняє Мінотавр із if-ів.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ