The Go Blog

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

Роб Пайк
12 января 2015

Часто обсуждаемая тема среди разработчиков на языке Go, особенно тех, кто только начинает осваивать этот язык, — это то, как обрабатывать ошибки. Разговор часто превращается в жалобу на количество раз, когда встречается следующая последовательность

<code>if err != nil {
  return err
}
</code>

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

<code>if err != nil
</code>

— значит, что-то не так, и очевидной целью становится сам язык Go.

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

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

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

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

Вот простой пример из типа Scanner пакета bufio. Метод Scan выполняет базовый ввод-вывод, что, конечно, может привести к ошибке. Тем не менее, метод Scan вообще не раскрывает ошибку. Вместо этого он возвращает логическое значение, и отдельный метод, вызываемый в конце сканирования, сообщает, произошла ли ошибка. Код клиента выглядит так:

<code>scanner := bufio.NewScanner(input)
for scanner.Scan() {
  token := scanner.Text()
  // process token
}
if err := scanner.Err(); err != nil {
  // process the error
}
</code>

Конечно, есть проверка на nil для ошибки, но она появляется и выполняется лишь один раз. Метод Scan мог бы быть определён иначе как

<code>func (s *Scanner) Scan() (token []byte, error)
</code>

и тогда пример пользовательского кода мог бы быть (в зависимости от того, как получается токен),

<code>scanner := bufio.NewScanner(input)
for {
  token, err := scanner.Scan()
  if err != nil {
    return err // or maybe break
  }
  // process token
}
</code>

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

Внутри, конечно, происходит следующее: как только Scan сталкивается с ошибкой ввода-вывода, он записывает её и возвращает false. Отдельный метод, Err, сообщает значение ошибки, когда клиент запрашивает его. Хотя это и тривиально, это не то же самое, что ставить

<code>if err != nil
</code>

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

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

Тема повторяющегося кода проверки ошибок возникла, когда я присутствовал на GoCon осенью 2014 года в Токио. Увлечённый гофер, известный как @jxck_ в Twitter, выразил привычную жалобу о проверке ошибок. У него был код, который выглядел условно следующим образом:

<code>_, 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
}
// и так далее
</code>

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

<code>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
}
</code>

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

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

Я определил объект под названием errWriter, примерно такой:

<code>type errWriter struct {
  w   io.Writer
  err error
}
</code>

и добавил ему один метод, write. Он не обязан иметь стандартную сигнатуру Write, и написан с маленькой буквы, чтобы подчеркнуть различие. Метод write вызывает метод Write базового Writer и сохраняет первую ошибку для последующего использования:

<code>func (ew *errWriter) write(buf []byte) {
  if ew.err != nil {
    return
  }
  _, ew.err = ew.w.Write(buf)
}
</code>

Как только возникает ошибка, метод write становится пустой операцией, но значение ошибки сохраняется.

Учитывая тип errWriter и его метод write, приведённый выше код можно рефакторить:

<code>ew := &errWriter{w: fd}
ew.write(p0[a:b])
ew.write(p1[c:d])
ew.write(p2[e:f])
// и так далее
if ew.err != nil {
  return ew.err
}
</code>

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

Скорее всего, какой-то другой фрагмент кода в том же пакете может опираться на эту идею, или даже напрямую использовать errWriter.

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

На самом деле, этот паттерн часто встречается в стандартной библиотеке. Пакеты archive/zip и net/http используют его. Более заметный для данного обсуждения — пакет bufio и его Writer на самом деле является реализацией идеи errWriter. Хотя bufio.Writer.Write возвращает ошибку, это в основном связано с соблюдением интерфейса io.Writer. Метод Write bufio.Writer ведёт себя так же, как наш метод errWriter.write выше, с тем отличием, что Flush сообщает об ошибке, поэтому наш пример можно было бы записать следующим образом:

<code>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()
}
</code>

У этого подхода есть одно существенное недостаток, по крайней мере для некоторых приложений: нет способа узнать, сколько обработки было завершено до возникновения ошибки. Если эта информация важна, требуется более точный подход. Однако часто проверка «всё или ничего» в конце оказывается достаточной.

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

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

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

Наконец, для полной истории моего взаимодействия с @jxck_, включая небольшое видео, которое он снял, посетите его блог.

Следующая статья: Имена пакетов
Предыдущая статья: GothamGo: гоферы в большом яблоке
Индекс блога

GoRu.dev Golang на русском

На сайте представлена адаптированная под русский язык документация языка программирования Golang