- PVSM.RU - https://www.pvsm.ru -
Буквально пару дней назад в Денвере закончилась очередная, уже 5-я по счёту, крупнейшая конференция по Go – GopherCon [1]. На ней команда Go сделала важное заявление [2] – черновики предварительного дизайна новой обработки ошибок и дженериков в Go 2 опубликованы [3], и все приглашаются к обсуждению.
Я постараюсь подробно пересказать суть этих черновиков [3] в трёх статьях.
Как многим, наверняка, известно, в прошлом году (также на GopherCon) команда Go объявила [4], что собирает отчёты (experience reports [5]) и предложения для решения главных проблем Go – тех моментов, которые по опросам собирали больше всего критики. В течении года все предложения и репорты изучались и рассматривались, и помогли в создании черновиков дизайна, о которых и будет идти речь.
Итак, начнём с черновиков нового [6] механизма обработки ошибок [7].
Для начала, небольшое отступление:
В Go изначально было принято решение использовать "явную" проверку ошибок, в противоположность популярной в других языках "неявной" проверке – исключениям. Проблема с неявной проверкой ошибок в том, как подробно описано в статье "Чище, элегеннтней и не корректней" [9], что очень сложно визуально понять, правильно ли ведёт себя программа в случае тех или иных ошибок.
Возьмём пример гипотетического 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.
Предложенный дизайн вводит две новых синтаксические формы.
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 {
<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)
Изначально пробовали слово 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)
}
}
}
Использование check
может практически убрать надобность в переопределении переменных в краткой форме присваивания (:=
), поскольку это было продиктовано именно необходимостью переиспользовать err
. С новым механизмом handle
/check
затенение переменных может вообще стать неактуальным.
Использование похожих концепций (defer
/panic
и handle
/check
) увеличивает когнитивную нагрузку на программиста и сложность языка. Не очень очевидные различия между ними открывают двери для нового класса ошибок и неправильного использования обоих механизмов.
Поскольку handle
всегда вызывается раньше defer
(и, напомню, паника в коде обработчика обрабатывается так же, как и в обычном теле функции), то нет способа использовать handle
/check
в теле defer-а. Вот этот код не скомпилируется:
func Greet(w io.WriteCloser) error {
defer func() {
check w.Close()
}()
fmt.Fprintf(w, "hello, worldn")
return nil
}
Пока не ясно, как можно красиво решить эту ситуацию.
Одним из главных преимуществ нынешнего механизма обработки ошибок в Go является высокая локальность – код обработчика ошибки находится очень близко к коду получения ошибки, и исполняется в том же контексте. Новый же механизм вводит контекстно-зависимый "прыжок", похожий одновременно на исключения, на defer
, на break
и на goto
. И хотя данный подход сильно отличается от исключений, и больше похож на goto
, это всё ещё одна концепция, которую программисты должны будут учить и держать в голове.
Рассматривалось использование таких слов как try
, catch
, ?
и других, потенциально более знакомых из других языков. После экспериментирования со всеми, авторы Go считают, что check
и handle
лучше всего вписываются в концепцию и уменьшают вероятность неверного трактования.
Что делать с кодом, в котором имена handle
и catch
уже определены, пока тоже не ясно (не факт, что это будут ключевые слова (keywords) ещё).
Неизвестно. Учитывая прошлый опыт нововведений в Go, от стадии обсуждения до первого экспериментального использования проходит 2-3 релиза, а официальное введение – ещё через релиз. Если отталкиваться от этого, то это 2-3 года при наилучших раскладах.
Плюс, не факт, что это будет Go2 – это вопрос брендинга. Скорее всего, будет обычный релиз очередной версии Go – Go 1.20 например. Никто не знает.
Нет. В исключениях главная проблема в неявности/невидимости кода и процесса обработки ошибок. Данный дизайн лишен такого недостатка, и является, фактически, синтаксическим сахаром для обычной проверки ошибок в Go.
if err != nil {}
проверкам, и сторонников handle
/check
?Неизвестно, но расчёт на то, что if err
будет мало смысла использовать, кроме специальных случаев – новый дизайн уменьшает количество символов для набора, и сохраняет явность проверки и обработки ошибок. Но, время покажет.
Является. Расчёт на то, что выгода от этого усложнения перевесит минусы самого факта усложнения.
Нет, это лишь начальный дизайн, и он может быть не принят. Хотя есть причины полагать, что в какой-то момент. после активного тестирования, он таки будет принят, возможно, с сильными изменениями, исправляющие слабые места.
Напишите статью с объяснением вашего видения и добавьте её в вики-страничку Go2ErrorHandlingFeedback [10] — (https://go.googlesource.com/proposal/+/master/design/go2draft-error-handling.md [7])
handle
/check
defer
/panic
)Мысли? Комментарии?
Автор: divan0
Источник [13]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/go/291214
Ссылки в тексте:
[1] GopherCon: https://www.gophercon.com
[2] важное заявление: https://blog.golang.org/go2draft
[3] опубликованы: https://go.googlesource.com/proposal/+/master/design/go2draft.md
[4] объявила: https://www.youtube.com/watch?v=0Zbh_vmAKvk
[5] experience reports: https://github.com/golang/go/wiki/ExperienceReports
[6] черновиков нового: https://go.googlesource.com/proposal/+/master/design/go2draft-error-handling-overview.md
[7] механизма обработки ошибок: https://go.googlesource.com/proposal/+/master/design/go2draft-error-handling.md
[8] предложения (proposals): https://github.com/golang/proposal#readme
[9] "Чище, элегеннтней и не корректней": https://blogs.msdn.microsoft.com/oldnewthing/20040422-00/?p=39683
[10] вики-страничку Go2ErrorHandlingFeedback: https://github.com/golang/go/wiki/Go2ErrorHandlingFeedback
[11] Отчёты об использовании с описанием проблемы обработки ошибок в Go: https://github.com/golang/go/wiki/ExperienceReports#error-handling
[12] Gopher Artwork by Ashley McNamara: https://github.com/ashleymcnamara/gophers
[13] Источник: https://habr.com/post/422049/?utm_source=habrahabr&utm_medium=rss&utm_campaign=422049
Нажмите здесь для печати.