Хабрахабр

Такой исключительный 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

Не смог выбрать между хабами "Программирование" и "Ненормальное программирование".
Весьма сложный выбор, добавил в оба.

Теги
Показать больше

Похожие статьи

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

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

Кнопка «Наверх»
Закрыть