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-ов.

1
Задача
Go SELF, 9 уровень, 2 лекция
Недоступна
Две цифры
Две цифры
1
Задача
Go SELF, 9 уровень, 2 лекция
Недоступна
Границы замера
Границы замера
1
Задача
Go SELF, 9 уровень, 2 лекция
Недоступна
Позитивный ввод
Позитивный ввод
1
Задача
Go SELF, 9 уровень, 2 лекция
Недоступна
Одна операция
Одна операция
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ