Недавно были опубликованы черновики дизайна новой обработки ошибок в Go 2. Очень радует, что язык не стоит на одном месте — он развивается и c каждым годом хорошеет как на дрожжах.
Только вот пока Go 2 лишь виднеется на горизонте, а ждать уж очень тягостно и грустно. Посему берем дело в свои руки. Немножко кодогенерации, чуть работы с ast, и легким движением руки паники превращаются, превращаются паники… в элегантные исключения!
И сразу же хочу сделать очень важное и абсолютно серьезное заявление.
Данное решение носит исключительно развлекательный и педагогический характер.
То бишь just 4 fun. Это вообще proof-of-concept, по правде говоря. Я предупредил ?
Так что же вышло
Получилась небольшенькая такая библиотека-кодогенератор. А кодогенераторы, как всем хорошо известно, несут в себе добро и благодать. На самом деле нет, но в мире Go они довольно популярны.
Натравливаем такой кодогенератор на go-сырец. Он его парсит за помощью стандартного модуля go/ast
, делает там некие нехитрые трансформации, результат пишет рядышком в файл, добавляя суффикс _jex.go
. Полученные файлы для работы хотят малюсенький рантайм.
Вот таким вот незамысловатым образом мы и добавляем исключения в Go.
Пользуем
Подключаем генератор к файлу, в шапку (до package
) пишем
//+build jex
//go:generate jex
Если теперь запустить команду go generate -tags jex
, то будет выполнена утилитка jex
. Она берет имя файла из os.Getenv("GOFILE")
, кушает его, переваривает и пишет {file}_jex.go
. У новорожденного файла в шапке уже //+build !jex
(тег инвертирован), так что go build
, а в купе с ним и остальные команды, навроде go test
или go install
, учитывают только новые, правильные файлы. Лепота…
Теперь дот-импортируем 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.TypeAssertionError
. Это соответствует принятым бест-практикам в Go — если у нас, скажем, nil-dereference, то мы весело и бодренько роняем весь процесс. Надежно и предсказуемо. Хотя не уверен, быть может стоит пересмотреть данный момент и таки ловить подобные ошибки. Может опционально?
А вот пример цепочки исключений
func one_() {
THROW(errors.New("one"))
}
func two_() {
THROW(errors.New("two")
}
func three() {
if TRY() {
one_()
} else {
two_()
}
}
Тут мы спокойно обрабатываем исключение one
, как внезапно бац… и выбрасывается исключение two
. Так вот к нему в поле suppress
автомагически прикрепится исходное one
. Ничего не пропадет, все пойдет в логи. А посему и нету особой надобности запихивать всю цепочку ошибок прямо в текст сообщения при помощи весьма популярного паттерна 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
Не помешает еще сделать свой хелпер для форматирования/печати стектрейса с учетом суррогатных функций, скрывая их для читаемости. Думаю неплохая идея, записал в .
А можно захватить стек и прикрепить его к исключению при помощи ex.Log()
. Потом такое исключение дозволено передавать в другую гороутину — стректрейсы не теряются.
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)
}
Но такой код выглядит стремновато после go fmt
. А еще компилятор ругается, когда видит return
в обоих ветках. С 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"
Вызов THROW
заменяется на panic(_jex.NewException(...))
. Также происходит замена EX()
на имя локальной переменной, в которой лежит выловленное исключение.
А вот if TRY() {..} else {..}
обрабатывается чуть посложнее. Сначала происходит специальная обработка для всех return
и defer
. Потом обработанные ветки if-а помещаются в анонимные функции. И потом эти функции передаются в _jex.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, поскольку вызов функции оборачивается в дополнительную лямбду.
Также при выводе ast дерева указана опция «сохранить комментарии». Так что, по идее, go/printer
должен их распечатать… Что он честно и делает, правда очень и очень криво =) Примеры приводить не буду, просто криво. В принципе, такая проблемка вполне решаема, если тщательно указать позиции для всех ast-узлов (сейчас они пустые), но это точно не входит в список необходимых вещей для прототипа.
Пробуем
Из любопытства написал небольшой бенчмарк.
Имеем деревянную реализацию qsort’а, которая в нагрузку проверяет наличие дубликатов. Нашли — ошибка. Одна версия просто пробрасывает через return err
, другая уточняет ошибку вызовом fmt.Errorf
. И еще одна использует исключения. Сортируем слайсы разного размера, либо вовсе без дубликатов (ошибки нет, слайс сортируется полностью), либо с одним повтором (сортировка обрывается примерно на полпути, видно по таймингам).
Если ошибка так и не брошена (код стабилен и железобетонен), то варант с пробросом исключения примерно сопоставим с return err
и fmt.Errorf
. Иногда чуточку быстрее. А вот ежели ошибку выбросили, то исключения уходят на второе место. Но все сильно зависит от соотношения «полезная работа / ошибки» и глубины стека. Для малых слайсов return err
идет в отрыв, для средних и больших исключения уже равняются с ручным пробросом.
Короче, если ошибки возникают крайне редко — исключения могут код даже немного ускорить. Если как у всех, то будет примерно так-на-так. А вот если очень часто… то медленные исключения — далеко не самая важная проблема, из-за которой стоит переживать.
В качестве теста пробно мигрировал реальную гошную библиотеку на исключения.
По моим личным ощущениям, с ошибками легче вернуть «полуготовый» результат.
Например, наполовину сконструированный респонс. С исключениями посложнее, поскольку функция либо возвращает успешный результат, либо вообще ничего не возвращает. Эдакая атомарность. С другой стороны, исключения труднее проигнорировать или потерять первопричину при цепочке исключений. Ведь нужно еще специально постараться это сделать. С ошибками же такое происходит легко и непринужденно.