O problema:
Desde muito tempo, estou preocupado com o exceptions
mecanismo, porque sinto que ele realmente não resolve o que deveria.
REIVINDICAÇÃO: Há longos debates externos sobre esse tópico, e a maioria deles luta para comparar ou exceptions
retornar um código de erro. Definitivamente, este não é o tópico aqui.
Tentando definir um erro, eu concordo com o CppCoreGuidelines, da Bjarne Stroustrup & Herb Sutter
Um erro significa que a função não pode atingir seu objetivo anunciado
REIVINDICAÇÃO: O exception
mecanismo é uma linguagem semântica para o tratamento de erros.
REIVINDICAÇÃO: Para mim, "não há desculpa" para uma função por não realizar uma tarefa: definimos condições pré / pós incorretamente para que a função não possa garantir resultados ou algum caso excepcional específico não é considerado importante o suficiente para gastar tempo no desenvolvimento uma solução. Considerando que, na IMO, a diferença entre o código normal e o tratamento do código de erro é (antes da implementação) uma linha muito subjetiva.
REIVINDICAÇÃO: Usar exceções para indicar quando uma condição anterior ou posterior não é mantida é outro objetivo do exception
mecanismo, principalmente para fins de depuração. Não viso esse uso exceptions
aqui.
Em muitos livros, tutoriais e outras fontes, eles tendem a mostrar o tratamento de erros como uma ciência bastante objetiva, resolvida exceptions
e você só precisa catch
deles para ter um software robusto, capaz de se recuperar de qualquer situação. Mas meus vários anos como desenvolvedor me fizeram ver o problema de uma abordagem diferente:
- Os programadores tendem a simplificar sua tarefa lançando exceções quando o caso específico parece muito raro para ser implementado com cuidado. Os casos típicos são: problemas de falta de memória, problemas de disco cheio, problemas de arquivos corrompidos etc. Isso pode ser suficiente, mas nem sempre é decidido do nível da arquitetura.
- Os programadores tendem a não ler atentamente a documentação sobre exceções nas bibliotecas e geralmente não estão cientes de quais e quando uma função é executada. Além disso, mesmo quando eles sabem, eles realmente não os gerenciam.
- Os programadores tendem a não capturar exceções com antecedência suficiente e, quando o fazem, é principalmente para registrar e lançar ainda mais. (consulte o primeiro ponto).
Isso tem duas consequências:
- Erros que ocorrem com frequência são detectados no início do desenvolvimento e depurados (o que é bom).
- Exceções raras não são gerenciadas e fazem o sistema travar (com uma boa mensagem de log) na casa do usuário. Algumas vezes o erro é relatado, ou nem mesmo.
Considerando que, na IMO, o principal objetivo de um mecanismo de erro deve ser:
- Tornar visível no código onde algum caso específico não é gerenciado.
- Comunique o tempo de execução do problema ao código relacionado (pelo menos ao chamador) quando essa situação ocorrer.
- Fornece mecanismos de recuperação
A principal falha da exception
semântica como mecanismo de tratamento de erros é a IMO: é fácil ver onde a throw
está no código-fonte, mas não é absolutamente evidente se uma função específica pode ser lançada observando a declaração. Isso traz todo o problema que apresentei acima.
O idioma não aplica e verifica o código de erro tão estritamente quanto em outros aspectos do idioma (por exemplo, tipos fortes de variáveis)
Uma tentativa de solução
Com a intenção de melhorar isso, desenvolvi um sistema muito simples de tratamento de erros, que tenta colocar o tratamento no mesmo nível de importância que o código normal.
A ideia é:
- Cada função (relevante) recebe uma referência a um
success
objeto muito leve e pode configurá-lo para um status de erro no caso. O objeto fica muito claro até que um erro no texto seja salvo. - Uma função é incentivada a ignorar sua tarefa se o objeto fornecido já contiver um erro.
- Um erro nunca deve ser substituído.
O design completo obviamente leva em consideração cada aspecto (cerca de 10 páginas) e também como aplicá-lo ao OOP.
Exemplo da Success
classe:
class Success
{
public:
enum SuccessStatus
{
ok = 0, // All is fine
error = 1, // Any error has been reached
uninitialized = 2, // Initialization is required
finished = 3, // This object already performed its task and is not useful anymore
unimplemented = 4, // This feature is not implemented already
};
Success(){}
Success( const Success& v);
virtual ~Success() = default;
virtual Success& operator= (const Success& v);
// Comparators
virtual bool operator==( const Success& s)const { return (this->status==s.status && this->stateStr==s.stateStr);}
virtual bool operator!=( const Success& s)const { return (this->status!=s.status || this->stateStr==s.stateStr);}
// Retrieve if the status is not "ok"
virtual bool operator!() const { return status!=ok;}
// Retrieve if the status is "ok"
operator bool() const { return status==ok;}
// Set a new status
virtual Success& set( SuccessStatus status, std::string msg="");
virtual void reset();
virtual std::string toString() const{ return stateStr;}
virtual SuccessStatus getStatus() const { return status; }
virtual operator SuccessStatus() const { return status; }
private:
std::string stateStr;
SuccessStatus status = Success::ok;
};
Uso:
double mySqrt( Success& s, double v)
{
double result = 0.0;
if (!s) ; // do nothing
else if (v<0.0) s.set(Error, "Square root require non-negative input.");
else result = std::sqrt(v);
return result;
}
Success s;
mySqrt(s, 144.0);
otherStuff(s);
saveStuff(s);
if (s) /*All is good*/;
else cout << s << endl;
Eu usei isso em muitos códigos (próprios) e forçou o programador (eu) a pensar mais sobre possíveis casos excepcionais e como resolvê-los (bom). No entanto, ele possui uma curva de aprendizado e não se integra bem ao código que agora o utiliza.
A questão
Eu gostaria de entender melhor as implicações do uso desse paradigma em um projeto:
- A premissa do problema está correta? ou Perdi algo relevante?
- A solução é uma boa idéia arquitetônica? ou o preço é muito alto?
EDITAR:
Comparação entre métodos:
//Exceptions:
// Incorrect
File f = open("text.txt"); // Could throw but nothing tell it! Will crash
save(f);
// Correct
File f;
try
{
f = open("text.txt");
save(f);
}
catch( ... )
{
// do something
}
//Error code (mixed):
// Incorrect
File f = open("text.txt"); //Nothing tell you it may fail! Will crash
save(f);
// Correct
File f = open("text.txt");
if (f) save(f);
//Error code (pure);
// Incorrect
File f;
open(f, "text.txt"); //Easy to forget the return value! will crash
save(f);
//Correct
File f;
Error er = open(f, "text.txt");
if (!er) save(f);
//Success mechanism:
Success s;
File f;
open(s, "text.txt");
save(s, f); //s cannot be avoided, will never crash.
if (s) ... //optional. If you created s, you probably don't forget it.
fonte
Respostas:
O tratamento de erros é talvez a parte mais difícil de um programa.
Em geral, é fácil perceber que há uma condição de erro; no entanto, sinalizá-lo de uma maneira que não pode ser contornada e manipulá-lo adequadamente (consulte os níveis de segurança de exceção da Abrahams ) é realmente difícil.
Em C, os erros de sinalização são feitos por um código de retorno, que é isomórfico para sua solução.
O C ++ introduziu exceções devido à falta de uma abordagem desse tipo; ou seja, só funciona se os chamadores se lembrarem de verificar se ocorreu um erro ou não e se falharem horrivelmente caso contrário. Sempre que você se encontra dizendo "Tudo bem desde que todas as vezes ...", você tem um problema; os humanos não são tão meticulosos, mesmo quando se importam.
O problema, no entanto, é que as exceções têm seus próprios problemas. Ou seja, fluxo de controle invisível / oculto. O objetivo era: ocultar o caso de erro para que a lógica do código não seja ofuscada pelo padrão de manipulação de erros. Isso torna o "caminho feliz" muito mais claro (e rápido!), Ao custo de tornar os caminhos de erro quase inescrutáveis.
Acho interessante ver como outros idiomas abordam o problema:
O C ++ costumava ter alguma forma de exceções verificadas; talvez você tenha notado que foi preterido e simplificado em relação a um básico
noexcept(<bool>)
: em vez disso, uma função é declarada como possivelmente lançada ou nunca. As exceções verificadas são um pouco problemáticas, pois não têm extensibilidade, o que pode causar mapeamentos / aninhamentos difíceis. E hierarquias de exceção complicadas (um dos principais casos de uso da herança virtual são as exceções ...).Por outro lado, Go e Rust adotam a abordagem que:
O último é bastante evidente no fato de que (1) eles denominam suas exceções de pânico e (2) não há hierarquia de tipos / cláusula complicada aqui. O idioma não oferece facilidades para inspecionar o conteúdo de um "pânico": nenhuma hierarquia de tipos, nenhum conteúdo definido pelo usuário, apenas um "opa, as coisas deram tão errado que não há recuperação possível".
Isso efetivamente incentiva os usuários a usar o tratamento adequado de erros, enquanto ainda deixa uma maneira fácil de sair em situações excepcionais (como: "espere, eu ainda não implementei isso!").
Obviamente, a abordagem Go infelizmente é muito parecida com a sua, pois você pode facilmente esquecer de verificar o erro ...
... a abordagem Rust, no entanto, é principalmente centrada em dois tipos:
Option
, que é semelhante astd::optional
,Result
, que é uma variante de duas possibilidades: Ok e Err.isso é muito mais claro porque não há oportunidade de usar um resultado acidentalmente sem ter verificado o sucesso: se você o fizer, o programa entrará em pânico.
As linguagens FP formam seu tratamento de erros em construções que podem ser divididas em três camadas: - Functor - Aplicável / Alternativa - Mônadas / Alternativa
Vamos dar uma olhada na
Functor
tipeclass de Haskell :Primeiro de tudo, as classes de tipo são um pouco semelhantes, mas não iguais às interfaces. As assinaturas de funções de Haskell parecem um pouco assustadoras à primeira vista. Mas vamos decifrá-los. A função
fmap
assume uma função como primeiro parâmetro, que é um pouco semelhante astd::function<a,b>
. A próxima coisa é umam a
. Você pode imaginarm
como algo parecidostd::vector
em a
algo parecidostd::vector<a>
. Mas a diferença é quem a
isso não significa que tenha que ser explicitamentestd:vector
. Então poderia serstd::option
também. Dizendo ao idioma que temos uma instância para a classeFunctor
de tipo de um tipo específico comostd::vector
oustd::option
, podemos usar a funçãofmap
para esse tipo. O mesmo deve ser feito para os typeclassesApplicative
,Alternative
eMonad
que permite que você faça cálculos com estado e possíveis falhas. AAlternative
classe tipográfica implementa abstrações de recuperação de erros. Com isso, você pode dizer algo comoa <|> b
significando que é termoa
ou termob
. Se nenhum dos dois cálculos tiver êxito, ainda será um erro.Vamos dar uma olhada no
Maybe
tipo de Haskell .Isso significa que, onde você espera um
Maybe a
, recebe umNothing
ou outroJust a
. Ao olharfmap
de cima, uma implementação pode parecerA
case ... of
expressão é chamada de correspondência de padrões e se assemelha ao que é conhecido no mundo OOP comovisitor pattern
. Imagine a linhacase m of
comom.apply(...)
e os pontos é a instanciação de uma classe implementando as funções de despacho. As linhas abaixo dacase ... of
expressão são as respectivas funções de despacho, trazendo os campos da classe diretamente no escopo por nome. NoNothing
ramo que criamosNothing
e noJust a
ramo, denominamos nosso único valora
e criamos outroJust ...
com a função de transformaçãof
aplicadaa
. Leia-o como:new Just(f(a))
.Agora, isso pode lidar com cálculos errôneos enquanto abstrai as verificações de erro reais. Existem implementações para as outras interfaces, o que torna esse tipo de computação muito poderoso. Na verdade,
Maybe
é a inspiração para o Rust'sOption
tipo- .Gostaria de encorajá-lo a refazer sua
Success
classe em direção a umResult
lugar. Alexandrescu propôs algo realmente próximo, chamadoexpected<T>
, para o qual propostas padrão foram feitas .Vou me ater ao nome e à API do Rust simplesmente porque ... está documentado e funciona. Obviamente, Rust tem um
?
operador de sufixo bacana que tornaria o código muito mais doce; em C ++, usaremos a expressão deTRY
macro e instruções do GCC para emulá-la.Nota: este
Result
é um espaço reservado. Uma implementação adequada usaria encapsulamento e umunion
. É o suficiente para esclarecer o ponto.O que me permite escrever ( veja em ação ):
que eu acho muito legal:
Success
classe), esquecer de verificar erros resultará em um erro de tempo de execução 1, em vez de um comportamento aleatório,concepts
no padrão. Isso tornaria esse tipo de programação muito mais agradável, pois poderíamos deixar a escolha sobre o tipo de erro. Por exemplo, com uma implementação destd::vector
como resultado, poderíamos calcular todas as soluções possíveis de uma só vez. Ou podemos optar por melhorar o tratamento de erros, como você propôs.1 Com uma
Result
implementação devidamente encapsulada ;)Nota: diferentemente da exceção, esse peso leve
Result
não possui backtraces, o que torna o registro menos eficiente; você pode achar útil registrar pelo menos o número do arquivo / linha no qual a mensagem de erro é gerada e geralmente escrever uma mensagem de erro avançada. Isso pode ser composto capturando o arquivo / linha toda vez que aTRY
macro é usada, criando essencialmente o backtrace manualmente ou usando códigos e bibliotecas específicas da plataforma, comolibbacktrace
para listar os símbolos na pilha de chamadas.Há uma grande ressalva: as bibliotecas C ++ existentes e até mesmo
std
são baseadas em exceções. Será uma batalha difícil usar esse estilo, já que a API de qualquer biblioteca de terceiros deve ser envolvida em um adaptador ...fonte
({...})
existe alguma extensão do gcc, mas mesmo assim não deveriaif (!result.ok) return result;
? Sua condição aparece ao contrário e você faz uma cópia desnecessária do erro.({...})
é a expressão de declarações do gcc .std::variant
para implementar oResult
se você estiver usando C ++ 17. Além disso, para receber um aviso se você ignorar um erro, use[[nodiscard]]
std::variant
ou não é uma questão de gosto, dadas as vantagens e desvantagens em relação ao tratamento de exceções.[[nodiscard]]
é de fato uma vitória pura.exceções são um mecanismo de controle de fluxo. A motivação para esse mecanismo de controle-fluxo foi separar especificamente o tratamento de erros do código de tratamento sem erros, no caso comum de que o tratamento de erros é muito repetitivo e tem pouca relevância para a parte principal da lógica.
Considere: eu tento criar um arquivo. O dispositivo de armazenamento está cheio.
Agora, isso não é uma falha na definição de minhas pré-condições: você não pode usar "deve haver armazenamento suficiente" como uma pré-condição em geral, porque o armazenamento compartilhado está sujeito a condições de corrida que impossibilitam a satisfação.
Então, meu programa deve, de alguma forma, liberar espaço e prosseguir com êxito, caso contrário, tenho preguiça de "desenvolver uma solução"? Isso parece francamente sem sentido. A "solução" para a gestão de armazenamento compartilhado está fora do âmbito do meu programa , e permitindo que o meu programa para falhar normalmente, e ser re-run uma vez que o utilizador seja lançado algum espaço, ou adicionado um pouco mais de armazenamento, é fino .
O que sua classe de sucesso faz é intercalar o tratamento de erros de maneira muito explícita com a lógica do seu programa. Toda função precisa verificar, antes da execução, se já ocorreu algum erro, o que significa que ela não deve fazer nada. Toda função de biblioteca precisa ser agrupada em outra função, com mais um argumento (e, esperançosamente, um encaminhamento perfeito), que faz exatamente a mesma coisa.
Observe também que sua
mySqrt
função precisa retornar um valor mesmo que tenha falhado (ou uma função anterior tenha falhado). Então, você está retornando um valor mágico (comoNaN
) ou injetando um valor indeterminado em seu programa e esperando nada o utilize sem verificar o estado de sucesso que você encadeara durante a execução.Para correção - e desempenho - é muito melhor recuperar o controle do escopo quando você não puder progredir. Exceções e verificação explícita de erros no estilo C com retorno antecipado realizam isso.
Para comparação, um exemplo de sua ideia que realmente funciona é a mônada de erro em Haskell. A vantagem sobre o seu sistema é que você escreve a maior parte da sua lógica normalmente e, em seguida, envolve-a na mônada, que cuida de interromper a avaliação quando uma etapa falha. Dessa forma, o único código que toca diretamente no sistema de tratamento de erros é o código que pode falhar (gerar um erro) e o código que precisa lidar com a falha (capturar uma exceção).
Não tenho certeza se o estilo de mônada e a avaliação lenta traduzem bem para C ++.
fonte
and allowing my program to fail gracefully, and be re-run
quando perdesse o trabalho de 2h:std::exception
no nível mais alto da operação lógica, diz ao usuário "X falhou por causa de ex.what ()" e oferece a tentativa de tentar novamente toda a operação quando e se estiver pronta.showing the Save dialog again along with an error message and allowing the user to specify an alternative location to try
. Esse é um tratamento gracioso de um problema que normalmente não pode ser solucionado a partir do código que detecta que o primeiro local de armazenamento está cheio.Sua abordagem traz alguns grandes problemas ao seu código-fonte:
depende do código do cliente sempre lembrando de verificar o valor de
s
. Isso é comum com os códigos de retorno de uso para a abordagem de tratamento de erros e um dos motivos pelos quais as exceções foram introduzidas no idioma: com exceções, se você falhar, não falhará silenciosamente.quanto mais código você escrever com essa abordagem, mais código de erro você precisará adicionar também para o tratamento de erros (seu código não é mais minimalista) e seu esforço de manutenção aumentará.
As soluções para esses problemas devem ser abordadas no nível do líder técnico ou no nível da equipe:
Se você se encontra lidando com todo tipo de exceção que pode ser lançada o tempo todo, o design não é bom; Quais erros são tratados, devem ser decididos de acordo com as especificações do projeto, não de acordo com o que os desenvolvedores desejam implementar.
Aborde a configuração de testes automatizados, separando a especificação dos testes de unidade e a implementação (faça com que duas pessoas diferentes façam isso).
Você não abordará isso escrevendo mais código. Acho que sua melhor aposta são as análises de código meticulosamente aplicadas.
O tratamento adequado de erros é difícil, mas menos tedioso com exceções do que com valores de retorno (se eles são realmente retornados ou transmitidos como argumentos de E / S).
A parte mais complicada do tratamento de erros não é como você recebe o erro, mas como garantir que seu aplicativo mantenha um estado consistente na presença de erros.
Para resolver isso, é necessário dar mais atenção à identificação e execução em condições de erro (mais testes, mais testes de unidade / integração, etc.).
fonte