1. Почему в Go так много if err != nil
Когда вы впервые видите Go-код, может возникнуть ощущение, что язык специально создан, чтобы тренировать терпение: вызов → if err != nil → возврат → вызов → if err != nil → возврат… И вот вы уже морально готовы написать макрос CHECK_ERR() и жить спокойно (но в Go макросов нет, потому что жизнь должна быть честной).
На самом деле этот стиль — сознательный выбор: Go поощряет явное управление потоком выполнения при ошибках. То есть ошибка не “прыгает” через стек сама, а становится значением, с которым вы работаете как с любым другим. В официальных рассуждениях про “ошибки как значения” эта мысль проговаривается прямо: важно не “избавиться от проверок”, а сделать так, чтобы программа проверяла ошибки корректно, не замазывая контроль потока магией.
Отсюда появляется наша сегодняшняя идиома:
if err != nil {
return ...
}
Её смысл максимально приземлённый: “если шаг провалился — дальше идти нельзя, выходим сейчас”.
2. Ритм кода: вызвал → проверил → ранний return
Сейчас мы сделаем важный переход: перестанем воспринимать if err != nil { return ... } как “досадную обязанность”, и начнём воспринимать как ритм, в котором пишется Go-код. Это похоже на вождение: сначала вы очень осознанно думаете “поворотник — зеркало — манёвр”, а потом это становится автоматикой. Примерно так же и здесь: шаг → проверка → либо продолжаем, либо выходим.
Чтобы увидеть логику, удобно представить функцию как последовательность шагов, где каждый шаг может “не получиться”. Идиома раннего возврата делает так, что “счастливый путь” (happy path) читается сверху вниз без лишней вложенности.
Вот простая блок‑схема:
flowchart TD
A[Шаг 1: вызываем функцию] --> B{err != nil?}
B -- да --> E[return ... err]
B -- нет --> C[Шаг 2: следующий вызов]
C --> D{err != nil?}
D -- да --> E
D -- нет --> F[Продолжаем happy path]
Именно это и делает if err != nil { return ... }: держит контроль потока честным и читаемым.
3. Ранний return вместо лесенки else
Давайте поговорим о типичной альтернативе: вы начинаете писать обработку ошибок, но вместо ранних возвратов строите “лесенку”:
- if err == nil { ... } else { ... }
- внутри if ещё if err == nil { ... } else { ... }
- и так далее, пока ваш код не становится похож на многоэтажку без лифта.
Проблема “лесенки” не в том, что она “не по стилю”. Проблема в читабельности: вы хотите глазами видеть основной сценарий, но вынуждены пробираться через уровни вложенности. Go-подход обычно другой: ошибку проверили, вышли, а основной сценарий продолжается на том же уровне отступов.
Сравним на коротком примере. Допустим, мы хотим распарсить два числа и вывести сумму.
Вариант с вложенностью
package main
import (
"fmt"
"strconv"
)
func printSum(a, b string) error {
x, err := strconv.Atoi(a)
if err == nil {
y, err := strconv.Atoi(b)
if err == nil {
fmt.Println("result:", x+y) // result: ...
return nil
}
return err
}
return err
}
Работает, но заметно, что “нормальная” логика зажата внутрь if.
Вариант по‑Go: ранние возвраты
package main
import (
"fmt"
"strconv"
)
func printSum(a, b string) error {
x, err := strconv.Atoi(a)
if err != nil {
return err
}
y, err := strconv.Atoi(b)
if err != nil {
return err
}
fmt.Println("result:", x+y) // result: ...
return nil
}
Этот пример часто приводят как иллюстрацию того, как выглядит типичный Go‑код с ошибками. Да, строк больше, но структура ясная: сверху вниз читается “что делаем”, а обработка ошибок — это “охранники у двери”, которые не пускают нас дальше, если что-то пошло не так.
4. Что писать в return ... и зачем zero value
Сама идиома if err != nil { return ... } почти всегда живёт внутри функций, которые возвращают ошибку наружу. Но тут у новичков возникает два устойчивых вопроса.
Первый: “Можно ли просто return err?”
Можно, если ваша функция возвращает только error. Но часто у нас контракт (T, error), и тогда нужно вернуть два значения.
Второй: “А что вернуть вместо T, если произошла ошибка?”
По базовой практике Go возвращают zero value типа T. Это не магия и не обязательный закон природы, а практическая защита от случайного использования результата, который на самом деле невалиден.
Смотрите небольшой пример: парсим число из строки, но хотим запретить отрицательные значения.
package main
import (
"fmt"
"strconv"
)
func ParseNonNegativeInt(s string) (int, error) {
n, err := strconv.Atoi(s)
if err != nil {
return 0, err
}
if n < 0 {
return 0, fmt.Errorf("expected non-negative integer, got %d", n)
}
return n, nil
}
Здесь 0 — zero value для int. Мы как бы говорим вызывающему коду: “результата нет, не трогай его — смотри на err”.
5. Правило читаемости: ошибка — выход
Сейчас зафиксируем простое правило, которое помогает писать и читать Go‑код.
Если случилась ошибка, обычно дальше по этой функции делать нечего. Значит, ветка err != nil должна либо возвращать управление (return), либо резко менять поток (например, break в цикле). В рамках нашей темы мы концентрируемся именно на “возврате наружу”.
Эта привычка делает две вещи одновременно: код становится линейным (меньше вложенности), и становится труднее “случайно продолжить” с плохими данными.
Есть важный психологический эффект: когда вы читаете функцию, ваш мозг быстро учится распознавать “охранников”:
if err != nil {
return ...
}
и почти автоматически их “пролистывает”, понимая: это защита, а не основная логика. В заметках про обработку ошибок в Go подчёркивается именно этот момент: явные проверки ошибок — часть модели языка, и они формируют понятный контроль потока.
6. return err и добавление контекста без %w
Очень частая ситуация: ошибка пришла изнутри (например, из strconv.Atoi), и вы хотите вернуть её наружу. Если вы просто сделаете return 0, err, это корректно. Но иногда снаружи будет непонятно, что именно вы пытались сделать и на каких данных.
Поэтому у нас есть аккуратный приём: добавить контекст текстом через fmt.Errorf, не сохраняя причину как отдельную структуру (это мы не трогаем сегодня).
package main
import (
"fmt"
"strconv"
)
func ParseID(idStr string) (int, error) {
id, err := strconv.Atoi(idStr)
if err != nil {
return 0, fmt.Errorf("parse id %q: %v", idStr, err)
}
return id, nil
}
Такой стиль “операция: причина” регулярно встречается в Go‑коде. Например, в разборе ошибок (исторически до оборачивания) прямо показывают добавление контекста через fmt.Errorf("...: %v", err).
7. Паттерн run() error и один if err != nil в main()
Теперь давайте перейдём к настоящей практике: как организовать код так, чтобы обработка ошибок не размазывалась по всему main.
Распространённый и очень удобный приём: сделать функцию run() error, внутри которой вся логика, а main() становится тонкой оболочкой: вызвал run(), проверил ошибку, вывел сообщение и завершился. Это особенно хорошо тренирует идиому дня, потому что в run() вы будете писать много ранних возвратов.
Начнём с каркаса:
package main
import "fmt"
func run() error {
// здесь будет “настоящая” логика
return nil
}
func main() {
if err := run(); err != nil {
fmt.Println("error:", err)
return
}
}
Обратите внимание на запись if err := run(); err != nil { ... }: мы объявили err прямо внутри if, и он живёт только там. Это уменьшает шанс случайно использовать старое значение err где-то дальше.
8. Шаги внутри run(): пример TaskBox
Сделаем очень простую учебную штуку: программа читает команду из stdin и выполняет один шаг. Мы не делаем полноценный CLI с флагами (это будет позже), а используем то, что уже умеем: fmt.Scan.
Поддержим две команды:
- add <title> — добавить задачу (в память),
- get <id> — вывести задачу по индексу.
Чтобы не уходить в структуры (они ещё впереди), будем хранить задачи в []string. Индекс в слайсе и будет нашим id.
Вот заготовка run(), которая читает команду и аргумент:
package main
import "fmt"
func run() error {
var cmd string
var arg string
if _, err := fmt.Scan(&cmd, &arg); err != nil {
return err
}
fmt.Println("cmd:", cmd, "arg:", arg) // пример: cmd: add arg: Buy_milk
return nil
}
Здесь уже видно ритм: вызов Scan → проверка → ранний return.
Теперь добавим минимальную логику с отдельными функциями, чтобы ошибки было удобно возвращать наружу.
Добавление задачи: ошибка, если название пустое
package main
import (
"errors"
)
func AddTask(tasks []string, title string) ([]string, error) {
if len(title) == 0 {
return nil, errors.New("task title is empty")
}
tasks = append(tasks, title)
return tasks, nil
}
Заметьте: при ошибке мы возвращаем nil вместо слайса (это zero value для []string) и ошибку. Это честно: “нового состояния нет, операция не выполнена”.
Получение задачи: ошибка, если индекс вне диапазона
package main
import "fmt"
func GetTask(tasks []string, id int) (string, error) {
if id < 0 || id >= len(tasks) {
return "", fmt.Errorf("task id out of range: %d", id)
}
return tasks[id], nil
}
Здесь zero value для string — пустая строка "".
9. run() как оркестратор: проверяем ошибку после каждого шага
Теперь самое важное: показать “оркестраторский” стиль Go, где run() вызывает шаги один за другим и после каждого шага делает ровно то, чему посвящена лекция.
package main
import (
"fmt"
"strconv"
)
func run() error {
tasks := []string{"Learn Go"} // стартовые данные, чтобы было что получать
var cmd, arg string
if _, err := fmt.Scan(&cmd, &arg); err != nil {
return err
}
if cmd == "add" {
var err error
tasks, err = AddTask(tasks, arg)
if err != nil {
return err
}
fmt.Println("ok") // ok
return nil
}
if cmd == "get" {
id, err := strconv.Atoi(arg)
if err != nil {
return fmt.Errorf("parse id %q: %v", arg, err)
}
task, err := GetTask(tasks, id)
if err != nil {
return err
}
fmt.Println(task) // например: Learn Go
return nil
}
return fmt.Errorf("unknown command %q", cmd)
}
Здесь много “маленьких” if err != nil { return ... }, но это как раз и есть “правильная скука” Go: каждый шаг либо прошёл, либо мы честно выходим.
10. Полезные микро‑паттерны и анти‑паттерны
Микро‑паттерн: if _, err := ...; err != nil { return ... }
Иногда функция возвращает полезное значение и error, но значение вам не нужно. Например, вы что-то печатаете или выполняете действие “ради эффекта”. В таких случаях удобно использовать подчёркивание _ и короткое объявление err прямо в if.
package main
import "fmt"
func PrintHeader() error {
if _, err := fmt.Println("=== TaskBox ==="); err != nil {
return err
}
return nil
}
Этот паттерн делает две полезные вещи: он не загрязняет функцию переменными и сразу показывает читателю: “важна только ошибка”.
Анти‑паттерн: “напечатать err и идти дальше”
Очень типичная ошибка новичка: он увидел err, испугался, напечатал его… и продолжил выполнять код, как будто всё нормально. Это особенно опасно в функциях, где результат на ошибке превращается в zero value. Например, strconv.Atoi("x") вернёт 0 и ошибку. Если вы проигнорируете ошибку, у вас внезапно окажется “валидное” число 0, и вы дальше будете вычислять что-то совершенно не то.
Идиома if err != nil { return ... } как раз лечит эту болезнь: в ней “правильная реакция” на ошибку встроена в структуру кода. Вы либо обработали проблему (и изменили поток), либо вышли наружу, чтобы обработал кто-то выше.
11. Типичные ошибки
Ошибка №1: проверить err, но не изменить поток выполнения.
Часто пишут fmt.Println(err) внутри if err != nil, а потом продолжают работу с данными, которые на самом деле невалидны. Если ошибка означает, что шаг не выполнен, то после проверки обычно нужен ранний выход: return err (или другой явный выход из текущего сценария).
Ошибка №2: перепутать условие и обработать ошибку в ветке err == nil.
Это выглядит смешно, пока не случилось ночью в пятницу перед дедлайном. Держите в голове простую “мантру”: err != nil — это ветка проблемы, err == nil — это ветка успеха. Если вы пишете ранний возврат, то почти всегда условие именно err != nil.
Ошибка №3: использовать результат до проверки ошибки.
В контрактах (T, error) значение T при ошибке часто равно zero value. Оно выглядит “нормальным” и поэтому особенно опасно. Правильный порядок в Go почти ритуальный: вызвал → проверил err → только потом используешь T.
Ошибка №4: возвращать “полу‑результат” вместе с ошибкой и надеяться, что вызывающий “как-нибудь разберётся”.
Иногда хочется вернуть “то, что успели посчитать”, и параллельно ошибку. На уровне новичка это обычно приводит к тому, что результат используют и забывают проверить err. В базовом стиле Go безопаснее возвращать zero value результата на ошибке, чтобы неправильное использование было менее вероятным.
Ошибка №5: плодить вложенность вместо ранних возвратов.
Когда вы оборачиваете каждый шаг в if err == nil { ... }, вы получаете лесенку отступов, и “нормальный сценарий” прячется внутрь. Ранний return как раз нужен, чтобы happy path оставался “по прямой”, без тоннелей и подземных уровней.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ