Такой исключительный Go
Очень радует, что язык не стоит на одном месте — он развивается и c каждым годом хорошеет как на дрожжах. Недавно были опубликованы черновики дизайна новой обработки ошибок в Go 2.
Посему берем дело в свои руки. Только вот пока Go 2 лишь виднеется на горизонте, а ждать уж очень тягостно и грустно. Немножко кодогенерации, чуть работы с ast, и легким движением руки паники превращаются, превращаются паники… в элегантные исключения!
Это вообще proof-of-concept, по правде говоря. И сразу же хочу сделать очень важное и абсолютно серьезное заявление.
Данное решение носит исключительно развлекательный и педагогический характер.
То бишь just 4 fun. Я предупредил 🙂
Так что же вышло
А кодогенераторы, как всем хорошо известно, несут в себе добро и благодать. Получилась небольшенькая такая библиотека-кодогенератор. На самом деле нет, но в мире Go они довольно популярны.
Он его парсит за помощью стандартного модуля go/ast
, делает там некие нехитрые трансформации, результат пишет рядышком в файл, добавляя суффикс _jex.go
. Натравливаем такой кодогенератор на go-сырец. Полученные файлы для работы хотят малюсенький рантайм.
Вот таким вот незамысловатым образом мы и добавляем исключения в Go.
Пользуем
Подключаем генератор к файлу, в шапку (до package
) пишем
//+build jex
//go:generate jex
Она берет имя файла из os. Если теперь запустить команду
, кушает его, переваривает и пишет go generate -tags jex
, то будет выполнена утилитка jex
. У новорожденного файла в шапке уже //+build !jex
(тег инвертирован), так что go build
, а в купе с ним и остальные команды, навроде go test
или go install
, учитывают только новые, правильные файлы. Getenv("GOFILE")_jex.go
. Лепота...
В будущем планируется оставить точно также. Теперь дот-импортируем github.com/anjensan/jex
.
Да-да, пока импорт через точку обязателен.
import . "github.com/anjensan/jex"
Код при всем этом остается синтаксически валидным, и даже компилируется в необработанном виде (только не работает), поэтому доступны автодополнения и линтеры не особо ругаются. Отлично, теперь в код можно вставлять вызовы функций-заглушек TRY
, THROW
, EX
. Редакторы показали бы и документацию к этим функциям, если бы только она у них была.
Бросаем исключение
THROW(errors.New("error name"))
Ловим исключение
if TRY() { // некий код
} else { fmt.Println(EX())
}
А в ней defer
. Под капотом сгенерируется анонимная функция. А в ней recover
… Ну там еще немного ast-магии для обработки return
и defer
. А в нем еще одна функция.
И да, кстати, они поддерживаются!
Если присвоить в нее ошибку, то выкидывается исключение. Вдобавок есть особая макро-переменная ERR
. Так легче вызывать функции, которые по старинке все еще возвращают error
file, ERR := os.Open(filename)
Дополнительно имеется парочка небольших утилитных пакетика ex
и must
, но там не о чем особо рассказывать.
Примеры
Вот пример корректного, идиоматичного кода на 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) }
}
Между прочим, это не только мое мнение!
Но jex
поможет нам его улучшить Этот код не так уж приятен и элегантен.
func CopyFile_(src, dst string) { defer ex.Logf("copy %s %s", src, dst) r, ERR := os.Open(src) defer r.Close() w, ERR := os.Create(dst) if TRY() { ERR := io.Copy(w, r) ERR := w.Close() } else { w.Close() os.Remove(dst) THROW() }
}
А вот например следующая программа
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() { if TRY() { hex, ERR := ioutil.ReadAll(os.Stdin) data, ERR := parseHexdump(string(hex)) os.Stdout.Write(data) } else { log.Fatal(EX()) }
}
Оригинальный код Вот ещё пример, дабы прочувствовать предложенную идею получше.
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) { x, ERR := strconv.Atoi(a) y, ERR := strconv.Atoi(b) fmt.Println("result:", x + y)
}
или вот даже так
func printSum_(a, b string) { fmt.Println("result:", must.Int_(strconv.Atoi(a)) + must.Int_(strconv.Atoi(b)))
}
Исключение
Суть простенькая структурка-обертка над экземпляром error
type exception struct { // оригинальная ошибка, без комментариев err error // всякий мусор^Wотладочная информация, переменные, логи там какие log []interface{} // вдруг мы уже обрабатывали другую ошибку, когда бросили исключение suppress []*exception
}
Так, не являются исключениями все стандартные ошибки, вроде runtime. Важный момент — обычные паники не воспринимаются как исключения. Это соответствует принятым бест-практикам в Go — если у нас, скажем, nil-dereference, то мы весело и бодренько роняем весь процесс. TypeAssertionError
. Хотя не уверен, быть может стоит пересмотреть данный момент и таки ловить подобные ошибки. Надежно и предсказуемо. Может опционально?
А вот пример цепочки исключений
func one_() { THROW(errors.New("one"))
} func two_() { THROW(errors.New("two")
} func three() { if TRY() { one_() } else { two_() }
}
Так вот к нему в поле suppress
автомагически прикрепится исходное one
. Тут мы спокойно обрабатываем исключение one
, как внезапно бац… и выбрасывается исключение two
. А посему и нету особой надобности запихивать всю цепочку ошибок прямо в текст сообщения при помощи весьма популярного паттерна fmt. Ничего не пропадет, все пойдет в логи. Хотя никто, конечно, не запрещает его использовать и здесь, если уж очень хочется. Errorf("blabla: %v", err)
.
Когда забыли отловить
В целях повышения читаемости имеется дополнительная проверка: если функция может выкинуть исключение, то ее имя должно оканчиваться на _
. Ах, еще один шибко важный момент. Сознательно кривое имя, которое подскажет программисту "многоуважаемый сударь, вот тут в вашей программе что-то может пойти не так, извольте проявить внимательность и усердие!"
Пожалуй имеет смысл запускать ее как часть билд процесса наравне с прочими линтерами. Проверка автоматом запускается для трансформируемых файлов, плюс еще может быть запущена вручную в проекте при помощи команды jex-check
.
Это, к слову, пока единственный способ выбрасывать исключения из анонимной функции. Отключается проверка комментарием //jex:nocheck
.
Чекер пропустит вот такое Конечно это не панацея от всех проблем.
func bad_() { THROW(errors.New("ups")) }
func worse() { f := bad_ f()
}
С другой стороны, это не сильно хуже стандартной проверки на err declared and not used
, которую ну очень легко обойти
func worse() { a, err := foo() if err != nil { return err } b, err := bar() // забыли проверку, а все типо ok... go vet, доколе?
}
В общем, сей вопрос скорее философский, что же лучше делать, когда забыли обработать ошибку — втихую ее проигнорировать, или выкинуть панику… Кстати, лучших результатов проверки можно было бы достигнуть, внедряя поддержку исключений в компилятор, но это сильно выходит за рамки данной статьи.
Ну там потому, что к исключениям не прикрепляются стектрейсы, или есть отдельный линтер для проверки имен функций, или что функция может заканчиваться на _
но при этом не выбрасывать исключений, или нету прямой поддержки в синтаксисе, или что это на самом деле паники, а паники не исключения вовсе, потому что гладиолус… Споры могут быть столь же жаркими, сколь бесполезными и бесцельными. Некоторые могут сказать, что, хоть это и замечательное решение, но уже исключениями не является, поскольку сейчас исключения означают вполне конкретную реализацию. Посему оставлю их за бортом статьи, а описанное решение продолжу невозбранно обзывать "исключениями".
По поводу стектрейсов
Есть даже несколько популярных библиотек для этого. Часто разработчики в целях упрощения отладки приклепляют стектрейс к кастомным имплементациям error
. Поэтому тут Но, к счастью, с исключениями для этого не нужно никаких дополнительных действий благодаря одной интересной особенности Go — при панике блоки defer
выполняются в стековом контектсе того кода, который панику выбросил.
func foo_() { THROW(errors.New("ups"))
} func bar() { if TRY() { foo_() } else { debug.PrintStack() }
}
распечатается полноценный стектрейс, пускай и чуть многословный (имена файлов вырезал)
runtime/debug.Stack runtime/debug.PrintStack main.bar.func2 github.com/anjensan/jex/runtime.TryCatch.func1 panic main.foo_ main.bar.func1 github.com/anjensan/jex/runtime.TryCatch main.bar main.main
Думаю неплохая идея, записал в . Не помешает еще сделать свой хелпер для форматирования/печати стектрейса с учетом суррогатных функций, скрывая их для читаемости.
Log(). А можно захватить стек и прикрепить его к исключению при помощи ex. Потом такое исключение дозволено передавать в другую гороутину — стректрейсы не теряются.
func foobar_() { e := make(chan error, 1) go func() { defer close(e) if TRY() { checkZero_() } else { EX().Log(debug.Stack()) // прикрепляем стектрейс e <- EX().Wrap() // оборачиваем исключение в ошибку } }() ex.Must_(<-e) // разворачиваем и, быть может, перевыбрасываем
}
К сожалению
Эх… конечно, куда лучше выглядело бы что-то такое
try { throw io.EOF, "some comment" } catch e { fmt.Printf("exception: %v", e) }
Но увы и ах, синтаксис у Go нерасширяемый.
[задумчиво] Хотя, наверное, это все же к лучшему...
Одной из альтернативных идей было сделать В любом случае, приходится извращаться.
TRY; { THROW(io.EOF, "some comment") }; CATCH; { fmt.Printf("exception: %v", EX) }
А еще компилятор ругается, когда видит return
в обоих ветках. Но такой код выглядит стремновато после go fmt
. С if-TRY
такой проблемы нет.
Дабы писать Было бы еще круто заменить макрос ERR
на функцию MUST
(лучше просто must
).
return MUST(strconv.Atoi(a)) + MUST(strconv.Atoi(b))
Это не совсем тривиально, но совершенно возможно… Только вот редакторы/иде не смогут понимать такой код. В принципе это таки реализуемо, можно при анализе ast выводить тип выражений, для всех вариантов типов сгенерировать простую функцию-обертку, вроде тех, что объявлены в пакете must
, а потом подменять MUST
на имя соответствующей суррогатной функции. А поэтому никакого автокомплита. Ведь сигнатура функции-заглушки MUST
не выражаема в рамках системы типов Go.
Под капотом
Во все обработанные файлы добавляется новый импорт
import _jex "github.com/anjensan/jex/runtime"
NewException(...)). Вызов THROW
заменяется на panic(_jex. Также происходит замена
EX()
на имя локальной переменной, в которой лежит выловленное исключение.
Сначала происходит специальная обработка для всех return
и defer
. А вот if TRY() {..} else {..}
обрабатывается чуть посложнее. И потом эти функции передаются в _jex. Потом обработанные ветки if-а помещаются в анонимные функции. Вот такое TryCatch(..)
.
func test(a int) (int, string) { fmt.Println("before") if TRY() { if a == 0 { THROW(errors.New("a == 0")) } defer fmt.Printf("a = %d\n", a) return a + 1, "ok" } else { fmt.Println("fail") } return 0, "hmm"
}
превращается примерно в такое (я убрал комментарии //line
):
func test(a int) (_jex_r0 int, _jex_r1 string) { var _jex_ret bool fmt.Println("before") var _jex_md2502 _jex.MultiDefer defer _jex_md2502.Run() _jex.TryCatch(func() { if a == 0 { panic(_jex.NewException(errors.New("a == 0"))) } { _f, _p0, _p1 := fmt.Printf, "a = %d\n", a _jex_md2502.Defer(func() { _f(_p0, _p1) }) } _jex_ret, _jex_r0, _jex_r1 = true, a+1, "ok" return }, func(_jex_ex _jex.Exception) { defer _jex.Suppress(_jex_ex) fmt.Println("fail") }) if _jex_ret { return } return 0, "hmm"
}
Ладно, не все и не всегда. Много, не красиво, но работает. Например, не получится сделать defer-recover
внутри TRY, поскольку вызов функции оборачивается в дополнительную лямбду.
Так что, по идее, go/printer
должен их распечатать… Что он честно и делает, правда очень и очень криво =) Примеры приводить не буду, просто криво. Также при выводе ast дерева указана опция "сохранить комментарии". В принципе, такая проблемка вполне решаема, если тщательно указать позиции для всех ast-узлов (сейчас они пустые), но это точно не входит в список необходимых вещей для прототипа.
Пробуем
Из любопытства написал небольшой бенчмарк.
Нашли — ошибка. Имеем деревянную реализацию qsort'а, которая в нагрузку проверяет наличие дубликатов. Errorf. Одна версия просто пробрасывает через return err
, другая уточняет ошибку вызовом fmt. Сортируем слайсы разного размера, либо вовсе без дубликатов (ошибки нет, слайс сортируется полностью), либо с одним повтором (сортировка обрывается примерно на полпути, видно по таймингам). И еще одна использует исключения.
Результаты
~ > cat /proc/cpuinfo | grep 'model name' | head -1
model name : Intel(R) Core(TM) i7-6700K CPU @ 4.00GHz
~ > go version go version go1.11 linux/amd64
~ > go test -bench=. github.com/anjensan/jex/demo
goos: linux
goarch: amd64
pkg: github.com/anjensan/jex/demo
BenchmarkNoErrors/_____10/exception-8 10000000 236 ns/op
BenchmarkNoErrors/_____10/return_err-8 5000000 255 ns/op
BenchmarkNoErrors/_____10/fmt.errorf-8 5000000 287 ns/op
BenchmarkNoErrors/____100/exception-8 500000 3119 ns/op
BenchmarkNoErrors/____100/return_err-8 500000 3194 ns/op
BenchmarkNoErrors/____100/fmt.errorf-8 500000 3533 ns/op
BenchmarkNoErrors/___1000/exception-8 30000 42356 ns/op
BenchmarkNoErrors/___1000/return_err-8 30000 42204 ns/op
BenchmarkNoErrors/___1000/fmt.errorf-8 30000 44465 ns/op
BenchmarkNoErrors/__10000/exception-8 3000 525864 ns/op
BenchmarkNoErrors/__10000/return_err-8 3000 524781 ns/op
BenchmarkNoErrors/__10000/fmt.errorf-8 3000 561256 ns/op
BenchmarkNoErrors/_100000/exception-8 200 6309181 ns/op
BenchmarkNoErrors/_100000/return_err-8 200 6335135 ns/op
BenchmarkNoErrors/_100000/fmt.errorf-8 200 6687197 ns/op
BenchmarkNoErrors/1000000/exception-8 20 76274341 ns/op
BenchmarkNoErrors/1000000/return_err-8 20 77806506 ns/op
BenchmarkNoErrors/1000000/fmt.errorf-8 20 78019041 ns/op
BenchmarkOneError/_____10/exception-8 2000000 712 ns/op
BenchmarkOneError/_____10/return_err-8 5000000 268 ns/op
BenchmarkOneError/_____10/fmt.errorf-8 2000000 799 ns/op
BenchmarkOneError/____100/exception-8 500000 2296 ns/op
BenchmarkOneError/____100/return_err-8 1000000 1809 ns/op
BenchmarkOneError/____100/fmt.errorf-8 500000 3529 ns/op
BenchmarkOneError/___1000/exception-8 100000 21168 ns/op
BenchmarkOneError/___1000/return_err-8 100000 20747 ns/op
BenchmarkOneError/___1000/fmt.errorf-8 50000 24560 ns/op
BenchmarkOneError/__10000/exception-8 10000 242077 ns/op
BenchmarkOneError/__10000/return_err-8 5000 242376 ns/op
BenchmarkOneError/__10000/fmt.errorf-8 5000 251043 ns/op
BenchmarkOneError/_100000/exception-8 500 2753692 ns/op
BenchmarkOneError/_100000/return_err-8 500 2824116 ns/op
BenchmarkOneError/_100000/fmt.errorf-8 500 2845701 ns/op
BenchmarkOneError/1000000/exception-8 50 33452819 ns/op
BenchmarkOneError/1000000/return_err-8 50 33374000 ns/op
BenchmarkOneError/1000000/fmt.errorf-8 50 33705994 ns/op
PASS
ok github.com/anjensan/jex/demo 64.008s
Errorf. Если ошибка так и не брошена (код стабилен и железобетонен), то варант с пробросом исключения примерно сопоставим с return err
и fmt. А вот ежели ошибку выбросили, то исключения уходят на второе место. Иногда чуточку быстрее. Для малых слайсов
return err
идет в отрыв, для средних и больших исключения уже равняются с ручным пробросом. Но все сильно зависит от соотношения "полезная работа / ошибки" и глубины стека.
Если как у всех, то будет примерно так-на-так. Короче, если ошибки возникают крайне редко — исключения могут код даже немного ускорить. А вот если очень часто… то медленные исключения — далеко не самая важная проблема, из-за которой стоит переживать.
В качестве теста пробно мигрировал реальную гошную библиотеку на исключения.
К моему глубокому прискорбию, не вышло переписать 1-в-1
Точнее оно бы и получилось, но это надо заморачиваться.
Если попытаться сериализовать неподдерживаемый тип данных — никакой ошибки, просто пустой вывод. Так, например, функция rpc2XML
вроде как возвращает error
… да вот только никогда его не возвращает. Нет, совесть не позволяет так оставлять. Может так и задумано?.. Добавил
default: THROW(fmt.Errorf("unsupported type %T", value))
Но оказалось, что эта фукнция используется особым образом
func rpcParams2XML(rpc interface{}) (string, error) { var err error buffer := "<params>" for i := 0; i < reflect.ValueOf(rpc).Elem().NumField(); i++ { var xml string buffer += "<param>" xml, err = rpc2XML(reflect.ValueOf(rpc).Elem().Field(i).Interface()) buffer += xml buffer += "</param>" } buffer += "</params>" return buffer, err
}
Остальные ошибки игнорируются. Тут бежим по списку параметров, сериализуем их все, но возвращаем ошибку только для последнего. Странное поведение, сделал проще
func rpcParams2XML_(rpc interface{}) string { buffer := "<params>" for i := 0; i < reflect.ValueOf(rpc).Elem().NumField(); i++ { buffer += "<param>" buffer += rpc2XML_(reflect.ValueOf(rpc).Elem().Field(i).Interface()) buffer += "</param>" } buffer += "</params>" return buffer
}
Ну, так-то получше. Если хоть один филд не вышло сериализовать — ошибка. Но оказалось, что и эта функция используется особым образом
xmlstr, _ = rpcResponse2XML(response)
Я походу начинаю догадываться, почему же некоторые программисты так любят явную обработку ошибок через if err != nil
… Но с исключениями все же проще пробросить или обработать, нежели проигнорировать опять же, для исходного кода это не так уж и принципиально, ведь там ошибки и так игнорируются.
xmlstr = rpcResponse2XML_(response)
Вот оригинальный код А еще я не стал убирать "цепочки ошибок".
func DecodeClientResponse(r io.Reader, reply interface{}) error { rawxml, err := ioutil.ReadAll(r) if err != nil { return FaultSystemError } return xml2RPC(string(rawxml), reply)
}
вот переписанный
func DecodeClientResponse_(r io.Reader, reply interface{}) { var rawxml []byte if TRY() { rawxml, ERR = ioutil.ReadAll(r) } else { THROW(FaultSystemError) } xml2RPC_(string(rawxml), reply)
}
ReadAll вернул) не потеряется, будет прикреплена к исключению в поле suppress
. Тут оригинальая ошибка (которую ioutil. Опять же, можно сделать и как в оригинале, но это надо специально заморочиться...
Error(..) } на простой проброс исключения. Переписал тесты, заменив if err != nil { log. По уму надо бы разделить их на под-тесты… Что, в общем то, стоит делать в любом случае. Есть негативный момент — тесты валятся на первой же ошибке, не продолжая работать "ну хоть как-то". Но зато очень легко вывести правильный стектрейс
func errorReporter(t testing.TB) func(error) { return func(e error) { t.Log(string(debug.Stack())) t.Fatal(e) }
} func TestRPC2XMLConverter_(t *testing.T) { defer ex.Catch(errorReporter(t)) // ... xml := rpcRequest2XML_("Some.Method", req)
}
В оригинальном коде Вообще ошибки очень уж легко игнорировать.
func fault2XML(fault Fault) string { buffer := "<methodResponse><fault>" xml, _ := rpc2XML(fault) buffer += xml buffer += "</fault></methodResponse>" return buffer
}
Стало вот так тут ошибка из rpc2XML
снова тихонько игнорируется.
func fault2XML(fault Fault) string { buffer := "<methodResponse><fault>" if TRY() { buffer += rpc2XML_(fault) } else { fmt.Printf("ERR: %v", EX()) buffer += "<nil/>" } buffer += "</fault></methodResponse>" return buffer
}
С исключениями посложнее, поскольку функция либо возвращает успешный результат, либо вообще ничего не возвращает. По моим личным ощущениям, с ошибками легче вернуть "полуготовый" результат.
Например, наполовину сконструированный респонс. С другой стороны, исключения труднее проигнорировать или потерять первопричину при цепочке исключений. Эдакая атомарность. С ошибками же такое происходит легко и непринужденно. Ведь нужно еще специально постараться это сделать.
Вместо заключения
При написании данной статьи ни один гофер не пострадал.
За фотографию гофера-алкоголика спасибо http://migranov.ru
Не смог выбрать между хабами "Программирование" и "Ненормальное программирование".
Весьма сложный выбор, добавил в оба.