Обработка ошибок в Go 2
Буквально пару дней назад в Денвере закончилась очередная, уже 5-я по счёту, крупнейшая конференция по Go — GopherCon. На ней команда Go сделала важное заявление — черновики предварительного дизайна новой обработки ошибок и дженериков в Go 2 опубликованы, и все приглашаются к обсуждению.
Я постараюсь подробно пересказать суть этих черновиков в трёх статьях.
Как многим, наверняка, известно, в прошлом году (также на GopherCon) команда Go объявила, что собирает отчёты (experience reports) и предложения для решения главных проблем Go — тех моментов, которые по опросам собирали больше всего критики. В течении года все предложения и репорты изучались и рассматривались, и помогли в создании черновиков дизайна, о которых и будет идти речь.
Итак, начнём с черновиков нового механизма обработки ошибок.
Для начала, небольшое отступление:
- Go 2 это условное название — все нововведения будут частью обычного процесса выпуска версий Go. Так что пока неизвестно, будет ли это Go 1.34 или Go2. Сценария Python 2/3 не будет железно.
- Черновики дизайна это даже не предложения (proposals), с которых начинается любое изменение в библиотеке, тулинге или языке Go. Это начальная точка для обсуждения дизайна, предложенная командой Go после нескольких лет работы над данными вопросами. Всё, что описано в черновиках с большой долей вероятности будет изменено, и, при наилучших раскладах, воплотится в реальность только через несколько релизов (я даю ~2 года).
В 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
это (скорее всего) ключевое слово, которое чётко выражает действие «проверка» и применяется либо к переменной типа error
, либо к функции, возвращающую ошибку последним значением. Если ошибка не равна nil, то check
вызывает ближайший обработчик (handler
), и вызывает return
с результатом обработчика.
Следующий пример:
v1, ..., vN := check <выражение>
равнозначен этому коду:
v1, ..., vN, vErr := <выражение>
if vErr != nil {
= handlerChain(vn)
return
}
где vErr
должен иметь тип error
и
означает ошибку, возвращённую из обработчика.
Аналогично,
foo(check <выражение>)
равнозначно:
v1, ..., vN, vErr := <выражение>
if vErr != nil {
= 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
описывает блок, называемый «обработчик» (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 — (https://go.googlesource.com/proposal/+/master/design/go2draft-error-handling.md)
- Предложен новый механизм обработки ошибок в будущих версиях Go —
handle
/check
- Обратно-совместим с нынешним
- Проверка и обработка ошибок остаются явными
- Сокращается количество текста, особенно в кусках кода, где много повторений однотипных ошибок
- В грамматику языка добавляются два новых элемента
- Есть открытые/нерешённые вопросы (взаимодействие с
defer
/panic
)
Мысли? Комментарии?