Ошибки это значения
Продолжаем серию переводов статей из официального го блога.
На этот раз статья Роба Пайка "Errors are values"
Очень часто, темой для обсуждения среди Go программистов, особенно среди новичков, становится процесс обработки ошибок. И, как правило, все сводится к нытью по поводу частого повторения конструкции:
if err != nil {
return err
}
Недавно мы просканировали все открытые проекты, которые смогли найти, и обнаружили, что подобный кусок кода повторяется один-два раза на страницу, меньше чем может показаться с первого раза. Тем не менее, бытует мнение, что постоянно нужно писать
if err != nil
хотя на самом деле это не так.
Такое положение дел немного печалит, вводит в заблуждение, но, что самое главное, легко исправить. Почему так происходит? Вероятно, когда молодой гофер задается вопросом "Как обработать ошибку?", то он выучивает описанный выше паттерн и удовлетворяется этим. В других языках можно использовать try-catch или схожий механизм обработки ошибок. И программист думает, что если try-catch используется в его старом языке, то в Go нужно просто использовать if err != nil
. Со временем, в программе собирается множество таких кусочков и код становится неуклюжим.
Таким образом(или не таким:) Go программист упускает фундаментальный момент в обработке ошибок: ошибки это значения.
Программы пишутся на основе значений, и зная, что ошибки тоже значения, их можно использовать в своем коде для реализации некоторой логики.
Конечно, общий принцип для обработки любого значения ошибки, это проверка на nil
, но есть множество других вариантов работы с значениями ошибки. И использование этих вариантов помогут сделать вашу программу лучше, избавят ее от повторяющегося шаблонного кода, который появляется при использовании стандартных проверок.
Тип Scanner это простой пример из пакета bufio
. Его метод Scan работает с вводом/выводом, что, конечно же, может привести к ошибке. Тем не менее, метод не возвращает все эти возможные ошибки. Вместо этого, он возвращает булевое значение. Существует отдельный метод, который можно вызвать после сканирования и который сообщит об ошибках. Клиентский код будет выглядеть так:
scanner := bufio.NewScanner(input)
for scanner.Scan() {
token := scanner.Text()
// обработка token
}
if err := scanner.Err(); err != nil {
// обработка ошибки
}
Конечно, проверка на ошибки никуда не делась, но она выполняется только один раз. Вместо этого, метод Scan
может быть определен как
func (s *Scanner) Scan() (token []byte, error)
и вот он может быть использован (в зависимости от того, найден ли токен)
scanner := bufio.NewScanner(input)
for {
token, err := scanner.Scan()
if err != nil {
return err // или break
}
// обработка token
}
С виду оба способа похожи, но есть одно очень важное различие. В последнем примере клиент должен проверять ошибки на каждой итерации цикла, но в АПИ Scanner
обработка ошибок абстрагирована от основного функционала АПИ, который осуществляет перебор токенов. При использовании АПИ, клиентский код выглядит более натуральным: сначала цикл завершает итерации, затем уже беспокоимся про ошибки. В этом случае, обработка ошибок не мешает потоку управления.
Конечно, проверка происходит, но она скрыта. Как только 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
}
// и так далее
Это действительно очень повторяющийся код. В реальной программе, которая может быть значительно длиннее, рефакторинг будет еще сложнее, но в конкретном примере мы можем использовать вспомогательную функцию:
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])
// и так далее
if err != nil {
return err
}
Такой подход будет замечательно работать, но требует написать замыкание во всех функциях, где используется Write
. Кроме того, не получится использовать стороннюю функцию(не замыкание), так как переменная err
должна сохраняться между вызовами (можете попробовать написать).
Мы можем сделать это более красиво, обобщенно и реюзабельно используя идеи, описанные выше для метода Scan
. Я упомянул этот способ в нашей дискуссии, но @jxck_ так и не увидел как его применить. После некоторого общения, усложненного языковым барьером, я попросил его ноутбук, чтобы объяснить ему, написав немного кода.
Я определил объект 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
перестает выполнять запись, но значение ошибки сохраняется.
Учитывая 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, этот метод может работать аналогично нашему методу errWriter.write
, используя Flush
для получения ошибок. Это значит, что наш пример можно переписать так:
b := bufio.NewWriter(fd)
b.Write(p0[a:b])
b.Write(p1[c:d])
b.Write(p2[e:f])
// и так далее
if b.Flush() != nil {
return b.Flush()
}
Правда, существует одни недостаток, который может быть критичен в некоторых случаях: нет никакой возможности проверить, на сколько выполнилась задача до возникновения ошибки. Если эта информация действительно критична, нужно использовать более точечный подход. Однако, как правило, проверки все-или-ничего достаточно в большинстве случаев.
Мы рассмотрели только одну из возможных техник уменьшения количества кода при обработке ошибок. Помните, что использование errWriter
или bufio.Writer
не единственный путь упрощения обработки ошибок и он не подходит для всех-всех случаев. Ключевой момент в том, что ошибки это значения и для их обработки доступна вся мощь языка Go.
Используйте язык для упрощения обработки ошибок.
Но помните: что бы вы не делали, ошибки нужно обязательно проверять!
Для полноты истории, вы можете пообщаться @jxck_, посмотреть небольшое видео записанное на встрече и зайти на его блог.