Exceções determinísticas e tratamento de erros em "C ++ do futuro"


É estranho que em Habrt ainda não tenha sido mencionada uma proposta clamorosa para o padrão C ++ chamada "Exceções determinísticas de sobrecarga zero". Corrigindo essa omissão irritante.


Se você está preocupado com a sobrecarga de exceções, ou teve que compilar o código sem suporte a exceções, ou apenas se perguntando o que acontecerá com o tratamento de erros no C ++ 2b (uma referência a uma publicação recente ), peço cat. Você está esperando por um aperto de tudo o que agora pode ser encontrado sobre o tópico, e algumas pesquisas.


A discussão abaixo será conduzida não apenas sobre exceções estáticas, mas também sobre propostas relacionadas ao padrão e sobre todas as outras formas de lidar com erros. Se você foi aqui para olhar a sintaxe, aqui está:


double safe_divide(int x, int y) throws(arithmetic_error) { if (y == 0) { throw arithmetic_error::divide_by_zero; } else { return as_double(x) / y; } } void caller() noexcept { try { cout << safe_divide(5, 2); } catch (arithmetic_error e) { cout << e; } } 

Se o tipo específico de erro não for importante / desconhecido, você poderá simplesmente usar throws e catch (std::error e) .


Bom saber


std::optional e std::expected


Vamos decidir que o erro que possa surgir na função não seja "fatal" o suficiente para gerar uma exceção a ela. Tradicionalmente, as informações de erro são retornadas usando um parâmetro out. Por exemplo, o Filesystem TS oferece vários recursos semelhantes:


 uintmax_t file_size(const path& p, error_code& ec); 

(Não lance uma exceção devido ao fato de o arquivo não ter sido encontrado?) No entanto, o processamento do código de erro é complicado e propenso a erros. É fácil esquecer o código de erro para verificar. Os estilos de código modernos proíbem o uso de parâmetros de saída; em vez disso, é recomendável retornar uma estrutura contendo o resultado inteiro.


Há algum tempo, o Boost oferece uma solução elegante para lidar com esses erros "não fatais" que podem ocorrer em determinados cenários no programa correto:


 expected<uintmax_t, error_code> file_size(const path& p); 

O tipo expected é semelhante à variant , mas fornece uma interface conveniente para trabalhar com o "resultado" e o "erro". Por padrão, o resultado expected é armazenado no expected . A implementação file_size pode ser algo como isto:


 file_info* info = read_file_info(p); if (info != null) { uintmax_t size = info->size; return size; // <== } else { error_code error = get_error(); return std::unexpected(error); // <== } 

Se a causa do erro não for interessante para nós, ou o erro puder consistir apenas na "ausência" do resultado, o optional poderá ser usado:


 optional<int> parse_int(const std::string& s); optional<U> get_or_null(map<T, U> m, const T& key); 

No C ++ 17 do Boost, o opcional chegou ao padrão (sem suporte para optional<T&> ); em C ++ 20, eles podem adicionar o esperado (essa é apenas a proposta, obrigado RamzesXI pela correção).


Contratos


Os contratos (que não devem ser confundidos com os conceitos) são uma nova maneira de impor restrições aos parâmetros de função, adicionados no C ++ 20. 3 anotações adicionadas:


  • espera verificações de parâmetros de função
  • garante a verificação do valor de retorno da função (aceita como argumento)
  • assert - um substituto civilizado para a macro assert

 double unsafe_at(vector<T> v, size_t i) [[expects: i < v.size()]]; double sqrt(double x) [[expects: x >= 0]] [[ensures ret: ret >= 0]]; value fetch_single(key e) { vector<value> result = fetch(vector<key>{e}); [[assert result.size() == 1]]; return v[0]; } 

Você pode configurar por quebra de contrato:


  • Comportamento Indefinido Chamado ou
  • Ele verificou e chamou a saída do usuário, após o que std::terminate

É impossível continuar executando o programa após a quebra do contrato, porque os compiladores usam garantias dos contratos para otimizar o código de função. Se houver a menor dúvida de que o contrato será cumprido, vale a pena adicionar uma verificação adicional.


std :: error_code


A biblioteca <system_error> , adicionada no C ++ 11, permite padronizar o tratamento de códigos de erro no seu programa. std :: error_code consiste em um código de erro do tipo int e um ponteiro para o objeto de alguma classe descendente std :: error_category . Esse objeto, de fato, desempenha o papel de uma tabela de funções virtuais e determina o comportamento de um dado std::error_code .


Para criar seu std::error_code , você deve definir sua std::error_category descendente std::error_category e implementar métodos virtuais, o mais importante dos quais é:


 virtual std::string message(int c) const = 0; 

Você também deve criar uma variável global para sua std::error_category . O tratamento de erros usando o error_code + esperado é algo como isto:


 template <typename T> using result = expected<T, std::error_code>; my::file_handle open_internal(const std::string& name, int& error); auto open_file(const std::string& name) -> result<my::file> { int raw_error = 0; my::file_handle maybe_result = open_internal(name, &raw_error); std::error_code error{raw_error, my::filesystem_error}; if (error) { return unexpected{error}; } else { return my::file{maybe_result}; } } 

É importante que em std::error_code valor 0 signifique nenhum erro. Se esse não for o caso dos seus códigos de erro, antes de converter o código de erro do sistema em std::error_code , você deve substituir o código 0 por SUCCESS e vice-versa.


Todos os códigos de erro do sistema são descritos em errc e system_category . Se, em um certo estágio, o encaminhamento manual dos códigos de erro se tornar muito sombrio, você poderá sempre quebrar o código de erro na std::system_error e jogá-lo fora.


Movimento destrutivo / Trivialmente relocável


Você precisa criar outra classe de objetos que possuam alguns recursos. Provavelmente, você desejará torná-lo não copiável, mas móvel, porque os objetos imóveis são inconvenientes para trabalhar (antes do C ++ 17, eles não podiam ser retornados de uma função).


Mas aqui está o problema: em qualquer caso, o objeto movido precisa ser excluído. Portanto, é necessário um estado especial de "movido de", ou seja, um objeto "vazio" que não exclui nada. Acontece que cada classe C ++ deve ter um estado vazio, ou seja, é impossível criar uma classe com uma invariante (garantia) de correção, do construtor ao destruidor. Por exemplo, não é possível criar a classe open_file correta de um arquivo aberto durante toda a sua vida útil. É estranho observar isso em um dos poucos idiomas que usam ativamente o RAII.


Outro problema é o zeramento de objetos antigos quando a movimentação adiciona uma sobrecarga: o preenchimento de std::vector<std::unique_ptr<T>> pode ser até duas vezes mais lento que std::vector<T*> devido ao monte de zeragem de ponteiros antigos ao se mover , seguido pela remoção de manequins.


Os desenvolvedores de C ++ há muito tempo lambiam o Rust, onde os destruidores não são chamados em objetos realocados. Esse recurso é chamado de movimento destrutivo. Infelizmente, a proposta Trivially relocatable não oferece para adicioná-lo ao C ++. Mas o problema de sobrecarga será resolvido.


Uma classe é considerada relocável de maneira trivial se duas operações: mover e excluir o objeto antigo forem equivalentes a memcpy do objeto antigo para o novo. O objeto antigo não é excluído, os autores chamam de "solte-o no chão".


Um tipo é Trivialmente relocável do ponto de vista do compilador se uma das seguintes condições (recursivas) for verdadeira:


  1. É trivialmente móvel + trivialmente destrutível (por exemplo, estrutura int ou POD)
  2. Esta é a classe marcada com o atributo [[trivially_relocatable]]
  3. Esta é uma classe da qual todos os membros são trivialmente relocáveis.

Você pode usar essas informações com std::uninitialized_relocate , que executa o movimento init + delete da maneira usual ou acelerado, se possível. Sugere-se marcar como [[trivially_relocatable]] maioria dos tipos de biblioteca padrão, incluindo std::string , std::vector , std::unique_ptr . Overhead std::vector<std::unique_ptr<T>> com isso em mente A proposta desaparecerá.


O que há de errado com exceções agora?


O mecanismo de exceção C ++ foi desenvolvido em 1992. Várias opções de implementação foram propostas. Desses, foi selecionado um mecanismo de tabela de exceção que garante a ausência de uma sobrecarga para o caminho principal da execução do programa. Porque, desde o momento de sua criação, assumiu-se que exceções deveriam ser lançadas muito raramente .


Desvantagens de exceções dinâmicas (ou seja, regulares):


  1. No caso da exceção lançada, a sobrecarga é em média de 10.000 a 100.000 ciclos de CPU e, na pior das hipóteses, pode atingir a ordem de milissegundos
  2. Tamanho do arquivo binário aumentado em 15-38%
  3. Incompatibilidade com a interface de programação C
  4. Exceção implícita ao suporte em todas as funções, exceto noexcept . Uma exceção pode ser lançada quase em qualquer lugar do programa, mesmo quando o autor da função não espera.

Devido a essas deficiências, o escopo das exceções é significativamente limitado. Quando as exceções não podem ser aplicadas:


  1. Onde o determinismo é importante, ou seja, onde é inaceitável que o código "às vezes" funcione 10, 100, 1000 vezes mais lento que o normal
  2. Quando eles não são suportados na ABI, por exemplo, em microcontroladores
  3. Quando grande parte do código é escrita em C
  4. Em empresas com uma grande carga de código legado ( Google Style Guide , Qt ). Se houver pelo menos uma função não protegida por exceção no código, de acordo com a lei da maldade, uma exceção será lançada mais cedo ou mais tarde e criará um bug
  5. Nas empresas que contratam programadores que não têm idéia sobre segurança de exceção

Segundo pesquisas, nos locais de trabalho de 52% (!) Desenvolvedores, as exceções são proibidas pelas regras corporativas.


Mas as exceções são parte integrante do C ++! Ao incluir o -fno-exceptions , os desenvolvedores perdem a capacidade de usar uma parte significativa da biblioteca padrão. Isso incita ainda mais as empresas a plantar suas próprias "bibliotecas padrão" e, sim, inventar sua própria classe de cadeias.


Mas este não é o fim. Exceções são a única maneira padrão de cancelar a criação de um objeto no construtor e gerar um erro. Quando desativados, uma abominação, como a inicialização em duas fases, é exibida. Os operadores também não podem usar códigos de erro; portanto, eles são substituídos por funções como assign .


Proposta: exceções do futuro


Novo mecanismo de transferência de exceção


Herb Sutter em P709 descreveu um novo mecanismo de transferência de exceções. Em princípio, a função retorna std::expected , no entanto, em vez de um discriminador separado do tipo bool , que junto com o alinhamento ocupará até 8 bytes na pilha, essa informação é transmitida de maneira mais rápida, por exemplo, para Carry Flag.


As funções que não tocam em CF (a maioria) terão a oportunidade de usar exceções estáticas gratuitamente - tanto no caso de um retorno normal quanto no caso de lançar uma exceção! As funções forçadas a salvá-lo e restaurá-lo receberão uma sobrecarga mínima e ainda serão mais rápidas que o std::expected e qualquer código de erro comum.


As exceções estáticas são assim:


 int safe_divide(int i, int j) throws(arithmetic_errc) { if (j == 0) throw arithmetic_errc::divide_by_zero; if (i == INT_MIN && j == -1) throw arithmetic_errc::integer_divide_overflows; return i / j; } double foo(double i, double j, double k) throws(arithmetic_errc) { return i + safe_divide(j, k); } double bar(int i, double j, double k) { try { cout << foo(i, j, k); } catch (erithmetic_errc e) { cout << e; } } 

Na versão alternativa, propõe-se obrigar a palavra-chave try na mesma expressão que a chamada da função throws : try i + safe_divide(j, k) . Isso reduzirá o número de casos de uso de funções throws no código que não é seguro para exceções quase zero. De qualquer forma, diferentemente das exceções dinâmicas, o IDE poderá, de alguma forma, destacar expressões que geram exceções.


O fato de a exceção lançada não ser armazenada separadamente, mas ser colocada diretamente no lugar do valor retornado, impõe restrições ao tipo de exceção. Primeiro, ele deve ser trivialmente realocável. Em segundo lugar, seu tamanho não deve ser muito grande (mas pode ser algo como std::unique_ptr ), caso contrário, todas as funções reservarão mais espaço na pilha.


status_code


A biblioteca <system_error2> , desenvolvida por Niall Douglas, conterá status_code<T> - "novo, melhor" error_code . As principais diferenças de error_code :


  1. status_code - um tipo de modelo que pode ser usado para armazenar quase todos os códigos de erro concebíveis (junto com um ponteiro para status_code_category ), sem usar exceções estáticas
  2. T deve ser trivialmente relocável e copiável (este último, IMHO, não deve ser obrigatório). Ao copiar e excluir, as funções virtuais são chamadas de status_code_category
  3. status_code pode armazenar não apenas dados de erro, mas também informações adicionais sobre uma operação concluída com êxito
  4. A função "virtual" code.message() não retorna std::string , mas string_ref é um tipo de string bastante pesado, que é um std::string_view "possivelmente proprietário" std::string_view . Lá você pode string_view ou string , ou std::shared_ptr<string> , ou alguma outra maneira maluca de possuir uma string. Niall afirma que #include <string> tornaria o cabeçalho <system_error2> inaceitavelmente "pesado"

Em seguida, errored_status_code<T> é inserido - um wrapper sobre status_code<T> com o seguinte construtor:


 errored_status_code(status_code<T>&& code) [[expects: code.failure() == true]] : code_(std::move(code)) {} 

erro


O tipo de exceção padrão ( throws sem tipo), bem como o tipo básico de exceções para as quais todas as outras são convertidas (como std::exception ), é error . É definido algo como isto:


 using error = errored_status_code<intptr_t>; 

Ou seja, error é um status_code "erro", no qual o valor ( value ) é colocado em 1 ponteiro. Como o mecanismo status_code_category garante exclusão, movimento e cópia corretos, teoricamente, qualquer estrutura de dados pode ser salva por error . Na prática, esta será uma das seguintes opções:


  1. Inteiros (int)
  2. std::exception_handle , ou seja, um ponteiro para uma exceção dinâmica lançada
  3. status_code_ptr , ou seja, unique_ptr para um status_code<T> arbitrário status_code<T> .

O problema é que o caso 3 não está planejado para dar a oportunidade de retornar o error ao status_code<T> . A única coisa que você pode fazer é obter a message() status_code<T> compactado status_code<T> . Para poder recuperar o valor novamente em error , ative-o como uma exceção dinâmica (!). Em seguida, pegue e envolva-o em error . Em geral, Niall acredita que apenas códigos de erro e mensagens de string devem ser armazenados com error , o que é suficiente para qualquer programa.


Para distinguir entre diferentes tipos de erros, propõe-se usar o operador de comparação "virtual":


 try { open_file(name); } catch (std::error e) { if (e == filesystem_error::already_exists) { return; } else { throw my_exception("Unknown filesystem error, unable to continue"); } } 

O uso de vários blocos catch ou dynamic_cast para selecionar o tipo de exceção falhará!


Interação com exceções dinâmicas


Uma função pode ter uma das seguintes especificações:


  • noexcept : não lança exceções
  • throws(E) : lança apenas exceções estáticas
  • (nada): lança apenas exceções dinâmicas

throws implica noexcept . Se uma exceção dinâmica é lançada a partir de uma função "estática", é envolto em error . Se uma exceção estática for lançada de uma função "dinâmica", ela será status_error em uma exceção status_error . Um exemplo:


 void foo() throws(arithmetic_errc) { throw erithmetic_errc::divide_by_zero; } void bar() throws { //  arithmetic_errc   intptr_t //     error foo(); } void baz() { // error    status_error bar(); } void qux() throws { // error    status_error baz(); } 

Exceções em C ?!


A proposta prevê a adição de exceções a um dos futuros padrões C, e essas exceções serão compatíveis com ABI com exceções estáticas em C ++. Uma estrutura semelhante ao std::expected<T, U> , o usuário precisará declarar independentemente, embora a redundância possa ser removida usando macros. A sintaxe consiste em (por simplicidade, assumiremos isso) as palavras-chave falham, falham, capturam.


 int invert(int x) fails(float) { if (x != 0) return 1 / x; else return failure(2.0f); } struct expected_int_float { union { int value; float error; }; _Bool failed; }; void caller() { expected_int_float result = catch(invert(5)); if (result.failed) { print_error(result.error); return; } print_success(result.value); } 

Ao mesmo tempo, em C ++ também será possível chamar funções de fails de C, declarando-as em blocos extern C . Assim, em C ++, haverá toda uma galáxia de palavras-chave para trabalhar com exceções:


  • throw() - removido em C ++ 20
  • noexcept - especificador de função, a função não lança exceções dinâmicas
  • noexcept(expression) - especificador de função, a função não lança exceções dinâmicas fornecidas
  • noexcept(expression) - Uma expressão noexcept(expression) exceções dinâmicas?
  • throws(E) - especificador de função, a função lança exceções estáticas
  • throws = throws(std::error)
  • fails(E) - uma função importada de C lança exceções estáticas

Assim, em C ++ eles trouxeram (ou melhor, entregaram) um carrinho de novas ferramentas para tratamento de erros. Em seguida, surge uma pergunta lógica:


Quando usar o que?


Direção geral


Os erros são divididos em vários níveis:


  • Erros do programador. Processado usando contratos. Eles levam à coleta de logs e ao encerramento do programa, de acordo com o conceito de fail-fast . Exemplos: ponteiro nulo (quando isso é inválido); divisão por zero; erros de alocação de memória não previstos pelo programador.
  • Erros fatais fornecidos pelo programador. Jogado fora um milhão de vezes menos que o retorno normal de uma função, o que justifica o uso de exceções dinâmicas. Normalmente, nesses casos, você precisa reiniciar todo o subsistema do programa ou cometer um erro ao executar a operação. Exemplos: perda repentina da conexão com o banco de dados; erros de alocação de memória fornecidos pelo programador.
  • Erros recuperáveis ​​quando algo impedia a função de concluir sua tarefa, mas a função de chamada pode saber o que fazer com ela. Manipulado por exceções estáticas. Exemplos: trabalhe com o sistema de arquivos; outros erros de entrada / saída (IO); Dados incorretos do usuário vector::at() .
  • A função concluiu com êxito sua tarefa, embora com um resultado inesperado. std::optional , std::expected , std::variant . Exemplos: stoi() ; vector::find() ; map::insert .

Na biblioteca padrão, é mais confiável abandonar completamente o uso de exceções dinâmicas para tornar legal a compilação "sem exceções".


errno


As funções que usam errno para trabalhar rápida e facilmente com os códigos de erro C e C ++ devem ser substituídas por throws(std::errc) fails(int) e throws(std::errc) , respectivamente. Por algum tempo, as versões antiga e nova das funções da biblioteca padrão coexistirão, e a antiga será declarada obsoleta.


Falta de memória


Os erros de alocação de memória são manipulados pelo gancho global new_handler , que pode:


  1. Elimine a falta de memória e continue a execução
  2. Lançar uma exceção
  3. Programa de falha

Agora std::bad_alloc lançado por padrão. É recomendável chamar std::terminate() por padrão. Se você precisar do comportamento antigo, substitua o manipulador pelo que você precisa no início de main() .


Todas as funções existentes da biblioteca padrão não serão noexcept e noexcept o programa quando std::bad_alloc . Ao mesmo tempo, novas APIs como vector::try_push_back serão adicionadas, o que permite erros de alocação de memória.


logic_error


Exceções std::logic_error , std::domain_error , std::invalid_argument , std::length_error , std::out_of_range , std::future_error reportam uma violação de uma pré-condição de função. O novo modelo de erro deve usar contratos. Os tipos de exceções listados não serão preteridos, mas quase todos os casos de uso na biblioteca padrão serão substituídos por [[expects: …]] .


Status atual da proposta


A proposta está agora em um estado de rascunho. Ele já mudou bastante e ainda pode mudar muito. Como alguns desenvolvimentos não conseguiram ser publicados, a API proposta <system_error2> não <system_error2> totalmente relevante.


A proposta está descrita em 3 documentos:


  1. P709 - documento original do brasão de armas da Sutter
  2. P1095 - Exceções determinadas na visão de Niall Douglas, alguns momentos alterados, compatibilidade de idioma C adicionada
  3. P1028 - API da implementação de teste de std::error

Atualmente, não há compilador que suporte exceções estáticas. Portanto, ainda não é possível fazer seus benchmarks.


C++23. , , , C++26, , , .


Conclusão


, , . , . .


, ^^

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


All Articles