Uso linguístico de exceções em C ++


16

O FAQ de exceção isocpp.org estados

Não use throw para indicar um erro de codificação no uso de uma função. Use assert ou outro mecanismo para enviar o processo para um depurador ou para travar o processo e coletar o despejo de falha para o desenvolvedor depurar.

Por outro lado, a biblioteca padrão define std :: logic_error e todas as suas derivadas, que me parecem que devem tratar, além de outras coisas, erros de programação. A passagem de uma string vazia para std :: stof (lançará um argumento inválido) não é um erro de programação? A passagem de uma string que contém caracteres diferentes de '1' / '0' para std :: bitset (lançará um argumento inválido) não é um erro de programação? A chamada std :: bitset :: set com um índice inválido (lançará fora do intervalo) não é um erro de programação? Se não forem, qual é o erro de programação que alguém testaria? O construtor baseado em string std :: bitset existe apenas desde C ++ 11, portanto, deveria ter sido projetado com o uso idiomático de exceções em mente. Por outro lado, as pessoas me dizem que logic_error basicamente não deve ser usado.

Outra regra que surge frequentemente com exceções é "use somente exceções em circunstâncias excepcionais". Mas como uma função de biblioteca deve saber quais circunstâncias são excepcionais? Para alguns programas, não é possível abrir um arquivo pode ser excepcional. Para outros, não é possível alocar memória pode não ser excepcional. E há centenas de casos no meio. Ser incapaz de criar um soquete? Não foi possível conectar ou gravar dados em um soquete ou arquivo? Não foi possível analisar a entrada? Pode ser excepcional, pode não ser. Definitivamente, a função em si definitivamente não pode saber, não tem idéia de que tipo de contexto está sendo chamada.

Então, como devo decidir se devo usar exceções ou não para uma função específica? Parece-me que a única maneira realmente consistente é usá-los para todo e qualquer tratamento de erros, ou para nada. E se eu estiver usando a biblioteca padrão, essa escolha foi feita para mim.


6
Você deve ler essa entrada da FAQ com muito cuidado. Aplica-se apenas a erros de codificação, não a dados inválidos, desreferenciando um objeto nulo ou qualquer coisa relacionada à falta geral de tempo de execução. Em geral, as afirmações são sobre a identificação de coisas que nunca deveriam acontecer. Para todo o resto, há exceções, códigos de erro e assim por diante.
Robert Harvey

1
@RobertHarvey essa definição ainda tem o mesmo problema - se algo pode ser resolvido sem intervenção humana ou não, é conhecido apenas pelas camadas superiores de um programa.
cooky451

1
Você está se apaixonando por questões jurídicas. Avalie os prós e os contras e decida-se. Além disso, o último parágrafo da sua pergunta ... Eu não considero isso auto-evidente. Seu pensamento é muito preto e branco, quando a verdade provavelmente está mais próxima de alguns tons de cinza.
Robert Harvey

4
Você já tentou fazer alguma pesquisa antes de fazer essa pergunta? Os idiomas de manipulação de erros do C ++ são quase certamente discutidos com detalhes nauseantes na web. Uma referência a uma entrada da FAQ não é boa pesquisa. Depois de fazer sua pesquisa, você ainda terá que se decidir. Não me inicie sobre como nossas escolas de programação aparentemente estão criando robôs de codificação de padrões de software irracionais que não sabem pensar por si mesmos.
Robert Harvey

2
O que dá credibilidade à minha teoria de que essa regra pode não existir realmente. Convidei algumas pessoas do The C ++ Lounge para ver se elas podem responder à sua pergunta, embora toda vez que eu vá lá, o conselho delas seja: "Pare de usar o C ++, isso estragará seu cérebro". Portanto, siga os conselhos deles por sua conta e risco.
Robert Harvey

Respostas:


15

Primeiro, sinto-me obrigado a salientar que std::exceptione seus filhos foram projetados há muito tempo. Existem várias partes que provavelmente (quase certamente) seriam diferentes se estivessem sendo projetadas hoje.

Não me entenda mal: há partes do design que funcionaram muito bem e são bons exemplos de como criar uma hierarquia de exceções para C ++ (por exemplo, o fato de que, diferentemente da maioria das outras classes, todas compartilham um raiz comum).

Olhando especificamente logic_error, temos um pouco de um enigma. Por um lado, se você tiver alguma escolha razoável no assunto, o conselho que você citou está correto: geralmente é melhor falhar o mais rápido e ruidosamente possível, para que possa ser depurado e corrigido.

Para melhor ou para pior, no entanto, é difícil definir a biblioteca padrão em torno do que você geralmente deve fazer. Se eles os definissem para sair do programa (por exemplo, chamar abort()) quando recebessem informações incorretas, isso seria o que sempre acontecia para essa circunstância - e na verdade existem algumas circunstâncias em que essa provavelmente não é a coisa certa a ser feita. , pelo menos no código implantado.

Isso se aplicaria ao código com requisitos de tempo real (pelo menos moderados) e penalidade mínima por uma saída incorreta. Por exemplo, considere um programa de bate-papo. Se estiver decodificando alguns dados de voz e obtendo uma entrada incorreta, é provável que o usuário fique muito mais feliz em viver com um milissegundo de estática na saída do que um programa que é completamente desligado. Da mesma forma, ao executar a reprodução de vídeo, pode ser mais aceitável produzir valores incorretos para alguns pixels de um quadro ou dois do que o programa sair sumariamente porque o fluxo de entrada foi corrompido.

Quanto ao uso de exceções para relatar certos tipos de erros: você está certo - a mesma operação pode ser qualificada como uma exceção ou não, dependendo de como está sendo usada.

Por outro lado, você também está errado - o uso da biblioteca padrão não força (necessariamente) essa decisão sobre você. No caso de abrir um arquivo, você normalmente usaria um iostream. Os Iostreams também não são exatamente o melhor e o mais recente, mas, neste caso, eles acertam: permitem definir um modo de erro, para que você possa controlar se não abrir um arquivo com o resultado de uma exceção ser lançada ou não. Portanto, se você tiver um arquivo que é realmente necessário para o seu aplicativo e, ao não abri-lo, precisará executar algumas ações corretivas sérias, e poderá criar uma exceção se ele não conseguir abrir o arquivo. Para a maioria dos arquivos, que você tentará abrir, se eles não existirem ou não estiverem acessíveis, eles simplesmente falharão (esse é o padrão).

Quanto à sua decisão: não acho que haja uma resposta fácil. Para o bem ou para o mal, nem sempre é fácil medir "circunstâncias excepcionais". Embora certamente haja casos fáceis de decidir devem ser [não] excepcionais, há (e provavelmente sempre haverá) casos em que isso pode ser questionado ou requer conhecimento de contexto fora do domínio da função em questão. Para casos como esse, pode pelo menos valer a pena considerar um design semelhante a esta parte do iostreams, em que o usuário pode decidir se a falha resulta em uma exceção sendo lançada ou não. Como alternativa, é inteiramente possível ter dois conjuntos separados de funções (ou classes etc.), uma das quais lançará exceções para indicar falha, a outra utilizará outros meios. Se você seguir esse caminho,


9

O construtor baseado em string std :: bitset existe apenas desde C ++ 11, portanto, deveria ter sido projetado com o uso idiomático de exceções em mente. Por outro lado, as pessoas me dizem que logic_error basicamente não deve ser usado.

Você pode não acreditar nisso, mas, bem, diferentes codificadores C ++ discordam. É por isso que o FAQ diz uma coisa, mas a biblioteca padrão discorda.

O FAQ defende a falha porque será mais fácil depurar. Se você travar e obter um dump principal, terá o estado exato do seu aplicativo. Se você lançar uma exceção, perderá muito desse estado.

A biblioteca padrão adota a teoria de que dar ao codificador a capacidade de capturar e manipular o erro é mais importante que a depuração.

Pode ser excepcional, pode não ser. Definitivamente, a função em si definitivamente não pode saber, não tem idéia de que tipo de contexto está sendo chamada.

A idéia aqui é que, se sua função não souber se a situação é excepcional ou não, ela não deve gerar uma exceção. Ele deve retornar um estado de erro por meio de outro mecanismo. Quando atingir um ponto no programa em que sabe que o estado é excepcional, deve lançar a exceção.

Mas isso tem seu próprio problema. Se um estado de erro for retornado de uma função, você pode não se lembrar de verificá-lo e o erro passará silenciosamente. Isso leva algumas pessoas a abandonar as exceções, sendo uma regra excepcional a favor de lançar exceções para qualquer tipo de estado de erro.

No geral, o ponto principal é que pessoas diferentes têm idéias diferentes sobre quando lançar exceções. Você não encontrará uma única ideia coesa. Mesmo que algumas pessoas afirmem dogmaticamente que essa ou aquela é a maneira correta de lidar com exceções, não existe uma teoria acordada.

Você pode lançar exceções:

  1. Nunca
  2. Em toda parte
  3. Somente em erros de programador
  4. Nunca em erros de programador
  5. Somente durante falhas não rotineiras (excepcionais)

e encontre alguém na internet que concorde com você. Você terá que adotar o estilo que funciona para você.


É possível notar que a sugestão de usar apenas exceções quando as circunstâncias são realmente excepcionais foi amplamente promovida por pessoas que ensinam sobre idiomas nas quais as exceções têm um desempenho ruim. C ++ não é uma dessas linguagens.
Jules

1
@Jules - agora que (desempenho) certamente merece uma resposta própria, onde você faz o backup da sua reivindicação. O desempenho de exceções em C ++ é certamente um problema, pode ser mais, talvez menor que em outros lugares, mas afirmar que "C ++ não é uma dessas linguagens [onde as exceções têm desempenho ruim]" é certamente discutível.
Martin Ba

1
@MartinBa - comparado com, digamos, Java, o desempenho de exceções em C ++ é de magnitude mais rápida. Os benchmarks sugerem que o desempenho de lançar uma exceção para cima 1 nível é cerca de 50x mais lento do que manipular um valor de retorno em C ++, contra mais de 1000x mais lento em Java. Os conselhos escritos para Java, neste caso, não devem ser aplicados ao C ++ sem uma reflexão extra, porque há mais do que uma diferença de ordem de magnitude no desempenho entre os dois. Talvez eu devesse ter escrito "desempenho extremamente ruim" em vez de "desempenho ruim".
Jules

1
@ Jules - obrigado por esses números. (alguma fonte?) eu posso acreditar nelas, porque Java (e C #) precisam capturar o rastreamento de pilha, o que certamente parece que pode ser muito caro. Eu ainda acho que sua resposta inicial é meio enganosa, porque mesmo uma desaceleração de 50x é bastante pesada, eu acho, especialmente. em uma linguagem orientada para o desempenho como C ++.
Martin Ba

2

Muitas outras boas respostas foram escritas, só quero acrescentar um breve ponto.

A resposta tradicional, especialmente quando a FAQ do ISO C ++ foi escrita, compara principalmente "exceção C ++" vs. "código de retorno no estilo C". Uma terceira opção ", retorna algum tipo de valor composto, por exemplo, a structou union, atualmente, boost::variantou o (proposto) std::expected, não é considerado.

Antes do C ++ 11, a opção "retornar um tipo composto" era geralmente muito fraca. Como não havia semântica de movimento, copiar as coisas dentro e fora de uma estrutura era potencialmente muito caro. Era extremamente importante naquele ponto do idioma modelar seu código no RVO para obter o melhor desempenho. As exceções eram como uma maneira fácil de retornar efetivamente um tipo composto, caso contrário isso seria bastante difícil.

A IMO, após o C ++ 11, essa opção "retornar uma união discriminada", semelhante ao idioma Result<T, E>usado atualmente no Rust, deve ser favorecida com mais frequência no código C ++. Às vezes, é realmente um estilo mais simples e conveniente de indicar erros. Com exceções, sempre existe uma possibilidade de que funções que não foram lançadas antes poderiam começar a ser repentinamente executadas após um refator e os programadores nem sempre documentam essas coisas tão bem. Quando o erro é indicado como parte do valor de retorno em uma união discriminada, reduz bastante a chance de o programador simplesmente ignorar o código do erro, que é a crítica usual ao tratamento de erros no estilo C.

Geralmente Result<T, E>funciona como um impulso opcional. Você pode testar, usando operator bool, se é um valor ou um erro. E então use say operator *para acessar o valor, ou alguma outra função "get". Geralmente esse acesso é desmarcado, para velocidade. Mas você pode fazer com que, em uma compilação de depuração, o acesso seja verificado e uma asserção garanta que haja realmente um valor e não um erro. Dessa forma, qualquer pessoa que não verifique corretamente os erros terá uma afirmação rígida, em vez de um problema mais insidioso.

Uma vantagem adicional é que, diferentemente das exceções em que, se não for capturado, apenas voa a pilha a alguma distância arbitrária, com esse estilo, quando uma função começa a sinalizar um erro que não ocorria antes, você não pode compilar, a menos que o código é alterado para lidar com isso. Isso torna os problemas mais altos - o problema tradicional de "exceção não capturada" se torna mais um erro em tempo de compilação do que um erro em tempo de execução.

Eu me tornei um grande fã desse estilo. Normalmente, hoje em dia uso isso ou exceções. Mas tento limitar as exceções a grandes problemas. Para algo como um erro de análise, tento retornar, expected<T>por exemplo. Coisas como std::stoie boost::lexical_castque lançam uma exceção C ++ no caso de algum problema relativamente menor "a string não poder ser convertida em número" me parecem muito ruins hoje em dia.


1
std::expectedainda é uma proposta não aceita, certo?
Martin Ba

Você está certo, acho que ainda não foi aceito. Mas existem várias implementações de código aberto flutuando, e eu criei as minhas algumas vezes, eu acho. É menos complicado do que fazer um tipo de variante, pois há apenas dois estados possíveis. As principais considerações de design são: qual interface exata você deseja e deseja que ela seja como o esperado <T> da Andrescu, onde o objeto de erro realmente deve ser um exception_ptr, ou você deseja apenas usar algum tipo de estrutura ou algo assim Curtiu isso.
21416 Chris Beck

A palestra de Andrei Alexandrescu está aqui: channel9.msdn.com/Shows/Going+Deep/… Ele mostra em detalhes como construir uma classe como esta e que considerações você pode ter.
21416 Chris Beck

O proposto [[nodiscard]] attributeserá útil para essa abordagem de tratamento de erros, pois garante que você simplesmente não ignore o resultado do erro por acidente.
CodesInChaos

- Sim, eu conhecia a palestra de AA. Achei o design bastante estranho, pois para desempacotá-lo ( except_ptr), você tinha que lançar uma exceção internamente. Pessoalmente, acho que essa ferramenta deve funcionar completamente independente das execuções. Apenas uma observação.
Martin Ba

1

Essa é uma questão altamente subjetiva, pois faz parte do design. E como o design é basicamente arte, prefiro discutir essas coisas em vez de debater (não estou dizendo que você está debatendo).

Para mim, casos excepcionais são de dois tipos - aqueles que lidam com recursos e aqueles que lidam com operações críticas. O que pode ser considerado crítico depende do problema em questão e, em muitos casos, do ponto de vista do programador.

A falha na aquisição de recursos é um dos principais candidatos a lançar exceções. O recurso pode ser memória, arquivo, conexão de rede ou qualquer outra coisa com base no seu problema e plataforma. Agora, a falha ao liberar um recurso justifica uma exceção? Bem, isso depende novamente. Não fiz nada em que a liberação da memória falhou, por isso não tenho certeza sobre esse cenário. No entanto, a exclusão de arquivos como parte da liberação de recursos pode falhar e falhou para mim, e essa falha geralmente está vinculada a outro processo, mantendo-o aberto em um aplicativo de processo múltiplo. Eu acho que outros recursos podem falhar durante o lançamento como um arquivo, e geralmente é uma falha de design que causa esse problema, portanto, corrigi-lo seria melhor do que lançar uma exceção.

Em seguida, vem a atualização de recursos. Este ponto, pelo menos para mim, está intimamente relacionado ao aspecto de operações críticas do aplicativo. Imagine uma Employeeclasse com uma função UpdateDetails(std::string&)que modifique os detalhes com base na sequência separada por vírgula fornecida. Semelhante à falha na liberação de memória, acho difícil imaginar a atribuição de valores de variáveis ​​de membros com falha devido à minha falta de experiência em domínios onde esses podem ocorrer. No entanto, UpdateDetailsAndUpdateFile(std::string&)espera-se que uma função como a que faz o nome indica falhe. Isso é o que chamo de operação crítica.

Agora, você precisa verificar se a chamada operação crítica merece uma exceção. Quero dizer, a atualização do arquivo está acontecendo no final, como no destruidor, ou é simplesmente uma ligação paranóica feita após cada atualização? Existe um mecanismo de fallback que grava objetos não escritos regularmente? O que estou dizendo é que você deve avaliar a criticidade da operação.

Obviamente, existem muitas operações críticas que não estão vinculadas ao recurso. Se os UpdateDetails()dados forem fornecidos incorretamente, ele não atualizará os detalhes e a falha deverá ser divulgada; portanto, você lançaria uma exceção aqui. Mas imagine uma função como GiveRaise(). Agora, se o empregado mencionado tiver sorte de ter um chefe de cabelos pontudos e não conseguir um aumento (em termos de programação, o valor de alguma variável impede que isso aconteça), a função falhou essencialmente. Você lançaria uma exceção aqui? O que estou dizendo é que você precisa avaliar a necessidade de uma exceção.

Para mim, a consistência é em termos da minha abordagem de design do que a usabilidade das minhas aulas. O que quero dizer é que não penso em termos de 'todas as funções Get devem fazer isso e todas as funções de atualização devem fazer isso', mas ver se uma função específica atrai uma certa idéia dentro da minha abordagem. Aparentemente, as aulas podem parecer meio 'aleatórias', mas sempre que os usuários (principalmente colegas de outras equipes) discordam ou perguntam sobre isso, eu explico e eles parecem satisfeitos.

Eu vejo muitas pessoas que basicamente substituem valores de retorno com exceções porque usam C ++ e não C, e isso oferece uma 'boa separação de tratamento de erros' etc. tais pessoas.


1

Em primeiro lugar, como já foi dito, as coisas não são que clara em C ++, IMHO principalmente porque os requisitos e restrições são um pouco mais variado em C ++ do que outras línguas, esp. C # e Java, que têm problemas de exceção "semelhantes".

Vou expor no exemplo std :: stof:

passar uma string vazia para std :: stof (lançará invalid_argument), não um erro de programação

O contrato básico , a meu ver, dessa função é que ele tenta converter seu argumento em um float, e qualquer falha nesse procedimento é relatada por uma exceção. Ambas as exceções possíveis são derivadas, logic_errormas não no sentido de erro do programador, mas no sentido de "a entrada não pode, jamais, ser convertida em um float".

Aqui, pode-se dizer que a logic_erroré usada para indicar que, dada essa entrada (em tempo de execução), sempre é um erro tentar convertê-la - mas é o trabalho da função determinar isso e informar (por exceção).

Nota lateral: Nessa visão, a runtime_error poderia ser visto como algo que, dada a mesma entrada para uma função, teoricamente poderia ter sucesso em execuções diferentes. (por exemplo, uma operação de arquivo, acesso ao banco de dados, etc.)

Nota: A biblioteca de regex C ++ optou por derivar seu erro, runtime_errorembora haja casos em que ela pode ser classificada da mesma forma que aqui (padrão de regex inválido).

Isso apenas mostra, IMHO, que o agrupamento logic_ou runtime_erro é bastante confuso em C ++ e não ajuda muito no caso geral (*) - se você precisar lidar com erros específicos, provavelmente precisará capturar menos do que os dois.

(*): Isso não quer dizer que um único pedaço de código não deve ser consistente, mas se você joga runtime_ou logic_ou custom_algumas coisas que não é realmente importante, eu acho.


Para comentar sobre ambos stofe bitset:

Ambas as funções recebem seqüências de caracteres como argumento e, nos dois casos, é:

  • não é trivial verificar se o chamador é válido ou não, se uma determinada string é válida (por exemplo, no pior caso, você teria que replicar a lógica da função; no caso de bitset, não está claro se a string vazia é válida; portanto, deixe o ctor decidir)
  • Já é responsabilidade da função "analisar" a sequência, portanto, ela já tem que validá-la; portanto, faz sentido que reporte um erro ao "usar" a sequência de maneira uniforme (e nos dois casos isso é uma exceção) .

A regra que surge frequentemente com exceções é "use somente exceções em circunstâncias excepcionais". Mas como uma função de biblioteca deve saber quais circunstâncias são excepcionais?

Esta declaração tem, IMHO, duas raízes:

Desempenho : se uma função é chamada em um caminho crítico e o caso "excepcional" não é excepcional, ou seja, uma quantidade significativa de passes envolve o lançamento de uma exceção, e pagar todas as vezes pelo mecanismo de desenrolar exceção não faz sentido. e pode ser muito lento.

Localidade do tratamento de erros : se uma função é chamada e a exceção é capturada e processada imediatamente, há pouco sentido em lançar uma exceção, pois o tratamento de erros será mais detalhado com o catchque com um if.

Exemplo:

float readOrDefault;
try {
  readOrDefault = stof(...);
} catch(std::exception&) {
  // discard execption, just use default value
  readOrDefault = 3.14f; // 3.14 is the default value if cannot be read
}

Aqui é onde funções como TryParsevs. Parseentram em cena: Uma versão para quando o código local espera que a string analisada seja válida, uma versão quando o código local assume que é realmente esperado (ou seja, não excepcional) que a análise falhe.

Na verdade, stofé apenas (definido como) um invólucro strtof, portanto, se você não deseja exceções, use esse.


Então, como devo decidir se devo usar exceções ou não para uma função específica?

IMHO, você tem dois casos:

  • Função "Biblioteca" (reutilizada frequentemente em diferentes contextos): Basicamente, você não pode decidir. Possivelmente, forneça ambas as versões, talvez uma que relate um erro e uma versão que converta o erro retornado em uma exceção.

  • Função "Aplicativo" (específica para um blob de código do aplicativo, pode ser reutilizada em alguns casos, mas é limitada pelo estilo de tratamento de erros do aplicativo etc.): Aqui, geralmente deve ser bem claro. Se o (s) caminho (s) de código que chamam as funções lidam com exceções de maneira sã e útil, use as exceções para relatar qualquer erro (mas veja abaixo) . Se o código do aplicativo for mais facilmente lido e gravado para um estilo de retorno de erro, use-o de qualquer maneira.

É claro que haverá lugares no meio - apenas use o que precisa e lembre-se da YAGNI.


Por fim, acho que devo voltar à declaração de perguntas frequentes,

Não use throw para indicar um erro de codificação no uso de uma função. Use assert ou outro mecanismo para enviar o processo para um depurador ou travar o processo ...

Eu assino isso para todos os erros que são uma indicação clara de que algo está gravemente bagunçado ou que o código de chamada claramente não sabia o que estava fazendo.

Mas quando isso é apropriado, muitas vezes é altamente específico do aplicativo, portanto, veja acima o domínio da biblioteca vs. o domínio do aplicativo.

Isso recai sobre a questão sobre se e como validar as pré - condições de chamada , mas não vou entrar nisso, responda já por muito tempo :-)

Ao utilizar nosso site, você reconhece que leu e compreendeu nossa Política de Cookies e nossa Política de Privacidade.
Licensed under cc by-sa 3.0 with attribution required.