Обработка ошибок в Go 2

title

Буквально пару дней назад в Денвере закончилась очередная, уже 5-я по счёту, крупнейшая конференция по Go – GopherCon. На ней команда Go сделала важное заявление – черновики предварительного дизайна новой обработки ошибок и дженериков в Go 2 опубликованы, и все приглашаются к обсуждению.

Я постараюсь подробно пересказать суть этих черновиков в трёх статьях.

Как многим, наверняка, известно, в прошлом году (также на GopherCon) команда Go объявила, что собирает отчёты (experience reports) и предложения для решения главных проблем Go – тех моментов, которые по опросам собирали больше всего критики. В течении года все предложения и репорты изучались и рассматривались, и помогли в создании черновиков дизайна, о которых и будет идти речь.

Итак, начнём с черновиков нового механизма обработки ошибок.

Для начала, небольшое отступление:

  1. Go 2 это условное название – все нововведения будут частью обычного процесса выпуска версий Go. Так что пока неизвестно, будет ли это Go 1.34 или Go2. Сценария Python 2/3 не будет железно.
  2. Черновики дизайна это даже не предложения (proposals), с которых начинается любое изменение в библиотеке, тулинге или языке Go. Это начальная точка для обсуждения дизайна, предложенная командой Go после нескольких лет работы над данными вопросами. Всё, что описано в черновиках с большой долей вероятности будет изменено, и, при наилучших раскладах, воплотится в реальность только через несколько релизов (я даю ~2 года).

В чём проблема с обработкой ошибок в Go?

В Go изначально было принято решение использовать «явную» проверку ошибок, в противоположность популярной в других языках «неявной» проверке – исключениям. Проблема с неявной проверкой ошибок в том, как подробно описано в статье «Чище, элегантней и не корректней», что очень сложно визуально понять, правильно ли ведёт себя программа в случае тех или иных ошибок.

Возьмём пример гипотетического Go с исключениями:

func CopyFile(src, dst string) throws error {     r := os.Open(src)     defer r.Close()      w := os.Create(dst)     io.Copy(w, r)     w.Close() }

Это приятный, чистый и элегантный код. Он также некорректый: если io.Copy или w.Close завершатся неудачей, данный код не удалит созданный и недозаписанный файл.

С другой стороны, код на реальном Go выглядит так:

func CopyFile(src, dst string) error {     r, err := os.Open(src)     if err != nil {         return err     }     defer r.Close()      w, err := os.Create(dst)     if err != nil {         return err     }     defer w.Close()      if _, err := io.Copy(w, r); err != nil {         return err     }     if err := w.Close(); err != nil {         return err     } }

Этот код не так уж приятен и элегантен, и, при этом, так же некорректен – он по прежнему не удаляет файл в случае описанных выше ошибок. Справедливым будет замечание, что явная обработка подталкивает программиста, читающего код задаваться вопросом – «а что же правильно сделать при этой ошибке», но из-за того, что проверка кода занимает много места, программисты нередко учатся её пропускать, чтобы лучше рассмотреть структуру кода.

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

Проще говоря, в Go слишком много проверки ошибок и недостаточно обработки ошибок. Более полноценная версия кода выше будет выглядеть вот так:

func CopyFile(src, dst string) error {     r, err := os.Open(src)     if err != nil {         return fmt.Errorf("copy %s %s: %v", src, dst, err)     }     defer r.Close()      w, err := os.Create(dst)     if err != nil {         return fmt.Errorf("copy %s %s: %v", src, dst, err)     }      if _, err := io.Copy(w, r); err != nil {         w.Close()         os.Remove(dst)         return fmt.Errorf("copy %s %s: %v", src, dst, err)     }      if err := w.Close(); err != nil {         os.Remove(dst)         return fmt.Errorf("copy %s %s: %v", src, dst, err)     } }

Исправление проблем сделало код корректным, но никак не чище или элегантней.

Цели

Команда Go ставит перед собой следующие цели для улучшения обработки ошибок в Go 2:

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

Черновик дизайна предлагает изменить или дополнить семантику обработки ошибок в Go.

Дизайн

Предложенный дизайн вводит две новых синтаксические формы.

  • check(x,y,z) или check err обозначающую явную проверку ошибки
  • handle – определяющую код, обрабатывающий ошибки

Если check возвращает ошибку, то контроль передаётся в ближайший блок handle (который передаёт контроль в следущий по лексическому контексту handler, если такой есть, и. затем, вызывает return)

Код выше будет выглядеть так:

func CopyFile(src, dst string) error {     handle err {         return fmt.Errorf("copy %s %s: %v", src, dst, err)     }      r := check os.Open(src)     defer r.Close()      w := check os.Create(dst)     handle err {         w.Close()         os.Remove(dst) // (только если check упадёт)     }      check io.Copy(w, r)     check w.Close()     return nil }

Этот синтаксис разрешён также в функциях, которые не возвращают ошибки (например main). Следующая программа:

func main() {     hex, err := ioutil.ReadAll(os.Stdin)     if err != nil {         log.Fatal(err)     }      data, err := parseHexdump(string(hex))     if err != nil {         log.Fatal(err)     }      os.Stdout.Write(data) }

может быть переписана как:

func main() {     handle err {         log.Fatal(err)     }      hex := check ioutil.ReadAll(os.Stdin)     data := check parseHexdump(string(hex))     os.Stdout.Write(data) }

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

func printSum(a, b string) error {     x, err := strconv.Atoi(a)     if err != nil {         return err     }     y, err := strconv.Atoi(b)     if err != nil {         return err     }     fmt.Println("result:", x + y)     return nil }

может быть переписан как:

func printSum(a, b string) error {     handle err { return err }     x := check strconv.Atoi(a)     y := check strconv.Atoi(b)     fmt.Println("result:", x + y)     return nil }

или даже вот так:

func printSum(a, b string) error {     handle err { return err }     fmt.Println("result:", check strconv.Atoi(x) + check strconv.Atoi(y))     return nil }

Давайте рассмотрим подробнее детали предложенных конструкций check и handle.

Check

check это (скорее всего) ключевое слово, которое чётко выражает действие «проверка» и применяется либо к переменной типа error, либо к функции, возвращающую ошибку последним значением. Если ошибка не равна nil, то check вызывает ближайший обработчик(handler), и вызывает return с результатом обработчика.

Следующий пример:

v1, ..., vN := check <выражение>

равнозначен этому коду:

v1, ..., vN, vErr := <выражение> if vErr != nil {     <error result> = handlerChain(vn)     return }

где vErr должен иметь тип error и <error result> означает ошибку, возвращённую из обработчика.

Аналогично,

foo(check <выражение>)

равнозначно:

v1, ..., vN, vErr := <выражение> if vErr != nil {     <error result> = handlerChain(vn)     return } foo(v1, ..., vN)

Check против try

Изначально пробовали слово try вместо check – оно более популярное/знакомое, и, например, Rust и Swift используют try (хотя Rust уходит в пользу постфикса ? уже).

try неплохо читался с функциями:

data := try parseHexdump(string(hex))

но совершенно не читался со значениями ошибок:

data, err := parseHexdump(string(hex)) if err == ErrBadHex {     ... special handling ... } try err

Кроме того, try всё таки несёт багаж cхожести с механизмом исключений и может вводить в заблуждение. Поскольку предложенный дизайн check/handle существенно отличается от исключений, выбор явного и красноречивого слова check кажется оптимальным.

Handle

handle описывает блок, называемый «обработчик» (handler), который будет обрабатывать ошибку, переданную в check. Возврат (return) из этого блока означает незамедлительный выход из функции с текущими значениями возвращаемых переменных. Возврат без переменных (то есть, просто return) возможен только в функциях с именованными переменными возврата (например func foo() (bar int, err error)).

Поскольку обработчиков может быть несколько, формально вводится понятие «цепочки обработчиков» – каждый из них это, по сути, функция, которая принимает на вход переменную типа error и возвращает те же самые переменные, что и функция, для которой обработчик определяется. Но семантика обработчика может быть описана вот так:

func handler(err error) error {...}

(это не то, как она на самом деле скорее всего будет реализована, но для простоты понимания можно пока её считать такой – каждый следующий обработчик получает на вход результат предыдущего).

Порядок обработчиков

Важный момент для понимания – в каком порядке будут вызываться обработчики, если их несколько. Каждая проверка (check) может иметь разные обработчики, в зависимости от скопа, в котором они вызываются. Первым будет вызван обработчик, который ближе всего объявлен в текущем скопе, вторым – следующий в обратном порядке объявления. Вот пример для лучшего понимания:

func process(user string, files chan string) (n int, err error) {     handle err { return 0, fmt.Errorf("process: %v", err)  }      // handler A     for i := 0; i < 3; i++ {         handle err { err = fmt.Errorf("attempt %d: %v", i, err) } // handler B         handle err { err = moreWrapping(err) }                    // handler C          check do(something())  // check 1: handler chain C, B, A     }     check do(somethingElse())  // check 2: handler chain A }

Проверка check 1 вызовет обработчики C, B и A – именно в таком порядке. Проверка check 2 вызовет только A, так как C и B были определены только для скопа for-цикла.

Конечно, в данном дизайне сохраняется изначальный подход к ошибкам как к обычным значениям. Вы всё также вольны использовать обычный if для проверки ошибки, а в обработчике ошибок (handle) можно (и нужно) делать то, что наилучшим образом подходит ситуации – например, дополнять ошибку деталями перед тем, как обработать в другом обработчике:

type Error struct {     Func string     User string     Path string     Err  error }  func (e *Error) Error() string  func ProcessFiles(user string, files chan string) error {     e := Error{ Func: "ProcessFile", User: user}     handle err { e.Err = err; return &e } // handler A     u := check OpenUserInfo(user)         // check 1     defer u.Close()     for file := range files {         handle err { e.Path = file }       // handler B         check process(check os.Open(file)) // check 2     }     ... }

Стоит отметить, что handle несколько напоминает defer, и можно решить, что порядок вызова будет аналогичным, но это не так. Эта разница – одна из слабых место данного дизайна, кстати. Кроме того, handler B будет исполнен только раз – аналогичный вызов defer в том же месте, привёл бы ко множественным вызовам. Go команда пыталась найти способ унифицировать defer/panic и handle/check механизмы, но не нашла разумного варианта, который бы не делал язык обратно-несовместимым.

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

Паника (panic) в обработчиках исполняется так же, как и в теле функции.

Обработчик по-умолчанию

Ещё одна ошибка компиляции – если код обработчика пуст (handle err {}). Вместо этого вводится понятие «обработчика по-умолчанию» (default handler). Если не определять никакой handle блок, то, по-умолчанию, будет возвращаться та же самая ошибка, которую получил check и остальные переменные без изменений (в именованных возвратных значениях; в неименованных будут возвращаться нулевые значения — zero values).

Пример кода с обработчиком по-умолчанию:

func printSum(a, b string) error {     x := check strconv.Atoi(a)     y := check strconv.Atoi(b)     fmt.Println("result:", x + y)     return nil }

Сохранение стека вызова

Для корректных стектрейсов Go трактует обработчики как код, вызывающийся из функции в своем собственном стеке. Нужен будет какой-то механизм, позволяющий игнорировать код обработчика в стектрейсе, например для табличных тестов. Скорее всего, вот использование t.Helper() будет достаточно, но это ещё открытый вопрос:

func TestFoo(t *testing.T) {     handle err {         t.Helper()         t.Fatal(err)     }     for _, tc := range testCases {         x := check Foo(tc.a)         y := check Foo(tc.b)         if x != y {             t.Errorf("Foo(%v) != Foo(%v)", tc.a, tc.b)         }     } }

Затенение (shadowing) переменных

Использование check может практически убрать надобность в переопределении переменных в краткой форме присваивания (:=), поскольку это было продиктовано именно необходимостью переиспользовать err. С новым механизмом handle/check затенение переменных может вообще стать неактуальным.

Открытые вопросы

defer/panic

Использование похожих концепций (defer/panic и handle/check) увеличивает когнитивную нагрузку на программиста и сложность языка. Не очень очевидные различия между ними открывают двери для нового класса ошибок и неправильного использования обоих механизмов.

Поскольку handle всегда вызывается раньше defer (и, напомню, паника в коде обработчика обрабатывается так же, как и в обычном теле функции), то нет способа использовать handle/check в теле defer-а. Вот этот код не скомпилируется:

func Greet(w io.WriteCloser) error {     defer func() {         check w.Close()     }()     fmt.Fprintf(w, "hello, world\n")     return nil }

Пока не ясно, как можно красиво решить эту ситуацию.

Уменьшение локальности кода

Одним из главных преимуществ нынешнего механизма обработки ошибок в Go является высокая локальность – код обработчика ошибки находится очень близко к коду получения ошибки, и исполняется в том же контексте. Новый же механизм вводит контекстно-зависимый «прыжок», похожий одновременно на исключения, на defer, на break и на goto. И хотя данный подход сильно отличается от исключений, и больше похож на goto, это всё ещё одна концепция, которую программисты должны будут учить и держать в голове.

Имена ключевых слов

Рассматривалось использование таких слов как try, catch, ? и других, потенциально более знакомых из других языков. После экспериментирования со всеми, авторы Go считают, что check и handle лучше всего вписываются в концепцию и уменьшают вероятность неверного трактования.

Что делать с кодом, в котором имена handle и catch уже определены, пока тоже не ясно (не факт, что это будут ключевые слова (keywords) ещё).

Часто задаваемые вопросы

Когда выйдет Go2?

Неизвестно. Учитывая прошлый опыт нововведений в Go, от стадии обсуждения до первого экспериментального использования проходит 2-3 релиза, а официальное введение – ещё через релиз. Если отталкиваться от этого, то это 2-3 года при наилучших раскладах.

Плюс, не факт, что это будет Go2 – это вопрос брендинга. Скорее всего, будет обычный релиз очередной версии Go – Go 1.20 например. Никто не знает.

Разве это не то же самое, что исключения?

Нет. В исключениях главная проблема в неявности/невидимости кода и процесса обработки ошибок. Данный дизайн лишен такого недостатка, и является, фактически, синтаксическим сахаром для обычной проверки ошибок в Go.

Не разделит ли это Go программистов на 2 лагеря – тех, кто останется верным if err != nil {} проверкам, и сторонников handle/check?

Неизвестно, но расчёт на то, что if err будет мало смысла использовать, кроме специальных случаев – новый дизайн уменьшает количество символов для набора, и сохраняет явность проверки и обработки ошибок. Но, время покажет.

Не является ли шагом к усложнению языка? Теперь есть два способа делать обработку и проверку ошибок, а Go ведь так этого избегает.

Является. Расчёт на то, что выгода от этого усложнения перевесит минусы самого факта усложнения.

Это окончательный дизайн и точно ли он будет принят?

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

Я знаю, как сделать дизайн лучше! Что мне делать?

Напишите статью с объяснением вашего видения и добавьте её в вики-страничку Go2ErrorHandlingFeedback

Резюме

  • Предложен новый механизм обработки ошибок в будущих версиях Go —  handle/check
  • Обратно-совместим с нынешним
  • Проверка и обработка ошибок остаются явными
  • Сокращается количество текста, особенно в кусках кода, где много повторений однотипных ошибок
  • В грамматику языка добавляются два новых элемента
  • Есть открытые/нерешённые вопросы (взаимодействие с defer/panic)

Ссылки

Мысли? Комментарии?

FavoriteLoadingДобавить в избранное
Posted in Без рубрики

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *