Такой исключительный Go

от автора

Недавно были опубликованы черновики дизайна новой обработки ошибок в 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. И еще одна использует исключения. Сортируем слайсы разного размера, либо вовсе без дубликатов (ошибки нет, слайс сортируется полностью), либо с одним повтором (сортировка обрывается примерно на полпути, видно по таймингам).

Результаты

~ > 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

Если ошибка так и не брошена (код стабилен и железобетонен), то варант с пробросом исключения примерно сопоставим с return err и fmt.Errorf. Иногда чуточку быстрее. А вот ежели ошибку выбросили, то исключения уходят на второе место. Но все сильно зависит от соотношения «полезная работа / ошибки» и глубины стека. Для малых слайсов 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) }

Тут оригинальая ошибка (которую ioutil.ReadAll вернул) не потеряется, будет прикреплена к исключению в поле suppress. Опять же, можно сделать и как в оригинале, но это надо специально заморочиться…

Переписал тесты, заменив if err != nil { log.Error(..) } на простой проброс исключения. Есть негативный момент — тесты валятся на первой же ошибке, не продолжая работать «ну хоть как-то». По уму надо бы разделить их на под-тесты… Что, в общем то, стоит делать в любом случае. Но зато очень легко вывести правильный стектрейс

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

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


ссылка на оригинал статьи https://habr.com/post/425145/


Комментарии

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

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