⬆️ ⬇️

Such an exceptional go

Recently, drafts of the design of a new error handling in Go 2 were published. It is very pleasing that the language does not stand in one place - it develops and becomes more lethal with each year.



Only now, while Go 2 can only be seen on the horizon, and it’s very painful and sad to wait. Therefore, we take matters into our own hands. A little bit of code generation, a bit of work with ast, and a slight movement of the arm of a panic turn, turn panic ... into elegant exceptions!





And at once I want to make a very important and absolutely serious statement.

This decision is purely entertaining and pedagogical.

I mean just 4 fun. This is generally a proof-of-concept, in truth. I warned :)


So what happened



It turned out a small such library-kodogenerator . And kodogenerators, as everyone knows, carry goodness and grace. Not really, but they are quite popular in the Go world.



We set such kodogenerator on go-raw. He parses it for using the standard go/ast module, does some not tricky transformations, the result is written next to the file, adding the suffix _jex.go . The resulting files for work want a tiny runtime.



This is exactly the way we add exceptions to Go.



We use



We connect the generator to the file, in the header (before the package ) we write



 //+build jex //go:generate jex 


If you now run the command go generate -tags jex , then the jex utility will be executed. She takes the file name from os.Getenv("GOFILE") , eats it, digests it and writes {file}_jex.go . The newborn file in the header already has //+build !jex (the tag is inverted), so the go build , and along with it the rest of the commands, like go test or go install , take into account only the new , correct files. Lepote ...



Now dot-import github.com/anjensan/jex .

Yes, yes, while import through a point is required. In the future it is planned to leave exactly the same.



 import . "github.com/anjensan/jex" 


Great, now you can insert TRY , THROW , EX stub function calls into the code. The code for all this remains syntactically valid, and even compiles in its raw form (it just does not work), so the auto-completions are available and the linters do not swear much . The editors would also show documentation for these functions, if only they had it.



Throw an exception



 THROW(errors.New("error name")) 


Catch exception



 if TRY() { //   } else { fmt.Println(EX()) } 


An anonymous function is generated under the hood. And in her defer . And in it one more function. And in her recover ... Well, there is still some more ast-magic to handle return and defer .



And yes, by the way, they are supported!



In addition, there is a special macro variable ERR . If you assign an error to it, an exception is thrown out. This makes it easier to call functions that still return error in the old fashioned way.



 file, ERR := os.Open(filename) 


Additionally, there are a couple of small utility bags ex and must , but there is nothing to tell about.



Examples



Here is an example of a correct, idiomatic Go code.



 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) } } 


This code is not so pleasant and elegant. By the way, this is not just my opinion!

But jex will help us improve it.



 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() } } 


But for example the following program



 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) } 


can be rewritten as



 func main() { if TRY() { hex, ERR := ioutil.ReadAll(os.Stdin) data, ERR := parseHexdump(string(hex)) os.Stdout.Write(data) } else { log.Fatal(EX()) } } 


Here is another example in order to feel the proposed idea better. Original code



 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 } 


can be rewritten as



 func printSum_(a, b string) { x, ERR := strconv.Atoi(a) y, ERR := strconv.Atoi(b) fmt.Println("result:", x + y) } 


or so even



 func printSum_(a, b string) { fmt.Println("result:", must.Int_(strconv.Atoi(a)) + must.Int_(strconv.Atoi(b))) } 


An exception



The essence is a simple structure-wrapper over the error instance.



 type exception struct { //  ,   err error //  ^W , ,    log []interface{} //      ,    suppress []*exception } 


An important point - the usual panic is not perceived as exceptions. So, all standard errors are not exceptions, like runtime.TypeAssertionError . This corresponds to the accepted best practices in Go - if we have, say, a nil-dereference, then we cheerfully and cheerfully drop the whole process. Reliable and predictable. Although not sure, perhaps it is worth reviewing this moment and still catching such errors. Maybe optional?



Here is an example of a chain of exceptions.



 func one_() { THROW(errors.New("one")) } func two_() { THROW(errors.New("two") } func three() { if TRY() { one_() } else { two_() } } 


Here we calmly handle the exception one , as suddenly bang ... and the exception two thrown. So, the original one suppress attach to the suppress field. Nothing is lost, everything goes to the logs. And therefore, there is no particular need to push the entire chain of errors directly into the message text using the very popular fmt.Errorf("blabla: %v", err) pattern fmt.Errorf("blabla: %v", err) . Although no one, of course, prohibits its use here, if you really want to.



When they forgot to catch



Ah, another very important moment. In order to improve readability, there is an additional check: if a function can throw an exception, then its name must end with _ . Consciously crooked name that prompts the programmer "dear sir, here in your program something can go wrong, if you please be attentive and diligent!"



The check is started automatically for transformable files, plus it can still be started manually in the project using the jex-check command. Perhaps it makes sense to run it as part of the build process along with other linters.



Comment checking is //jex:nocheck . This, by the way, is still the only way to throw exceptions from an anonymous function.



Of course this is not a panacea for all problems. Checker will miss this



 func bad_() { THROW(errors.New("ups")) } func worse() { f := bad_ f() } 


On the other hand, it is not much worse than the standard check on err declared and not used , which is very easy to get around



 func worse() { a, err := foo() if err != nil { return err } b, err := bar() //  ,    ok... go vet, ? } 


In general, this question is rather philosophical, what is better to do when you forgot to handle the error - quietly ignore it, or throw out panic ... By the way, better verification results could be achieved by introducing support for exceptions to the compiler, but this is much beyond the scope of this article .



Some may say that, although this is a wonderful solution, it is no exception, since exceptions now mean a very concrete implementation. Well, it’s because the exceptions are not attached to the exceptions, or there is a separate linter for checking the function names, or that the function may end in _ but it does not throw exceptions, or there is no direct support in the syntax, or that it is in fact panic, but panic is not an exception at all, because gladiolus ... Disputes can be as hot as they are useless and pointless. Therefore, I will leave them behind the board of the article, and the described solution will continue to unshakably call "exceptions."



Regarding the structures



Often, developers, in order to simplify debugging, rivet the constructs to custom implementation error . There are even some popular libraries for this. But, fortunately, with exceptions, no additional actions are needed due to one interesting feature of Go — during a panic, the defer blocks defer executed in the stack context of the code that the panic threw. Therefore here



 func foo_() { THROW(errors.New("ups")) } func bar() { if TRY() { foo_() } else { debug.PrintStack() } } 


a full-fledged spectra will be printed, even a little verbose (cut out file names)



  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 


It doesn’t hurt to make your own helper for formatting / printing of the glassboard taking into account surrogate functions, hiding them for readability. I think a good idea, recorded in.



And you can grab the stack and attach it to the exception with ex.Log() . Then such an exception is allowed to transfer to another city center - the strays are not lost.



 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) //  ,  ,  } 


Unfortunately



Eh ... of course, something better would look better



  try { throw io.EOF, "some comment" } catch e { fmt.Printf("exception: %v", e) } 


But alas, the Go syntax is not extensible.

[thoughtfully] Although, probably, this is still for the better ...



In any case, it is necessary to pervert. One of the alternative ideas was to make



  TRY; { THROW(io.EOF, "some comment") }; CATCH; { fmt.Printf("exception: %v", EX) } 


But such code looks awkward after go fmt . And the compiler curses when it sees return in both branches. There is no such problem with if-TRY .



It would be cool to replace the macro ERR with the MUST function (better simply must ). To write



  return MUST(strconv.Atoi(a)) + MUST(strconv.Atoi(b)) 


In principle, this is still realizable; it is possible to derive the type of expressions when analyzing ast, generate a simple wrapper function for all variants of types, such as those declared in the must package, and then replace MUST with the name of the corresponding surrogate function. This is not entirely trivial, but it is completely possible ... Only now editors / ides will not be able to understand such code. After all, the signature of the MUST stub function is not expressed in the framework of the Go type system. And therefore no autocomplete.



Under the hood



A new import is added to all processed files.



  import _jex "github.com/anjensan/jex/runtime" 


The call to THROW is replaced by panic(_jex.NewException(...)) . Also, EX() is replaced with the name of a local variable in which the caught exception lies.



But if TRY() {..} else {..} processed a bit more complicated. First, special processing occurs for all return and defer . Then the processed if-a branches are placed in anonymous functions. And then these functions are passed to _jex.TryCatch(..) . Here it is



 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" } 


turns into something like this (I removed comments //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" } 


A lot, not beautiful, but it works. Well, not all and not always. For example, you cannot defer-recover inside TRY, since the function call turns into an additional lambda.



Also, when displaying the ast tree, the option "save comments" is specified. So, in theory, go/printer should print them ... What he honestly does, though very, very crooked =) I will not give examples, just crooked. In principle, such a problem is quite solvable if you carefully specify the positions for all ast-nodes (they are now empty), but this is definitely not included in the list of necessary things for the prototype.



We try



Out of curiosity, wrote a small benchmark .



We have a wooden implementation of qsort, which checks for the presence of duplicates. Found - an error. One version simply forgets through return err , the other clarifies the error by calling fmt.Errorf . And one more uses exceptions. We sort the slices of different sizes, either without any duplicates (there is no error, the slice is sorted completely), or with one repetition (the sorting is interrupted about halfway through the timing).



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


If the error is not thrown (the code is stable and reinforced concrete), then the varant with the exception forwarding is approximately comparable to return err and fmt.Errorf . Sometimes a little faster. But if they threw a mistake, the exceptions go to second place. But it all depends on the ratio of "useful work / errors" and the depth of the stack. For small slices, return err goes to the gap, for medium and large exceptions are already equal to manual forwarding.



In short, if errors occur very rarely - exceptions may even speed up the code a bit. If like everyone else, it will be something like this. But if very often ... then slow exceptions are far from the most important problem that is worth worrying about.



As a test, I tried to migrate the real gosh library for exceptions.



To my deep regret, it did not work out to rewrite 1-in-1

More precisely, it would have happened, but it must be bother.



So, for example, the rpc2XML function seems to be returning an error ... but it just never returns it. If you try to serialize an unsupported data type - no error, just empty output. Maybe it is conceived? .. No, conscience does not allow to leave like that. Added by



  default: THROW(fmt.Errorf("unsupported type %T", value)) 


But it turned out that this function is used in a special way.



 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 } 


Here we run through the list of parameters, serialize them all, but return an error only for the latter. The remaining errors are ignored. Strange behavior made easier



 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 } 


If at least one field failed to serialize - an error. Well, that's better. But it turned out that this function is also used in a special way.



 xmlstr, _ = rpcResponse2XML(response) 


again, for the source code it is not so important, because there errors are already ignored. I am starting to guess why some programmers are so fond of explicit error handling via if err != nil ... But with exceptions, it’s still easier to forward or process than to ignore



 xmlstr = rpcResponse2XML_(response) 


And I did not remove the "chain of errors." Here is the original code



 func DecodeClientResponse(r io.Reader, reply interface{}) error { rawxml, err := ioutil.ReadAll(r) if err != nil { return FaultSystemError } return xml2RPC(string(rawxml), reply) } 


that's rewritten



 func DecodeClientResponse_(r io.Reader, reply interface{}) { var rawxml []byte if TRY() { rawxml, ERR = ioutil.ReadAll(r) } else { THROW(FaultSystemError) } xml2RPC_(string(rawxml), reply) } 


Here the original error (which ioutil.ReadAll returned) will not be lost, will be attached to the exception in the suppress field. Again, it can be done the same as in the original, but this should be specially confused ...



Rewrote the tests, replacing if err != nil { log.Error(..) } with a simple forwarding exception. There is a negative point - the tests fall on the first error, not continuing to work "well, at least somehow." According to the mind, we should divide them into sub-tests ... What, in general, is worth doing anyway. But it’s very easy to get the right frame



 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) } 


In general, errors are very easy to ignore. In the original code



 func fault2XML(fault Fault) string { buffer := "<methodResponse><fault>" xml, _ := rpc2XML(fault) buffer += xml buffer += "</fault></methodResponse>" return buffer } 


here the error from rpc2XML again rpc2XML ignored. It became like this



 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 } 


According to my personal feelings, with errors it is easier to return the "half-ready" result.

For example, a half-designed response. With exceptions it is more difficult, since the function either returns a successful result or returns nothing at all. Such atomicity. On the other hand, exceptions are more difficult to ignore or lose the root cause in the exception chain. After all, you need to specifically try to do it. With errors, this happens easily and naturally.



Instead of conclusion



When writing this article, no gopher was hurt.



Thanks for the photo of the gopher-alcoholic http://migranov.ru



Could not choose between the hubs "Programming" and "Abnormal Programming".

A very difficult choice, added to both.



')

Source: https://habr.com/ru/post/425145/



All Articles