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-ов.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ