Язык Go — ошибки, panic и recover
Обработка ошибок в Go — интерфейс error, кастомные типы ошибок, panic для неустранимых ситуаций и recover для перехвата паники внутри пакета.
Содержание
В Go нет исключений. Ошибки — обычные значения, которые функция возвращает явно. Это вынуждает вызывающий код обработать ошибку прямо на месте, а не надеяться что кто-то поймает исключение выше по стеку.
Интерфейс error
По соглашению ошибки имеют тип error — встроенный интерфейс:
type error interface {
Error() string
}
Любой тип с методом Error() string удовлетворяет этому интерфейсу. Это даёт авторам библиотек свободу реализовывать ошибки с любым уровнем детализации.
Например, os.Open при ошибке возвращает не просто nil-указатель, а значение типа *os.PathError с подробным описанием что именно пошло не так:
// PathError содержит операцию, путь к файлу и исходную ошибку ОС
type PathError struct {
Op string // "open", "unlink" и т.д.
Path string // путь к файлу
Err error // ошибка системного вызова
}
func (e *PathError) Error() string {
return e.Op + " " + e.Path + ": " + e.Err.Error()
}
Такая ошибка выглядит так:
open /etc/passwx: no such file or directory
Это намного информативнее чем просто "no such file or directory" — даже если ошибка напечатана далеко от места где она возникла, сразу понятно что за файл, какая операция и что случилось.
Правила именования ошибок
По соглашению строка ошибки должна содержать источник — имя операции или пакета:
// хорошо: сразу понятно откуда ошибка
"image: unknown format"
"json: cannot unmarshal string into Go value of type int"
"http: request too large"
// плохо: непонятно откуда
"unknown format"
"cannot unmarshal"
Строки ошибок не начинаются с заглавной буквы и не заканчиваются точкой — они часто вставляются в более длинные сообщения через конкатенацию.
Извлечение деталей из ошибки
Если нужно получить конкретную информацию из ошибки — используем type assertion:
for try := 0; try < 2; try++ {
file, err = os.Create(filename)
if err == nil {
return // всё хорошо
}
// проверяем: это PathError с ошибкой «нет места на диске»?
if e, ok := err.(*os.PathError); ok && e.Err == syscall.ENOSPC {
deleteTempFiles() // освобождаем место
continue // пробуем снова
}
return // другая ошибка — выходим
}
err.(*os.PathError) — type assertion: если err содержит *os.PathError, ok будет true и e даст доступ к полям структуры. Если нет — ok равно false, e равно nil, паники нет.
Panic: когда продолжение невозможно
Обычный способ сообщить об ошибке — вернуть её как значение. Но иногда программа действительно не может продолжать работу.
Для этого есть встроенная функция panic. Она принимает аргумент любого типа — обычно строку — который будет напечатан при падении программы:
// учебная реализация кубического корня методом Ньютона
func CubeRoot(x float64) float64 {
z := x / 3 // начальное приближение
for i := 0; i < 1e6; i++ {
prevz := z
z -= (z*z*z - x) / (3 * z * z)
if veryClose(z, prevz) {
return z
}
}
// миллион итераций не сошёлся — что-то принципиально не так
panic(fmt.Sprintf("CubeRoot(%g) did not converge", x))
}
Это учебный пример. Реальные библиотечные функции должны избегать panic — если проблему можно обойти, лучше вернуть ошибку и дать вызывающему коду решить что делать.
Исключение — инициализация: если библиотека не может настроить себя при старте, panic оправдан:
var user = os.Getenv("USER")
func init() {
if user == "" {
panic("no value for $USER") // без этого программа всё равно не заработает
}
}
Recover: перехват паники
Когда вызывается panic — Go немедленно прекращает выполнение текущей функции и начинает раскручивать стек горутины, вызывая отложенные функции (defer) по пути. Если раскрутка доходит до верха стека — программа падает.
Однако встроенная функция recover позволяет перехватить панику и возобновить нормальное выполнение. recover останавливает раскрутку стека и возвращает значение переданное в panic.
recover полезен только внутри defer — это единственный код который выполняется во время раскрутки стека:
func safelyDo(work *Work) {
defer func() {
if err := recover(); err != nil {
log.Println("work failed:", err) // логируем и выходим чисто
}
}()
do(work) // если запаникует — defer выше поймает
}
func server(workChan <-chan *Work) {
for work := range workChan {
go safelyDo(work) // паника в одной горутине не убьёт сервер
}
}
Если do(work) запаникует — горутина завершится чисто, ошибка будет залогирована, остальные горутины продолжат работу. Весь сервер не упадёт.
Важная деталь: recover возвращает nil если вызван не из defer или если паники не было. Это значит что отложенный код может вызывать другие функции которые внутри используют panic/recover — они не будут перехвачены внешним recover.
Паттерн: panic/recover внутри пакета
Иногда внутри сложной рекурсивной логики (например, парсера) удобно использовать panic для выхода из глубокого стека вызовов — вместо того чтобы пробрасывать ошибку через каждый уровень. Главное правило: не выпускать панику наружу из пакета.
Пример из идеализированного пакета regexp:
// Error — тип ошибки парсинга; удовлетворяет интерфейсу error
type Error string
func (e Error) Error() string {
return string(e)
}
// error — метод *Regexp для удобного вызова panic с типом Error
// (называется error — совпадение с именем встроенного типа допустимо для методов)
func (regexp *Regexp) error(err string) {
panic(Error(err))
}
// Compile парсит регулярное выражение и возвращает ошибку если не удалось
func Compile(str string) (regexp *Regexp, err error) {
regexp = new(Regexp)
defer func() {
if e := recover(); e != nil {
regexp = nil // сбрасываем возвращаемое значение
err = e.(Error) // если это не Error — будет re-panic (неожиданная ошибка)
}
}()
return regexp.doParse(str), nil // doParse может вызвать panic(Error(...))
}
Как это работает:
doParseглубоко внутри вызываетre.error("'*' illegal at start of expression")- это вызывает
panic(Error(...)) - раскрутка стека поднимается до
deferвCompile recover()перехватывает панику и возвращает значение- type assertion
e.(Error)проверяет: это наша ошибка парсинга? - если да — конвертируем в
errorи возвращаем вызывающему - если нет (например, index out of bounds) —
e.(Error)паникует снова, и неожиданная ошибка продолжает раскручивать стек как ни в чём не бывало
Поэтому внутри парсера можно просто писать:
if pos == 0 {
re.error("'*' illegal at start of expression") // без возни с возвратом ошибок через стек
}
Снаружи вызывающий код видит обычную ошибку — никакой паники не утекает из пакета.
Правило: паника не выходит за пределы пакета
Это важное соглашение в Go: если пакет использует panic внутри себя — он должен перехватить её через recover и вернуть наружу как error.
// правильно: паника перехвачена, наружу идёт error
func Parse(input string) (result Result, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("parse failed: %v", r)
}
}()
return doParse(input), nil
}
// неправильно: паника утекает наружу — вызывающий код не ожидает этого
func Parse(input string) Result {
return doParse(input) // может запаниковать — плохой API
}
Итоги
error— встроенный интерфейс с единственным методомError() string. Любой тип его реализующий может быть ошибкой- Строки ошибок начинаются с имени пакета или операции, не начинаются с заглавной буквы, не заканчиваются точкой
- Type assertion
err.(*ConcreteType)позволяет извлечь детали из конкретного типа ошибки panic— для ситуаций когда продолжение невозможно. Библиотечные функции должны избегатьpanic; исключение — инициализацияrecoverработает только внутриdefer. Перехватывает панику и возвращает значение переданное вpanic- Паттерн panic/recover внутри пакета: удобен для сложной рекурсивной логики, но паника не должна выходить наружу
e.(Error)в recover: если тип не совпадает — re-panic, неожиданные ошибки не поглощаются