Uma jogada tão excepcional

Recentemente, foram publicados rascunhos do design do novo tratamento de erros no Go 2. É muito agradável que o idioma não esteja no mesmo lugar - ele se desenvolve e a cada ano cresce melhor aos trancos e barrancos.


Só agora, enquanto o Go 2 é visível apenas no horizonte, é muito doloroso e triste esperar. Portanto, tomamos o assunto em nossas próprias mãos. Um pouco de geração de código, um pouco de trabalho com ast e, com um ligeiro movimento da mão, o pânico se transforma, o pânico se transforma ... em elegantes exceções!



E imediatamente quero fazer uma declaração muito importante e absolutamente séria.
Esta decisão é de natureza exclusivamente divertida e pedagógica.
Quero dizer, apenas 4 diversão. Isso geralmente é uma prova de conceito, na verdade. Eu avisei :)

Então o que aconteceu


O resultado foi um pequeno gerador de código de biblioteca . E os geradores de código, como todos sabem, carregam consigo bondade e graça. Na verdade não, mas no mundo Go eles são bastante populares.


Definimos esse gerador de código em go-raw. Ele analisa-o com a ajuda do módulo go/ast padrão, faz algumas não transformações _jex.go , o resultado é gravado próximo ao arquivo, adicionando o sufixo _jex.go . Os arquivos resultantes desejam um pequeno tempo de execução para funcionar.


Dessa maneira simples, adicionamos exceções ao Go.


Nós usamos


Conectamos o gerador ao arquivo, no cabeçalho (antes do package ) escrevemos


 //+build jex //go:generate jex 

Se você agora executar o comando go generate -tags jex , o utilitário jex será executado. Ela pega o nome do arquivo de os.Getenv("GOFILE") , come, digere e grava {file}_jex.go . O arquivo recém-nascido já possui //+build !jex no cabeçalho (a tag é invertida), então go build e no compartimento com ele os outros comandos, como go test ou go install , leve em conta apenas os arquivos novos e corretos. Lepota ...


Agora, importe github.com/anjensan/jex .
Sim, enquanto a importação através de um ponto é obrigatória. No futuro, está previsto deixar o mesmo.


 import . "github.com/anjensan/jex" 

Ótimo, agora você pode inserir chamadas nas funções stub TRY , THROW , EX no código. Por tudo isso, o código permanece sintaticamente válido e até compila de forma não processada (simplesmente não funciona), para que o preenchimento automático esteja disponível e os alinhadores não jurem. Os editores também mostrariam documentação para essas funções, se ao menos tivessem uma.


Lançar uma exceção


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

Capturar a exceção


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

Uma função anônima é gerada sob o capô. E nele defer . E tem mais uma função. E nele se recover ... Bem, ainda há um pouco de magia ast para lidar com o return e o defer .


E sim, a propósito, eles são suportados!


Além disso, há uma variável macro especial ERR . Se você atribuir um erro a ele, uma exceção será lançada. É mais fácil chamar funções que ainda retornam um error da maneira antiga


 file, ERR := os.Open(filename) 

Além disso, existem algumas pequenas malas de utilidade ex e must , mas não há muito o que falar.


Exemplos


Aqui está um exemplo do código Go idiomático correto


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

Este código não é tão agradável e elegante. A propósito, essa não é apenas minha opinião!
Mas o jex nos ajudará a melhorá-lo.


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

Mas, por exemplo, o seguinte programa


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

pode ser reescrito como


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

Aqui está outro exemplo para sentir melhor a ideia proposta. Código original


 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 } 

pode ser reescrito como


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

ou mesmo isso


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

Exceção


A linha inferior é uma estrutura de wrapper simples sobre uma instância de error .


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

Um ponto importante é que os ataques de pânico comuns não são percebidos como exceções. Portanto, todos os erros padrão, como runtime.TypeAssertionError não são exceção. Isso está de acordo com as melhores práticas aceitas no Go - se tivermos, digamos, nenhuma dereferência, abandonamos todo o processo com alegria e alegria. Confiável e previsível. Embora eu não tenha certeza, talvez valha a pena revisar esse momento e pegar esses erros. Talvez opcional?


E aqui está um exemplo de uma cadeia de exceção


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

Aqui, com calma, lidamos com a exceção one , como de repente bam ... e a exceção two lançada. Portanto, a fonte anexada suppress a ela no campo suppress . Nada será perdido, tudo irá para os logs. Portanto, não há necessidade específica de enviar toda a cadeia de erros diretamente para o texto da mensagem usando o padrão fmt.Errorf("blabla: %v", err) muito popular fmt.Errorf("blabla: %v", err) . Embora ninguém, é claro, não proíba seu uso aqui, se você realmente quiser.


Quando se esqueceu de pegar


Ah, outro ponto muito importante. Para aumentar a legibilidade, há uma verificação adicional: se uma função pode gerar uma exceção, seu nome deve terminar com _ . Um nome deliberadamente torto que dirá ao programador "prezado senhor, aqui no seu programa algo pode dar errado, tenha cuidado e diligência!"


Uma verificação é iniciada automaticamente para arquivos transformados, além disso, também pode ser iniciada manualmente em um projeto usando o comando jex-check . Talvez faça sentido executá-lo como parte do processo de compilação junto com outros linters.


A verificação de comentários é //jex:nocheck . A propósito, esta é a única maneira de lançar exceções de uma função anônima.


Obviamente, isso não é uma panacéia para todos os problemas. Checker vai perder isso


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

Por outro lado, não é muito pior do que a verificação padrão de err declared and not used , o que é muito fácil de contornar.


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

Em geral, essa pergunta é bastante filosófica, o que é melhor fazer quando você se esquece de processar o erro - ignora-o silenciosamente ou causa pânico ... A propósito, os melhores resultados do teste podem ser alcançados implementando o suporte a exceções no compilador, mas isso está muito além do escopo deste artigo .


Alguns podem dizer que, embora essa seja uma solução maravilhosa, não é mais uma exceção, porque agora as exceções significam uma implementação muito específica. Bem, porque os rastreamentos de pilha não estão anexados às exceções, ou há um linter separado para verificar os nomes das funções, ou que a função pode terminar com _ mas não gera exceções, ou não há suporte direto na sintaxe, ou é realmente um pânico, e o pânico não é uma exceção, porque gladíolo ... Os esporos podem ser tão quentes quanto inúteis e inúteis. Portanto, eu os deixarei para trás no quadro do artigo e continuarei chamando a solução descrita de maneira não seletiva, chamada "exceções".


Sobre stackraces


Geralmente, os desenvolvedores, para simplificar a depuração, mantêm um rastreamento de pilha nas implementações de error personalizadas. Existem até várias bibliotecas populares para isso. Mas, felizmente, com exceções, isso não requer nenhuma ação adicional devido a um recurso interessante do Go - durante o pânico, os blocos de defer executados no contexto da pilha do código que causou o pânico. Portanto aqui


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

Um rastreamento de pilha completo será impresso, embora um pouco detalhado (cortei os nomes dos arquivos)


  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 

Não custa nada ajudar você a formatar / imprimir um rastreamento de pilha, levando em consideração as funções substitutas, ocultando-as para facilitar a leitura. Eu acho uma boa idéia, escreveu.


Ou você pode pegar a pilha e anexá-la à exceção usando ex.Log() . Em seguida, é permitido que essa exceção seja transferida para outra horotina - os estratos não são perdidos.


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

Infelizmente


Eh ... é claro, algo assim ficaria muito melhor


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

Mas, infelizmente, a sintaxe de Go não é extensível.
[pensativo] Embora, provavelmente, seja para melhor ...


Em qualquer caso, você tem que perverter. Uma das idéias alternativas era fazer


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

Mas esse código parece um tanto idiota depois de go fmt . E o compilador jura quando vê return nos dois ramos. Não existe esse problema com o if-TRY .


Seria legal substituir a macro ERR pela função MUST (melhor do que apenas). Para escrever


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

Em princípio, isso ainda é viável, pode-se derivar o tipo de expressões durante a análise ast, gerar uma função de invólucro simples para todos os tipos de tipos, como os declarados no pacote must , e depois substituir MUST pelo nome da função substituta correspondente. Isso não é inteiramente trivial, mas completamente possível ... Somente editores / ide não serão capazes de entender esse código. Afinal, a assinatura da função de esboço MUST não é expressável no sistema do tipo Go. E, portanto, nenhum preenchimento automático.


Sob o capô


Uma nova importação é adicionada a todos os arquivos processados.


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

A chamada THROW substituída por panic(_jex.NewException(...)) . EX() também EX() substituído pelo nome da variável local que contém a exceção capturada.


Mas if TRY() {..} else {..} processado um pouco mais complicado. Primeiro, ocorre um processamento especial para todo return e defer . Em seguida, as ramificações processadas se forem colocadas em funções anônimas. E então essas funções são passadas para _jex.TryCatch(..) . Aqui está


 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 transforma em algo assim (eu removi os //line comentários da //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" } 

Muito, não é bonito, mas funciona. Ok, nem todos e nem sempre. Por exemplo, você não pode defer-recover dentro de TRY, pois a chamada de função se transforma em um lambda adicional.


Além disso, ao exibir a árvore ast, a opção "salvar comentários" é indicada. Então, em teoria, go/printer deve imprimi-los ... O que ele honestamente faz, a verdade é muito, muito torta =) Não vou dar exemplos, apenas torta. Em princípio, esse problema é completamente solucionável se você especificar cuidadosamente as posições de todos os nós-ast (agora estão vazios), mas isso definitivamente não está incluído na lista de itens necessários para o protótipo.


Experimente


Por curiosidade, escrevi uma pequena referência .


Temos uma implementação qsort de madeira que verifica se há duplicatas na carga. Encontrado - um erro. Uma versão simplesmente lança o return err , a outra esclarece o erro chamando fmt.Errorf . E mais um usa exceções. Classificamos fatias de tamanhos diferentes, sem duplicatas (sem erro, a fatia é classificada completamente) ou com uma repetição (a classificação é interrompida na metade do caminho, pode ser vista pelos tempos).


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

Se o erro não foi lançado (o código é estável e concreto armado), a garantia com o lançamento de exceção é aproximadamente comparável ao return err e fmt.Errorf . Às vezes um pouco mais rápido. Mas se o erro foi gerado, as exceções vão para o segundo lugar. Mas tudo depende da proporção de "trabalho útil / erro" e da profundidade da pilha. Para fatias pequenas, o return err ultrapassa a lacuna; para fatias médias e grandes, as exceções já são iguais ao encaminhamento manual.


Em resumo, se os erros ocorrerem muito raramente, as exceções podem até acelerar um pouco o código. Se como todo mundo, será algo assim. Mas se com muita freqüência ... então as exceções lentas estão longe do problema mais importante, pelo qual vale a pena se preocupar.


Como teste, migrei uma biblioteca gosh real para exceções.


Para meu profundo pesar, não funcionou para reescrever 1 em 1

Mais precisamente, teria acontecido, mas isso deve ser incomodado.


Assim, por exemplo, a função rpc2XML parece retornar um error ... sim, apenas nunca a retorna. Se você tentar serializar um tipo de dados não suportado - nenhum erro, apenas esvazie a saída. Talvez seja isso que foi pretendido? .. Não, a consciência não permite deixar assim. Adicionado por


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

Mas aconteceu que essa função é usada de uma maneira especial


 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 } 

Aqui, percorremos a lista de parâmetros, serializamos todos eles, mas retornamos um erro apenas para o último. Os erros restantes são ignorados. Comportamento estranho facilitado


 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 } 

Se pelo menos um campo não funcionou para serializar - um erro. Bem, isso é melhor. Mas descobriu-se que essa função também é usada de uma maneira especial .


 xmlstr, _ = rpcResponse2XML(response) 

novamente, para o código-fonte, isso não é tão importante, porque erros são ignorados. Estou começando a adivinhar por que alguns programadores adoram o tratamento explícito de erros, if err != nil ... Mas, com exceções, ainda é mais fácil encaminhar ou processar do que ignorar


 xmlstr = rpcResponse2XML_(response) 

E eu não comecei a remover a "cadeia de erros". Aqui está o código original


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

aqui está o reescrito


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

Aqui o erro original (que ioutil.ReadAll retornou) não será perdido, será anexado à exceção no campo suppress . Novamente, isso pode ser feito como no original, mas deve ser especialmente confuso ...


Reescrevi os testes, substituindo if err != nil { log.Error(..) } por uma simples exceção. Há um ponto negativo - os testes caem no primeiro erro, não continuando a funcionar "bem, pelo menos de alguma forma". Segundo a mente, seria necessário dividi-los em sub-testes ... O que, em geral, vale a pena fazer em qualquer caso. Mas é muito fácil obter o stackrace correto


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

Em geral, os erros são muito fáceis de ignorar. No código original


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

aqui o erro do rpc2XML novamente silenciosamente ignorado. Tornou-se assim


 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 } 

De acordo com meus sentimentos pessoais, é mais fácil retornar um resultado "semi-acabado" com erros.
Por exemplo, uma resposta semi-construída. As exceções são mais complicadas, pois a função retorna um resultado bem-sucedido ou nada retorna. Uma espécie de atomicidade. Por outro lado, as exceções são mais difíceis de ignorar ou perder a causa raiz na cadeia de exceções. Afinal, você ainda precisa tentar fazer isso especificamente. Com erros, isso acontece com facilidade e naturalidade.


Em vez de uma conclusão


Ao escrever este artigo, nenhum esquilo ficou ferido.


Obrigado pela foto do alcoólatra


Não pude escolher entre os hubs "Programação" e "Programação anormal".
Uma escolha muito difícil, adicionada a ambos.

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


All Articles