Как вообще работают ошибки в Go?
В Java, JavaScript, Python или C# вы могли привыкнуть к конструкции примерно такого типа:
try:
user=get_user()
except Exception as e:
print(e)Функция внутри может выбросить исключение, которое полетит вверх по стеку, пока где-нибудь не встретится обработчик.
В Go основной подход другой.
Ошибка — это обычное возвращаемое значение.
Например:
func getUser(id int)(User,error){
// ...
}Функция обещает вернуть две вещи:
User;error.
Вызываем:
user,err:=getUser(42)Если всё хорошо:
err==nilЕсли что-то пошло не так:
err!=nilОтсюда и главный мем Go-разработки:
if err!=nil{
return err
}
Почему именно nil?
В Go существует встроенный интерфейс:
type error interface{
Error() string
}Любой тип, реализующий метод Error() string, может использоваться как ошибка.
Например:
type ValidationError struct{
Field string
}
func(e ValidationError)Error()string{
return "invalid field: "+e.Field
}А nil означает: ошибки нет, продолжаем жить.
Поэтому типичный код выглядит так:
result,err:=doSomething()
if err!=nil{
return err
}Именно эта конструкция встречается практически в каждом Go-проекте.
Но почему Go просто не добавит try/catch?
Это не случайность и не потому, что разработчики языка забыли.
Идея Go заключается в том, что обработка ошибок должна быть явной.
Посмотрим на условный код с исключениями:
const user=await getUser()
const orders=await getOrders(user.id)
const invoice=await createInvoice(orders)
sendEmail(invoice)Выглядит красиво.
Но какие из этих функций могут упасть?
getUser? getOrders? createInvoice? sendEmail?
Теоретически — все.
И чтобы понять реальное поведение программы, приходится смотреть документацию или реализацию.
В Go это часто выглядит так:
user,err:=getUser()
if err!=nil{return err}
orders,err:=getOrders(user.ID)
if err!=nil{return err}
invoice,err:=createInvoice(orders)
if err!=nil{return err}Да, строк больше.
Зато буквально глазами видно: вот здесь операция может завершиться ошибкой.
Go в этом плане немного напоминает очень тревожного коллегу:
— А если файл не откроется?
— Проверим.
— А если база не ответит?
— Проверим.
— А если JSON сломан?
— Проверим.
— А если…
ДА, МЫ УЖЕ ПИШЕМ if err != nil.
Что реально означает if err != nil?
Начинающие часто воспринимают его просто как обязательный шаблон.
Но правильнее читать такой код:
result,err:=operation()
if err!=nil{
return err
}как: выполни операцию; если она не удалась — прекрати текущую функцию и сообщи об ошибке вызывающему коду.
Например:
func loadConfig()([]byte,error){
data,err:=os.ReadFile("config.json")
if err!=nil{
return nil,err
}
return data,nil
}Теперь вызывающий код решает, что делать дальше:
config,err:=loadConfig()
if err!=nil{
log.Fatal(err)
}Получается цепочка:
os.ReadFile()
↓
loadConfig()
↓
main()Ошибка постепенно поднимается туда, где её действительно можно нормально обработать.
Главная ошибка новичка: просто возвращать err
Вот такой код технически нормальный:
data,err:=os.ReadFile("config.json")
if err!=nil{
return err
}Но представьте большой проект.
Через пять уровней прилетает:
open config.json: no such file or directoryИ начинается мини-игра: «угадай, где именно всё развалилось».
Поэтому ошибки часто стоит дополнять контекстом.
fmt.Errorf: добавляем контекст
Например:
data,err:=os.ReadFile("config.json")
if err!=nil{
return fmt.Errorf("read config: %w",err)
}Теперь сообщение может выглядеть так:
read config: open config.json: no such file or directoryУже значительно понятнее.
А если ошибка проходит ещё через один слой:
config,err:=loadConfig()
if err!=nil{
return fmt.Errorf("start application: %w",err)
}Получим:
start application: read config: open config.json: no such file or directoryКрасота.
Точнее, приложение всё ещё умерло.
Но умерло информативно.
Что за %w?
Очень важный момент.
Можно написать:
fmt.Errorf("load user: %v",err)А можно:
fmt.Errorf("load user: %w",err)Разница огромная.
%w оборачивает исходную ошибку, сохраняя её внутри новой.
Это позволяет позже проверить причину через errors.Is() или достать конкретный тип ошибки через errors.As().
Поэтому для передачи ошибки выше обычно используется именно:
return fmt.Errorf("load user: %w",err)errors.Is: сравниваем ошибки правильно
Допустим, файл не найден.
Можно попробовать:
if err==os.ErrNotExist{
// ...
}Но если ошибка была обёрнута через fmt.Errorf, обычное сравнение может не сработать.
Используется:
if errors.Is(err,os.ErrNotExist){
// файл не найден
}Например:
data,err:=os.ReadFile("config.json")
if err!=nil{
if errors.Is(err,os.ErrNotExist){
fmt.Println("Config file not found")
}
return err
}errors.Is умеет идти по цепочке обёрнутых ошибок.
application failed
└── cannot load settings
└── cannot read file
└── file does not existИ находить исходную причину.
errors.As: когда нужен конкретный тип ошибки
Иногда недостаточно понять, что произошла определённая ошибка. Нужно достать данные из неё.
type ValidationError struct{
Field string
}
func(e *ValidationError)Error()string{
return "invalid field: "+e.Field
}Функция возвращает её:
return &ValidationError{
Field:"email",
}Выше можем написать:
var validationErr *ValidationError
if errors.As(err,&validationErr){
fmt.Println(validationErr.Field)
}И получить:
emailПолезно, когда ошибки несут дополнительную информацию.
А можно создать ошибку вручную?
Да. Для простых случаев есть errors.New().
var ErrUserNotFound=errors.New("user not found")И использовать:
func getUser(id int)(User,error){
if id<=0{
return User{},ErrUserNotFound
}
// ...
}А выше:
user,err:=getUser(id)
if errors.Is(err,ErrUserNotFound){
// показать 404
}Такие ошибки часто объявляют как package-level переменные:
var ErrNotFound=errors.New("not found")
var ErrUnauthorized=errors.New("unauthorized")Их называют sentinel errors.
По сути это специальные маркеры: произошла ошибка конкретной категории.
Ошибки — это часть API функции
Вот здесь начинает раскрываться философия Go.
func GetUser(id int)Userи:
func GetUser(id int)(User,error)Это два разных контракта.
Вторая функция прямо говорит: я могу не вернуть пользователя.
В языках с исключениями такая информация часто скрыта.
Функция выглядит совершенно невинно:
user=get_user(id)Но внутри потенциально могут прилететь DatabaseError, TimeoutError, NotFoundError или ConnectionError.
В Go возможность ошибки буквально присутствует в сигнатуре:
(User,error)И это одна из причин, почему Go-код обычно довольно легко читать сверху вниз.
Можно ли игнорировать ошибку?
Технически да.
data,_:=os.ReadFile("config.json")Подчёркивание _ говорит: второе значение мне не нужно.
Но с ошибками так делать стоит только тогда, когда вы реально понимаете последствия.
result,_:=strconv.Atoi("hello")
fmt.Println(result)Можно получить:
0И где-то через 500 строк вы будете искать: «а какого чёрта у нас тут ноль?»
Хотя ошибка произошла гораздо раньше.
Игнорирование ошибок — один из лучших способов сделать баг, который потом будет смотреть на вас из production.
Нужно ли логировать каждую ошибку?
Нет.
Вот распространённый анти-паттерн:
if err!=nil{
log.Println(err)
return err
}А выше:
if err!=nil{
log.Println(err)
return err
}И ещё выше:
if err!=nil{
log.Println(err)
}В итоге одна ошибка превращается в логах в:
database timeout
database timeout
database timeout
database timeoutИ человек на дежурстве думает, что база умерла четыре раза. Хотя умерла один.
Часто хороший принцип такой: нижние уровни возвращают ошибку с контекстом, верхний уровень решает, когда её логировать.
А где panic?
Вот тут начинающие часто говорят: «Подождите. В Go же есть panic. Почему бы вместо всех этих err просто не использовать его?»
Потому что panic — это не обычный механизм обработки ошибок.
panic("something went terribly wrong")После panic нормальное выполнение функции прекращается и начинается раскрутка стека.
Есть ещё recover(), который может перехватить panic.
Звучит подозрительно похоже на исключения, но философия применения другая.
error или panic?
Обычная ожидаемая проблема → error
файл не найден;
пользователь ввёл неверные данные;
API недоступен;
запрос в БД завершился ошибкой;
сервер вернул 500;
JSON не распарсился.
Это нормальные ситуации, которые программа должна уметь переживать.
Программа попала в состояние, которого вообще не должно существовать → возможно panic
func MustConfig(path string)Config{
config,err:=loadConfig(path)
if err!=nil{
panic(err)
}
return config
}Иногда приложение действительно не имеет смысла запускать без обязательной конфигурации.
Но превращать каждую ошибку в:
panic(err)— практически гарантированный способ превратить приложение в минное поле.
Знаменитая короткая форма
Поскольку if err != nil встречается постоянно, Go-разработчики часто совмещают вызов и проверку:
if err:=doSomething();err!=nil{
return err
}Вместо:
err:=doSomething()
if err!=nil{
return err
}Особенно удобно, если результат функции кроме ошибки не нужен.
if err:=db.Ping();err!=nil{
return fmt.Errorf("database unavailable: %w",err)
}Переменная err при этом существует только внутри if.
Почему Go-разработчики защищают эту систему
Когда впервые сталкиваешься с Go, выглядит это примерно так:
if err!=nil{return err}
if err!=nil{return err}
if err!=nil{return err}
if err!=nil{return err}МОЖЕТ ХВАТИТ УЖЕ?
Но через некоторое время становится понятно, зачем это сделали.
1. Ошибки видно
Вы смотрите на код и сразу понимаете, где функция может завершиться.
2. Контроль потока очевидный
Не нужно гадать, где внезапно вылетит исключение.
3. Обработку сложнее случайно забыть
Возвращаемый error находится прямо рядом с результатом функции.
4. Обработка находится рядом с проблемой
file,err:=os.Open(path)
if err!=nil{
// решение прямо здесь
}5. Код становится предсказуемым
А предсказуемый код особенно ценен в больших backend-системах.
Иногда скучный код — лучший код.
Это, возможно, самый Go-шный тезис всей статьи.
Но давайте признаем: boilerplate реально есть
Да. Go часто критикуют именно за количество повторяющейся обработки ошибок.
user,err:=loadUser()
if err!=nil{return err}
orders,err:=loadOrders(user.ID)
if err!=nil{return err}
invoice,err:=createInvoice(orders)
if err!=nil{return err}
err=sendInvoice(invoice)
if err!=nil{return err}Очень компактным такой код не назовёшь.
Но язык делает выбор в пользу: явности > магии.
И это одна из фундаментальных особенностей Go.
Как НЕ надо обрабатывать ошибки в Go
1. Игнорировать ошибку
result,_:=dangerousOperation()Без веской причины — плохо.
2. Сравнивать текст ошибки
if err.Error()=="user not found"{
// ...
}Лучше:
if errors.Is(err,ErrUserNotFound){
// ...
}Текст ошибки может поменяться. Тип или sentinel error — значительно надёжнее.
3. Терять исходную ошибку
return fmt.Errorf("cannot load user: %v",err)Часто хуже, чем:
return fmt.Errorf("cannot load user: %w",err)Потому что %w сохраняет цепочку.
4. Логировать ошибку на каждом уровне
Получаете лог-матрёшку.
Лучше добавлять контекст:
return fmt.Errorf("checkout: %w",err)и логировать там, где запрос окончательно обрабатывается.
5. Использовать panic вместо error
if err!=nil{
panic(err)
}Не превращайте сервер в приложение, которое при первом неправильном JSON говорит: «ну всё, я увольняюсь».
Как выглядит хороший Go-код с ошибками
func LoadUser(id int)(*User,error){
user,err:=repository.FindByID(id)
if err!=nil{
if errors.Is(err,sql.ErrNoRows){
return nil,ErrUserNotFound
}
return nil,fmt.Errorf("find user %d: %w",id,err)
}
return user,nil
}А выше:
user,err:=LoadUser(id)
if err!=nil{
if errors.Is(err,ErrUserNotFound){
http.Error(w,"user not found",http.StatusNotFound)
return
}
log.Printf("load user failed: %v",err)
http.Error(w,"internal server error",http.StatusInternalServerError)
return
}Здесь уже видно нормальную архитектуру:
База данных
↓
Repository
↓
Service
↓
HTTP handler
↓
ПользовательКаждый уровень решает свою задачу.
Как быстрее привыкнуть к Go?
Самый бесполезный способ учить обработку ошибок: прочитать 40 страниц документации и решить, что вроде всё понятно.
А потом открыть редактор:
package main
func main(){
}И десять минут смотреть на мигающий курсор.
Go хорошо учится именно через практику: написать функцию, получить error, обработать его, специально сломать входные данные и посмотреть, что произойдёт.
В Кодике как раз можно учить программирование таким способом: небольшая теория → код → задания → практика прямо во время обучения.
Так гораздо быстрее становится понятно, зачем нужны error, if err != nil, fmt.Errorf() и другие конструкции, которые в статье выглядят очевидными, а в собственном проекте внезапно превращаются в: «стоп, а сюда что возвращать?»
А если хочется регулярно повторять программирование небольшими порциями, заглядывайте и в наше Telegram-сообщество Кодика. Там выходят полезные разборы, задачи, фишки языков и материалы, которые удобно прочитать между делом и сохранить себе.
