Un aller si exceptionnel

Des ébauches de la conception du nouveau traitement des erreurs dans Go 2 ont récemment été publiées. Il est très agréable que le langage ne se trouve pas au même endroit - il se développe et croît chaque année mieux à pas de géant.


Seulement maintenant, alors que Go 2 n'est visible qu'à l'horizon, il est très douloureux et triste d'attendre. Par conséquent, nous prenons les choses en main. Un peu de génération de code, un peu de travail avec ast, et avec un léger mouvement de la main, les paniques se transforment, les paniques se transforment ... en exceptions élégantes!



Et immédiatement, je veux faire une déclaration très importante et absolument sérieuse.
Cette décision est exclusivement divertissante et de nature pédagogique.
Je veux dire juste 4 amusements. Il s'agit généralement d'une preuve de concept, en vérité. J'ai prévenu :)

Alors qu'est-ce qui s'est passé


Le résultat était un petit générateur de code de bibliothèque . Et les générateurs de code, comme chacun le sait, portent en eux la bonté et la grâce. En fait non, mais dans le monde du Go, ils sont assez populaires.


Nous avons mis un tel générateur de code sur go-raw. Il l’analyse à l’aide du module standard go/ast , fait quelques pas transformations astucieuses, le résultat est écrit à côté du fichier, en ajoutant le suffixe _jex.go . Les fichiers résultants veulent un minuscule runtime pour fonctionner.


De cette manière simple, nous ajoutons des exceptions à Go.


Nous utilisons


Nous connectons le générateur au fichier, dans l'en-tête (avant le package ) nous écrivons


 //+build jex //go:generate jex 

Si vous exécutez maintenant la commande go generate -tags jex , l'utilitaire jex sera exécuté. Elle prend le nom du fichier dans os.Getenv("GOFILE") , le mange, le digère et écrit {file}_jex.go . Le fichier nouveau-né a déjà //+build !jex dans l'en-tête (la balise est inversée), alors go build , et dans le compartiment avec lui les autres commandes, comme go test ou go install , ne prennent en compte que les nouveaux fichiers corrects. Lepota ...


Maintenant, importez github.com/anjensan/jex .
Oui, tandis que l'importation via un point est obligatoire. À l'avenir, il est prévu de laisser la même chose.


 import . "github.com/anjensan/jex" 

Très bien, vous pouvez maintenant insérer des appels aux fonctions de stub TRY , THROW , EX dans le code. Pour tout cela, le code reste syntaxiquement valide, et se compile même sous une forme non traitée (cela ne fonctionne tout simplement pas), donc l'auto-complétion est disponible et les linters ne jurent pas vraiment . Les éditeurs montreraient également la documentation de ces fonctions, si seulement elles en avaient une.


Jetez une exception


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

Saisissez l'exception


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

Une fonction anonyme est générée sous le capot. Et en cela defer . Et il a encore une fonction. Et là-dedans, recover ... Eh bien, il y a encore un peu d'ast-magie pour gérer le return et le defer .


Et oui, d'ailleurs, ils sont pris en charge!


De plus, il existe une variable macro spéciale ERR . Si vous lui affectez une erreur, une exception est levée. Il est plus facile d'appeler des fonctions qui renvoient toujours une error à l'ancienne


 file, ERR := os.Open(filename) 

De plus, il y a quelques petits sacs utilitaires ex et must , mais il n'y a pas grand-chose à dire.


Des exemples


Voici un exemple du code Go idiomatique correct


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

Ce code n'est pas si agréable et élégant. Au fait, ce n'est pas seulement mon avis!
Mais jex nous aidera à l'améliorer.


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

Mais par exemple, le programme suivant


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

peut être réécrit comme


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

Voici un autre exemple pour mieux ressentir l'idée proposée. Code d'origine


 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 } 

peut être réécrit comme


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

ou même ça


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

Exception


La ligne de fond est une structure d'encapsuleur simple sur une instance d' error .


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

Un point important est que les attaques de panique ordinaires ne sont pas perçues comme des exceptions. Ainsi, toutes les erreurs standard comme runtime.TypeAssertionError ne font pas exception. Ceci est conforme aux meilleures pratiques acceptées dans Go - si nous avons, disons, une déréférence nulle, alors nous abandonnons tout le processus gaiement et gaiement. Fiable et prévisible. Bien que je ne sois pas sûr, cela vaut peut-être la peine de revoir ce moment et de détecter de telles erreurs. Peut-être en option?


Et voici un exemple de chaîne d'exception


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

Ici, nous gérons calmement l'exception, comme soudainement bam ... et l'exception two levée. Ainsi, la source one attachera suppress dans le champ de suppress . Rien ne sera perdu, tout ira dans les journaux. Par conséquent, il n'est pas particulièrement nécessaire d'insérer toute la chaîne d'erreurs directement dans le texte du message en utilisant le modèle très populaire fmt.Errorf("blabla: %v", err) . Bien que personne, bien sûr, n'interdise pas son utilisation ici, si vous le voulez vraiment.


Quand j'ai oublié d'attraper


Ah, un autre point très important. Afin d'augmenter la lisibilité, il existe une vérification supplémentaire: si une fonction peut lever une exception, son nom doit se terminer par _ . Un nom délibérément tordu qui dira au programmeur "cher monsieur, ici dans votre programme quelque chose peut mal tourner, soyez prudent et diligent!"


Une vérification démarre automatiquement pour les fichiers transformés, et elle peut également être lancée manuellement dans un projet à l'aide de la commande jex-check . Il est peut-être judicieux de l'exécuter dans le cadre du processus de génération avec d'autres linters.


La vérification des commentaires est //jex:nocheck . Soit dit en passant, c'est le seul moyen de lever des exceptions à partir d'une fonction anonyme.


Bien sûr, ce n'est pas une panacée pour tous les problèmes. Checker va manquer ça


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

En revanche, il n'est pas bien pire que le contrôle standard de l' err declared and not used , ce qui est très facile à contourner.


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

En général, cette question est plutôt philosophique, que vaut-il mieux faire quand vous avez oublié de traiter l'erreur - ignorez-la tranquillement, ou jetez une panique ... Au fait, les meilleurs résultats du test pourraient être obtenus en implémentant la prise en charge des exceptions dans le compilateur, mais cela dépasse de loin la portée de cet article. .


Certains peuvent dire que, bien que ce soit une merveilleuse solution, ce n'est plus une exception, car maintenant les exceptions signifient une mise en œuvre très spécifique. Eh bien, là, parce que les traces de pile ne sont pas attachées aux exceptions, ou qu'il existe un linter distinct pour vérifier les noms de fonction, ou que la fonction peut se terminer par _ mais ne lève pas d'exceptions, ou qu'il n'y a pas de prise en charge directe dans la syntaxe, ou que c'est vraiment de la panique, et la panique n'est pas du tout une exception, car le glaïeul ... Les spores peuvent être aussi chaudes qu'inutiles et inutiles. Par conséquent, je les laisserai derrière le tableau de l'article et je continuerai d'appeler la solution décrite non sélectivement appelée «exceptions».


À propos de stackraces


Souvent, les développeurs, afin de simplifier le débogage, collent une trace de pile aux implémentations d' error personnalisées. Il existe même plusieurs bibliothèques populaires pour cela. Mais, heureusement, à quelques exceptions près, cela ne nécessite aucune action supplémentaire en raison d'une caractéristique intéressante de Go - pendant la panique, les blocs de defer exécutés dans le contexte de pile du code qui a provoqué la panique. Par conséquent ici


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

Une trace de pile à part entière s'imprimera, quoique un peu détaillée (j'ai coupé les noms de fichiers)


  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 

Cela ne fait pas de mal de créer votre propre assistant pour formater / imprimer une trace de pile, en tenant compte des fonctions de substitution, en les cachant pour la lisibilité. Je pense qu'une bonne idée, a écrit.


Ou vous pouvez saisir la pile et l'attacher à l'exception en utilisant ex.Log() . Ensuite, une telle exception peut être transférée vers une autre horoutine - les strextraces ne sont pas perdues.


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

Malheureusement


Eh ... bien sûr, quelque chose comme ça aurait l'air beaucoup mieux


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

Mais hélas, ah, la syntaxe de Go n'est pas extensible.
[pensivement] Bien que, probablement, c'est pour le mieux ...


Dans tous les cas, il faut pervertir. Une des idées alternatives était de faire


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

Mais un tel code semble plutôt stupide après go fmt . Et le compilateur jure quand il voit le return dans les deux branches. Il n'y a pas un tel problème avec if-TRY .


Ce serait cool de remplacer la macro ERR par la fonction MUST (mieux que juste). Pour écrire


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

En principe, cela est toujours possible, lors de l'analyse de ast, vous pouvez dériver le type d'expressions, car tous les types de types génèrent une fonction wrapper simple, comme celles déclarées dans le package must , puis remplacent MUST par le nom de la fonction de substitution correspondante. Ce n'est pas tout à fait banal, mais tout à fait possible ... Seuls les éditeurs / ide ne pourront pas comprendre un tel code. Après tout, la signature de la fonction de stub MUST n'est pas exprimable dans le système de type Go. Et donc pas de saisie semi-automatique.


Sous le capot


Une nouvelle importation est ajoutée à tous les fichiers traités.


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

L'appel panic(_jex.NewException(...)) THROW remplacé par panic(_jex.NewException(...)) . EX() également remplacé par le nom de la variable locale qui contient l'exception interceptée.


Mais if TRY() {..} else {..} traité un peu plus compliqué. Premièrement, un traitement spécial a lieu pour tout return et defer . Ensuite, les branches traitées sont placées dans des fonctions anonymes. Et puis ces fonctions sont passées à _jex.TryCatch(..) . Voici


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

se transforme en quelque chose comme ça (j'ai supprimé les commentaires de la //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" } 

Beaucoup, pas beau, mais ça marche. D'accord, pas tous et pas toujours. Par exemple, vous ne pouvez pas effectuer defer-recover différée dans TRY, car l'appel de fonction se transforme en lambda supplémentaire.


De plus, lors de l'affichage de l'arbre ast, l'option "enregistrer les commentaires" est indiquée. Donc, en théorie, go/printer devrait les imprimer ... Ce qu'il fait honnêtement, la vérité est très, très tordue =) Je ne donnerai pas d'exemples, juste tordu. En principe, un tel problème est complètement résolu si vous spécifiez soigneusement les positions pour tous les nœuds ast (maintenant ils sont vides), mais cela n'est certainement pas inclus dans la liste des choses nécessaires pour le prototype.


Essayez


Par curiosité, j'ai écrit une petite référence .


Nous avons une implémentation qsort en bois qui vérifie les doublons dans la charge. Trouvé - une erreur. Une version la renvoie simplement par return err , l'autre clarifie l'erreur en appelant fmt.Errorf . Et une autre utilise des exceptions. Nous trions les tranches de différentes tailles, soit sans doublons (aucune erreur, la tranche est triée complètement), soit avec une répétition (le tri s'arrête à mi-chemin, cela peut être vu par les timings).


Résultats
 ~ > 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 

Si l'erreur n'a pas été levée (le code est en béton stable et armé), alors la garantie avec un lancer d'exception est à peu près comparable à return err et fmt.Errorf . Parfois un peu plus vite. Mais si l'erreur a été levée, les exceptions arrivent en deuxième position. Mais tout dépend du rapport "travail utile / erreur" et de la profondeur de la pile. Pour les petites tranches, l' return err précède l'écart; pour les tranches moyennes et grandes, les exceptions sont déjà égales au transfert manuel.


En bref, si les erreurs se produisent extrêmement rarement, les exceptions peuvent même accélérer un peu le code. Si, comme tout le monde, ce sera quelque chose comme ça. Mais si très souvent ... alors les exceptions lentes sont loin d'être le problème le plus important, ce qui vaut la peine de s'inquiéter.


À titre de test, j'ai migré une véritable bibliothèque gosh pour les exceptions.


À mon grand regret, cela n'a pas fonctionné de réécrire 1 en 1

Plus précisément, cela se serait avéré, mais cela doit être dérangé.


Ainsi, par exemple, la fonction rpc2XML semble renvoyer une error ... oui, elle ne la renvoie jamais. Si vous essayez de sérialiser un type de données non pris en charge - pas d'erreur, videz simplement la sortie. Peut-être que c'est ce qui était prévu? .. Non, la conscience ne permet pas de le laisser comme ça. Ajouté par


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

Mais il s'est avéré que cette fonction est utilisée d'une manière spéciale


 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 } 

Ici, nous parcourons la liste des paramètres, les sérialisons tous, mais retournons une erreur uniquement pour ces derniers. Les erreurs restantes sont ignorées. Un comportement étrange facilité


 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 } 

Si au moins un champ n'a pas fonctionné pour sérialiser - une erreur. Eh bien, c’est mieux. Mais il s'est avéré que cette fonction est également utilisée d'une manière spéciale .


 xmlstr, _ = rpcResponse2XML(response) 

encore une fois, pour le code source, ce n'est pas si important, car les erreurs sont ignorées. Je commence à deviner pourquoi certains programmeurs aiment la gestion explicite des erreurs via if err != nil ... Mais avec des exceptions, il est toujours plus facile de transmettre ou de traiter que d'ignorer


 xmlstr = rpcResponse2XML_(response) 

Et je n'ai pas commencé à supprimer la "chaîne d'erreurs". Voici le code d'origine


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

voici le réécrit


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

Ici l'erreur d'origine (que ioutil.ReadAll renvoyée) ne sera pas perdue, elle sera attachée à l'exception dans le champ de suppress . Encore une fois, cela peut être fait comme dans l'original, mais il doit être spécialement confondu ...


J'ai réécrit les tests en remplaçant if err != nil { log.Error(..) } par un simple lancer d'exception. Il y a un point négatif - les tests tombent sur la toute première erreur, ne continuant pas à fonctionner "bien, du moins en quelque sorte". Selon l'esprit, il faudrait les diviser en sous-tests ... Ce qui, en général, vaut le coup d'être fait. Mais il est très facile d'obtenir le stackrace correct


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

En général, les erreurs sont très faciles à ignorer. Dans le code d'origine


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

ici l'erreur de rpc2XML nouveau discrètement ignorée. C'est devenu comme ça


 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 } 

Selon mes sentiments personnels, il est plus facile de renvoyer un résultat "semi-fini" avec des erreurs.
Par exemple, une réponse semi-construite. Les exceptions sont plus compliquées, car la fonction retourne un résultat réussi ou ne renvoie rien du tout. Une sorte d'atomicité. D'un autre côté, les exceptions sont plus difficiles à ignorer ou à perdre la cause première dans la chaîne des exceptions. Après tout, vous devez toujours essayer spécifiquement de le faire. Avec des erreurs, cela se produit facilement et naturellement.


Au lieu d'une conclusion


Lors de la rédaction de cet article, aucun gopher n'a été blessé.


Merci pour la photo du golfeur-alcoolique http://migranov.ru


Je ne pouvais pas choisir entre les hubs "Programmation" et "Programmation anormale".
Un choix très difficile, ajouté aux deux.

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


All Articles