JavaRush /Курси /Go SELF /Множинні результати у функціях

Множинні результати у функціях

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

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 Коментар
int
0
Нуль за замовчуванням
float64
0.0
Теж нуль, тільки «з крапкою»
string
""
Порожній рядок
bool
false
Хиба

Саме тому в 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-ів.

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