Pull to refresh

Ошибки — это значения

Reading time5 min
Views25K
Original author: Rob Pike
(Перевод статьи из официального блога Go)

Частой темой среди Go программистов, особенно тех, которые только познакомились с языком, является вопрос о том, как обрабатывать ошибки. Разговор часто сводится к жалобам на то, что последовательность
if err != nil {
    return err
}

появляется слишком часто. Недавно мы просканировали все open-source проекты, которые мы только смогли найти и увидели, что этот сниппет появляется лишь раз на страницу или две, гораздо реже, чем многие могли бы подумать. И всё же, если впечатление того, что вы должны всегда писать
 if err != nil
остается, значит, очевидно, что-то тут не так, и мишенью оказывается сам Go.

Это неверно, это вводит в заблуждение и это легко исправить. Наверное происходит следующее — программист, знакомясь с Go, задаёт вопрос — «Как я должен обрабатывать ошибки?», заучивает этот паттерн и тут и останавливается. В других языках, это может быть блок try-catch или другой механизм обработки ошибок. Соответственно, думает программист, там где я бы использовал try-catch в моём старом языке, в Go я просто напечатаю if err != nil. Со временем, в Go коде накапливается много таких сниппетов, и результат выглядит неуклюже.

Но вне зависимости от того, как это объясняется на самом деле, очевиден тот факт, что эти Go программисты упускают фундаментальную идею ошибок: Ошибки это значения.

Значениями можно оперировать и программировать различную логику, и поскольку ошибки это значения, то и ошибками также можно оперировать и программировать логику.

Конечно, самой частой операцией с ошибкой-значением будет проверка, является она нулевой или нет, но есть бесчисленное количество других вещей, которые можно делать со значением, и применение многих из них может сделать вашу программу лучше, убрав большую часть boilerplate, которая появляется, если каждую ошибку проверять, без раздумий, через if.

Вот простой пример из пакета bufio, тип Scanner. В нём метод Scan осуществляет низлежащие операции ввода-вывода, которые, разумеется, могут вернуть ошибку. Но при этом, метод Scan, не возвращает ошибку вообще. Взамен, он возвращает boolean, и отдельный метод, который может быть запущен в конце процесса сканирования, сообщая, произошла ли ошибка. Пользовательский код выглядит примерно так:
scanner := bufio.NewScanner(input)
for scanner.Scan() {
    token := scanner.Text()
    // process token
}
if err := scanner.Err(); err != nil {
    // process the error
}

Безусловно, тут есть проверка ошибки на nil, но она происходит и выполняется лишь раз. При этом, метод Scan мог бы быть определён как
func (s *Scanner) Scan() (token []byte, error)

и пользовательский код мог выглядеть как-нибудь так (в зависимости от того, как извлекается токен),
scanner := bufio.NewScanner(input)
for {
    token, err := scanner.Scan()
    if err != nil {
        return err // or maybe break
    }
    // process token
}

Эти подходы не сильно отличается, но тут есть одно важное отличие. В этом коде, пользователь должен проверять ошибку на каждой итерации, но в самом API сканера, обработка ошибок происходит на другом уровне абстракций — ключевой элемент API работает с токенами. В существующем же дизайне API, пользовательский код таким образом выглядит более естественным: сначала пройтись до конца по токенам, а потом проверять и волноваться об ошибках. Обработка ошибок не прячет поток управления.

Под капотом всего этого, конечно, происходит следующее — как только Scan обнаруживает ошибку ввода-вывода, она сохраняет ошибку и возвращает false. Отдельный метод, Err, её возвращает, когда клиент запросит. Тривиально, но это не тоже самое, что вставлять
if err != nil

во все места или просить клиента проверять ошибку на каждый токен. Это программирование с использованием значений ошибок. Простое программирование, да, но всё же программирование.

Важно отметить, что какой бы ни был дизайн, очень критично, чтобы программа проверяла ошибки и чтобы ошибки были доступны. Обсуждение тут не о том, как избежать проверки ошибок, а о том, как использовать язык, чтобы обрабатывать ошибки красиво.

Тема повторяющегося кода для обработки ошибок поднялась, когда я был на конференции GoCon осенью 2014 в Токио. Гофер-энтузиаст, под ником @jxck_ в Твиттере, повторил частую жалобу про проверку ошибок. У него был некоторый код, который схематически выглядит примерно так:
_, err = fd.Write(p0[a:b])
if err != nil {
    return err
}
_, err = fd.Write(p1[c:d])
if err != nil {
    return err
}
_, err = fd.Write(p2[e:f])
if err != nil {
    return err
}
// and so on

Это слишком повторяющийся код. В реальном примере, который был длиннее, было ещё масса кода и было непросто просто так отрефакторить этот код, создав функцию-хелпер, но в общем случае, литерал функции, замыкающую в себе обработку ошибки мог бы помочь:
var err error
write := func(buf []byte) {
    if err != nil {
        return
    }
    _, err = w.Write(buf)
}
write(p0[a:b])
write(p1[c:d])
write(p2[e:f])
// and so on
if err != nil {
    return err
}

Этот паттерн работает хорошо, но требует замыкание в каждой функции, которая должна делать запись, а отдельная внешняя функция для этого будет неуклюжей, поскольку переменная err должна переиспользоваться между вызовами (попробуйте).

Мы можем сделать этот код более ясным, более общим и переиспользуемым, если позаимствуем идею из метода Scan, которую обсуждали выше. Я упомянул эту технику в нашем разговоре, но @jcxk_ не понял, как её применить. После длинной беседы, несколько стесненной языковым барьером, я спросил, могу ли я просто взять его ноутбук и показать пример, написав реальный код.

Я определил объект под названием errWriter, примерно вот так:
type errWriter struct {
    w   io.Writer
    err error
}

и дал ему один метод, write. Он не должен даже следовать стандартной Write сигнатуре, и он намеренно с маленькой буквы, чтобы подчеркнуть отличие. Метод write вызывает Write метод низлежащего Writer-а и сохраняет ошибку для использования в будущем:
func (ew *errWriter) write(buf []byte) {
    if ew.err != nil {
        return
    }
    _, ew.err = ew.w.Write(buf)
}

Как только ошибка произойдёт, метод write станет no-op, но значение ошибки будет сохранено.

Имея тип errWriter и его метод write, мы можем переписать код выше:
ew := &errWriter{w: fd}
ew.write(p0[a:b])
ew.write(p1[c:d])
ew.write(p2[e:f])
// and so on
if ew.err != nil {
    return ew.err
}

Это намного яснее, даже по сравнению с использованием замыканий, и делает фактическую последовательность записей более удобной в представлении. Больше нет беспорядка. Программирование со значениями ошибок (и интерфейсами) сделало код приятней.

Вполне вероятно, что и другой кусок кода в этом же пакете может быть построен на этой идее, или даже использовать errWriter напрямую.

Помимо прочего, как только errWriter завершает работу, он может делать дополнительные полезные вещи, особенно в менее искусственных примерах. Он может накапливать количество байт, к примеру. Он может объединять запросы на запись в один буфер, который затем будет записан атомарно. И многое другое.

По факту, этот паттерн появляется в стандартной библиотеке весьма часто. Пакеты archive/zip и net/http его используют. Более релевантный к этой дискуссии, Writer в пакете bufio является, по сути, реализацией идеи errWriter. Хотя bufio.Writer.Write возвращает ошибку, это, в основном, для имплементации интерфейса io.Writer. Метод Write в bufio.Writer ведёт себя также, как и errWriter.write в примере выше, и вызывает Flush в момент возврата ошибки, так что наш пример мог бы был записан следующим образом:
b := bufio.NewWriter(fd)
b.Write(p0[a:b])
b.Write(p1[c:d])
b.Write(p2[e:f])
// and so on
if b.Flush() != nil {
    return b.Flush()
}

В таком подходе, впрочем, есть один значительный минус, по крайней мере, для некоторых применений: нет способа узнать, как много данных было обработано до возникновения ошибки. Если эта информация важна, нужен будет более адаптированный подход. Но зачастую, подход всё-или-ничего в конце бывает достаточным.

Мы посмотрели лишь на одну из техник избегания повторяющегося кода проверки ошибок. Имейте ввиду, что использование errWriter или bufio.Writer — не единственный способ упростить обработку ошибок, и этот подход не подходит ко всем случаям. Ключевой урок тут в том, что ошибки — это значения, и вся мощь Go как языка программирования в вашем распоряжении чтобы их обрабатывать.

Используйте язык для того, чтобы упростить обработку ошибок.

Но помните: Чтобы вы ни делали, всегда проверяйте ошибки!

И в заключение, для полноты картины моего общения с @jxck_, включая небольшое записанное им видео, посмотрите его блог.
Tags:
Hubs:
+1
Comments139

Articles

Change theme settings