Ter uma bandeira para indicar se devemos lançar erros

64

Recentemente, comecei a trabalhar em um local com desenvolvedores muito mais antigos (com mais de 50 anos). Eles trabalharam em aplicativos críticos que lidam com a aviação, onde o sistema não podia cair. Como resultado, o programador mais velho tende a codificar dessa maneira.

Ele tende a colocar um booleano nos objetos para indicar se uma exceção deve ser lançada ou não.

Exemplo

public class AreaCalculator
{
    AreaCalculator(bool shouldThrowExceptions) { ... }
    CalculateArea(int x, int y)
    {
        if(x < 0 || y < 0)
        {
            if(shouldThrowExceptions) 
                throwException;
            else
                return 0;
        }
    }
}

(Em nosso projeto, o método pode falhar porque estamos tentando usar um dispositivo de rede que não pode estar presente no momento. O exemplo da área é apenas um exemplo do sinalizador de exceção)

Para mim, isso parece um cheiro de código. Escrever testes de unidade se torna um pouco mais complexo, pois você precisa testar o sinalizador de exceção a cada vez. Além disso, se algo der errado, você não gostaria de saber imediatamente? Não deveria ser responsabilidade do interlocutor determinar como continuar?

Sua lógica / raciocínio é que nosso programa precisa fazer uma coisa, mostrar dados ao usuário. Qualquer outra exceção que não nos impeça de fazê-lo deve ser ignorada. Concordo que eles não devem ser ignorados, mas devem aparecer e ser manipulados pela pessoa apropriada, e não precisam lidar com bandeiras para isso.

Essa é uma boa maneira de lidar com exceções?

Edit : Apenas para dar mais contexto sobre a decisão de design, suspeito que seja porque, se esse componente falhar, o programa ainda poderá operar e executar sua tarefa principal. Portanto, não queremos lançar uma exceção (e não lidar com isso?) E interromper o programa quando, para o usuário, estiver funcionando bem

Edit 2 : Para dar ainda mais contexto, no nosso caso, o método é chamado para redefinir uma placa de rede. O problema surge quando a placa de rede é desconectada e reconectada, é atribuído um endereço IP diferente; portanto, a redefinição lançará uma exceção, pois estaríamos tentando redefinir o hardware com o antigo IP.

Nicolas
fonte
22
O c # possui uma convenção para esse padrão de Try-Parse. mais informações: docs.microsoft.com/en-us/dotnet/standard/design-guidelines/… O sinalizador não corresponde a esse padrão.
Peter
18
Este é basicamente um parâmetro de controle e altera a maneira como os métodos internos são executados. Isso é ruim, independentemente do cenário. martinfowler.com/bliki/FlagArgument.html , softwareengineering.stackexchange.com/questions/147977/… , medium.com/@amlcurran/…
bic
11
Além do comentário do Try-Parse de Peter, aqui está um bom artigo sobre as exceções do Vexing: blogs.msdn.microsoft.com/ericlippert/2008/09/10/…
Linaith
2
"Portanto, não queremos lançar uma exceção (e não lidar com isso?) E interromper o programa quando o usuário estiver funcionando bem" - você sabe que pode capturar exceções, certo?
user253751 6/02
11
Tenho certeza de que isso já foi abordado em outro lugar, mas, dado o exemplo simples da Área, eu estaria mais inclinado a imaginar de onde esses números negativos viriam e se você conseguirá lidar com essa condição de erro em outro lugar (por exemplo, o que quer que estivesse lendo o arquivo contendo comprimento e largura, por exemplo); No entanto, o "estamos tentando usar um dispositivo de rede que não pode estar presente no momento". Esse ponto pode merecer uma resposta totalmente diferente. Essa é uma API de terceiros ou algo padrão da indústria como TCP / UDP?
jrh

Respostas:

74

O problema dessa abordagem é que, embora as exceções nunca sejam lançadas (e, portanto, o aplicativo nunca trava devido a exceções não capturadas), os resultados retornados não são necessariamente corretos e o usuário pode nunca saber que há um problema com os dados (ou qual é esse problema e como corrigi-lo).

Para que os resultados sejam corretos e significativos, o método de chamada deve verificar o resultado em busca de números especiais - ou seja, valores de retorno específicos usados ​​para indicar problemas que surgiram durante a execução do método. Números negativos (ou zero) retornados para quantidades definidas positivas (como área) são um excelente exemplo disso no código antigo. Se o método de chamada não souber (ou esquecer!) Verificar esses números especiais, o processamento poderá continuar sem nunca perceber um erro. Os dados são exibidos ao usuário mostrando uma área 0, que o usuário sabe que está incorreta, mas eles não têm indicação do que deu errado, onde ou por quê. Eles então se perguntam se algum dos outros valores está errado ...

Se a exceção fosse lançada, o processamento seria interrompido, o erro (idealmente) seria registrado e o usuário poderá ser notificado de alguma forma. O usuário pode então corrigir o que estiver errado e tentar novamente. O manuseio adequado das exceções (e testes!) Garantirá que aplicativos críticos não travem ou acabem em um estado inválido.

mmathis
fonte
11
@Quirk É impressionante como Chen conseguiu violar o Princípio da Responsabilidade Única em apenas 3 ou 4 linhas. Esse é o verdadeiro problema. Além disso, o problema de que ele está falando (o programador que falha em pensar nas conseqüências dos erros em cada linha) é sempre uma possibilidade com exceções não verificadas e apenas às vezes uma possibilidade com exceções verificadas. Eu acho que já vi todos os argumentos já feitos contra exceções verificadas, e nenhum deles é válido.
TKK
@TKK pessoalmente, há alguns casos em que eu realmente gostei de exceções verificadas no .NET. Seria bom se houvesse algumas ferramentas de análise estática de ponta que pudessem garantir que o que uma API documenta como suas exceções lançadas seja preciso, embora isso provavelmente seja praticamente impossível, especialmente ao acessar recursos nativos.
jrh
11
@jrh Sim, seria bom se algo incluísse alguma exceção de segurança no .NET, semelhante à forma como os kludges do TypeScript digitam segurança no JS.
TKK
47

Essa é uma boa maneira de lidar com exceções?

Não, acho que é uma prática muito ruim. Lançar uma exceção x retornar um valor é uma alteração fundamental na API, alterar a assinatura do método e fazer com que o método se comporte de maneira bem diferente da perspectiva da interface.

Em geral, quando projetamos classes e suas APIs, devemos considerar que

  1. pode haver várias instâncias da classe com configurações diferentes flutuando no mesmo programa ao mesmo tempo e,

  2. devido à injeção de dependência e a qualquer número de outras práticas de programação, um cliente consumidor pode criar os objetos e entregá-los a outro uso - tantas vezes temos uma separação entre criadores e usuários de objetos.

Considere agora o que o responsável pela chamada do método deve fazer para usar uma instância que lhe foi entregue, por exemplo, para chamar o método de cálculo: o responsável pela chamada teria que verificar se a área é zero e capturar exceções - ai! As considerações de teste vão não apenas para a classe em si, mas também para o tratamento de erros dos chamadores ...

Devemos sempre facilitar as coisas o mais fácil possível para o cliente consumidor; essa configuração booleana no construtor que altera a API de um método de instância é o oposto de fazer com que o programador cliente consumidor (talvez você ou seu colega) caia no poço do sucesso.

Para oferecer as duas APIs, você é muito melhor e mais normal para fornecer duas classes diferentes - uma que sempre gera erro e outra que sempre retorna 0 em erro ou fornece dois métodos diferentes com uma única classe. Dessa forma, o cliente consumidor pode facilmente saber exatamente como verificar e manipular erros.

Usando duas classes diferentes ou dois métodos diferentes, é possível usar os IDEs para encontrar usuários do método e refatorar recursos, etc. muito mais facilmente, pois os dois casos de uso não estão mais conflitantes. A leitura, escrita, manutenção, revisões e testes de código também são mais simples.


Em outra nota, eu pessoalmente acho que não devemos aceitar parâmetros de configuração booleanos, onde todos os chamadores reais simplesmente passam uma constante . Essa parametrização de configuração combina dois casos de uso separados, sem benefício real.

Dê uma olhada na sua base de código e veja se uma variável (ou expressão não constante) é usada para o parâmetro de configuração booleana no construtor! Eu duvido.


E uma consideração mais aprofundada é perguntar por que a área de computação pode falhar. O melhor seria lançar o construtor, se o cálculo não puder ser feito. No entanto, se você não souber se o cálculo pode ser feito até que o objeto seja inicializado ainda mais, talvez considere usar classes diferentes para diferenciar esses estados (não está pronto para calcular a área versus pronto para calcular a área).

Eu li que sua situação de falha é orientada à comunicação remota, portanto, pode não se aplicar; apenas algum alimento para o pensamento.


Não deveria ser responsabilidade do interlocutor determinar como continuar?

Sim eu concordo. Parece prematuro para o receptor decidir que a área 0 é a resposta certa em condições de erro (especialmente porque 0 é uma área válida, portanto, não há como diferenciar o erro do zero real, embora possa não se aplicar ao seu aplicativo).

Erik Eidt
fonte
Você realmente não precisa verificar a exceção porque precisa verificar os argumentos antes de chamar o método. Verificar o resultado contra zero não faz distinção entre os argumentos legais 0, 0 e negativos negativos ilegais. A API é realmente IMHO horrível.
BlackJack
Os pushs do MS do anexo K para iostreams C99 e C ++ são exemplos de APIs em que um gancho ou sinalizador altera radicalmente a reação a falhas.
Deduplicator
37

Eles trabalharam em aplicativos críticos que lidam com a aviação, onde o sistema não podia cair. Como um resultado ...

Essa é uma introdução interessante, que me dá a impressão de que a motivação por trás desse design é evitar lançar exceções em alguns contextos "porque o sistema pode ficar inativo" nessa época. Mas se o sistema "pode ​​cair por causa de uma exceção", isso é uma indicação clara de que

  • as exceções não são tratadas adequadamente , pelo menos nem rigidamente.

Portanto, se o programa que usa o AreaCalculatoré buggy, seu colega prefere não interromper o programa mais cedo, mas retornar algum valor errado (esperando que ninguém perceba ou que ninguém faça algo importante). Na verdade, isso está ocultando um erro e, na minha experiência, mais cedo ou mais tarde levará a erros de acompanhamento para os quais fica difícil encontrar a causa raiz.

IMHO escrever um programa que não trava em nenhuma circunstância, mas mostra dados incorretos ou resultados de cálculos geralmente não é melhor do que deixar o programa travar. A única abordagem correta é dar ao chamador a chance de perceber o erro, lidar com ele, deixar que ele decida se o usuário deve ser informado sobre o comportamento errado e se é seguro continuar o processamento ou se é mais seguro para parar o programa completamente. Assim, eu recomendaria um dos seguintes:

  • dificulta a negligência do fato de que uma função pode gerar uma exceção. Os padrões de documentação e codificação são seus amigos aqui, e as revisões regulares de código devem oferecer suporte ao uso correto dos componentes e ao tratamento adequado de exceções.

  • treine a equipe a esperar e lidar com exceções quando usarem componentes da "caixa preta" e tiver em mente o comportamento global do programa.

  • se, por algum motivo, você acha que não pode obter o código de chamada (ou os desenvolvedores que o escrevem) para usar o tratamento de exceções corretamente, como último recurso, você pode criar uma API com variáveis ​​de saída de erro explícitas e sem exceções, como

    CalculateArea(int x, int y, out ErrorCode err)

    então fica muito difícil para o chamador ignorar a função pode falhar. Mas isso é IMHO extremamente feio em C #; é uma técnica de programação defensiva antiga de C onde não há exceções e, normalmente, não é necessário trabalhar isso hoje em dia.

Doc Brown
fonte
3
"escrever um programa que não trava em nenhuma circunstância, mas mostra dados incorretos ou resultados de cálculos geralmente não é melhor do que deixar o programa travar" Eu concordo plenamente em geral, embora eu possa imaginar que na aviação provavelmente preferiria ter o avião ainda acompanha os instrumentos mostrando valores errados em comparação com o desligamento do computador do avião. Para todas as aplicações menos críticas, é definitivamente melhor não ocultar erros.
Trilarion
18
@ Trilarion: se um programa para computador de vôo não contém o tratamento adequado de exceções, "consertar isso" fazendo com que os componentes não atinjam exceções é uma abordagem muito equivocada. Se o programa travar, deve haver algum sistema de backup redundante que possa assumir o controle. Se o programa não bater e mostrar uma altura errada, por exemplo, os pilotos podem pensar "está tudo bem" enquanto o avião corre para a próxima montanha.
Doc Brown
7
@ Trilarion: se o computador de vôo mostrar uma altura incorreta e o avião cair devido a isso, também não ajudará (especialmente quando o sistema de backup estiver lá e não for informado que ele precisa assumir o controle). Sistemas de backup para computadores de avião não é uma idéia nova, google para "sistemas de backup de computador de avião", tenho certeza de que engenheiros em todo o mundo sempre construíram sistemas redundantes em qualquer sistema crítico da vida real (e se fosse apenas por não perder seguro).
Doc Brown
4
Este. Se você não pode se dar ao luxo de travar o programa, também não pode dar-lhe silenciosamente respostas erradas. A resposta correta é ter um tratamento de exceção apropriado em todos os casos. Para um site, isso significa um manipulador global que converte erros inesperados em 500. Você também pode ter manipuladores adicionais para situações mais específicas, como ter um try/ catchdentro de um loop se precisar processar para continuar se um elemento falhar.
jpmc26
2
Obter o resultado errado é sempre o pior tipo de falha; isso me lembra a regra sobre otimização: "faça a correção antes de otimizar, porque obter a resposta errada mais rapidamente ainda não beneficia ninguém".
Toby Speight
13

Escrever testes de unidade se torna um pouco mais complexo, pois você precisa testar o sinalizador de exceção a cada vez.

Qualquer função com n parâmetros será mais difícil de testar do que uma função com parâmetros n-1 . Estenda isso ao absurdo, e o argumento é que as funções não devem ter parâmetros, porque isso as torna mais fáceis de testar.

Embora seja uma ótima idéia escrever código fácil de testar, é uma péssima idéia colocar a simplicidade do teste acima da escrita útil para as pessoas que precisam chamá-lo. Se o exemplo da pergunta tiver um comutador que determine se uma exceção será lançada ou não, é possível que o número de chamadores que desejam esse comportamento tenha merecido adicioná-lo à função. Onde está a linha entre complexo e complexo demais, é uma decisão judicial; qualquer pessoa que tente lhe dizer que existe uma linha brilhante que se aplica a todas as situações deve ser vista com desconfiança.

Além disso, se algo der errado, você não gostaria de saber imediatamente?

Isso depende da sua definição de errado. O exemplo da pergunta define errado como "dada uma dimensão menor que zero e shouldThrowExceptionsé verdadeira". Receber uma dimensão se menos de zero não estiver errado quando shouldThrowExceptionsfor falso, porque a opção induz um comportamento diferente. Isso simplesmente não é uma situação excepcional.

O problema real aqui é que a opção foi mal nomeada porque não é descritiva do que faz a função. Se tivesse recebido um nome melhor treatInvalidDimensionsAsZero, você teria feito essa pergunta?

Não deveria ser responsabilidade do interlocutor determinar como continuar?

O chamador não determinar como continuar. Nesse caso, ele é feito antecipadamente, definindo ou limpando shouldThrowExceptionse a função se comporta de acordo com seu estado.

O exemplo é patologicamente simples porque faz um único cálculo e retorna. Se você o tornar um pouco mais complexo, como calcular a soma das raízes quadradas de uma lista de números, lançar exceções pode causar problemas aos chamadores que eles não podem resolver. Se eu passar em uma lista de [5, 6, -1, 8, 12]e a função lança uma exceção sobre o -1, eu não tenho como dizer à função para continuar, porque ela já terá abortado e jogado fora a soma. Se a lista for um conjunto enorme de dados, gerar uma cópia sem números negativos antes de chamar a função pode ser impraticável, por isso sou forçado a dizer com antecedência como os números inválidos devem ser tratados, na forma de "apenas ignore-os" "alterne ou talvez forneça um lambda que é chamado para tomar essa decisão.

Sua lógica / raciocínio é que nosso programa precisa fazer uma coisa, mostrar dados ao usuário. Qualquer outra exceção que não nos impeça de fazê-lo deve ser ignorada. Concordo que eles não devem ser ignorados, mas devem aparecer e ser manipulados pela pessoa apropriada, e não precisam lidar com bandeiras para isso.

Novamente, não existe uma solução única para todos. No exemplo, a função foi presumivelmente escrita para uma especificação que diz como lidar com dimensões negativas. A última coisa que você quer fazer é diminuir a proporção sinal-ruído de seus logs, preenchendo-os com mensagens que dizem "normalmente, uma exceção seria lançada aqui, mas o chamador disse para não se incomodar".

E como um desses programadores muito mais velhos, eu pediria que você gentilmente se afastasse do meu gramado. ;-)

Blrfl
fonte
Concordo que a nomeação e a intenção são extremamente importantes e, nesse caso, o nome do parâmetro adequado pode realmente mudar a tabela, portanto, +1, mas 1. a our program needs to do 1 thing, show data to user. Any other exception that doesn't stop us from doing so should be ignoredmentalidade pode levar a decisão do usuário com base nos dados errados (porque, na verdade, o programa precisa fazer 1 coisa - ajudar o usuário a tomar decisões informadas) 2. casos semelhantes, como bool ExecuteJob(bool throwOnError = false)geralmente são extremamente propensos a erros e levam a códigos difíceis de raciocinar, apenas lendo-os.
Eugene Podskal
@EugenePodskal Acho que a suposição é que "mostrar dados" significa "mostrar dados corretos". O interlocutor não diz que o produto acabado não funciona, apenas que pode estar escrito "errado". Eu teria que ver alguns dados concretos sobre o segundo ponto. Eu tenho um punhado de funções muito usadas no meu projeto atual que possuem uma opção throw / no-throw e não são mais difíceis de raciocinar do que qualquer outra função, mas esse é um ponto de dados.
Blrfl
Boa resposta, acho que essa lógica se aplica a uma gama muito maior de circunstâncias do que apenas os OPs. BTW, o novo cpp tem uma versão throw / no-throw por exatamente essas razões. Eu sei que existem algumas pequenas diferenças, mas ...
drjpizzle 21/02
8

Um código crítico e "normal" de segurança pode levar a idéias muito diferentes de como é a "boa prática". Há muita sobreposição - algumas coisas são arriscadas e devem ser evitadas em ambas -, mas ainda existem diferenças significativas. Se você adicionar um requisito com garantia de resposta, esses desvios serão bastante substanciais.

Eles geralmente se relacionam com o que você esperaria:

  • Para o git, a resposta errada pode ser muito ruim em relação a: demorar / interromper / travar ou até travar (que são efetivamente não-problemas em relação a, digamos, alterar o código de check-in acidentalmente).

    No entanto: para um painel de instrumentos com um cálculo de força-g parado e impedindo que um cálculo de velocidade do ar seja feito talvez inaceitável.

Alguns são menos óbvios:

  • Se você testou bastante , os resultados de primeira ordem (como respostas corretas) não são uma preocupação tão grande em termos relativos. Você sabe que seu teste terá coberto isso. No entanto, se houvesse estado oculto ou fluxo de controle, você não sabe que isso não será a causa de algo muito mais sutil. É difícil descartar os testes.

  • Ser comprovadamente seguro é relativamente importante. Poucos clientes se perguntam se a fonte que estão comprando é segura ou não. Se você está no mercado de aviação, por outro lado ...

Como isso se aplica ao seu exemplo:

Eu não sei. Há vários processos de pensamento que podem ter regras de liderança como "Ninguém lance código de produção" sendo adotados em códigos críticos de segurança que seriam bastante tolos em situações mais comuns.

Alguns se relacionam com a integração, alguns com segurança e talvez outros ... Alguns são bons (eram necessários limites apertados de desempenho / memória) outros são ruins (não lidamos com exceções adequadamente, então é melhor não arriscar). Na maioria das vezes, mesmo sabendo por que eles fizeram isso, realmente não responde à pergunta. Por exemplo, se tem a ver com a facilidade de auditar mais o código do que realmente torná-lo melhor, é uma boa prática? Você realmente não pode dizer. Eles são animais diferentes e precisam tratar de maneira diferente.

Tudo isso dito, parece um pouco suspeito para mim, MAS :

As decisões críticas sobre software e software de segurança provavelmente não devem ser tomadas por estranhos na troca de pilha de engenharia de software. Pode haver uma boa razão para fazer isso, mesmo que faça parte de um sistema ruim. Não leia muito sobre isso, a não ser como "alimento para o pensamento".

drjpizzle
fonte
7

Às vezes, lançar uma exceção não é o melhor método. Além disso, devido ao desenrolamento da pilha, mas às vezes porque a captura de uma exceção é problemática, principalmente ao longo de costuras de idioma ou interface.

Uma boa maneira de lidar com isso é retornar um tipo de dados enriquecido. Esse tipo de dados possui estado suficiente para descrever todos os caminhos felizes e todos os caminhos infelizes. O ponto é que, se você interagir com esta função (membro / global / caso contrário), será forçado a lidar com o resultado.

Dito isto, esse tipo de dados enriquecido não deve forçar a ação. Imagine em seu exemplo da área algo como var area_calc = new AreaCalculator(); var volume = area_calc.CalculateArea(x, y) * z;. Parece útil volumedeve conter a área multiplicada pela profundidade - que pode ser um cubo, cilindro, etc ...

Mas e se o serviço area_calc estivesse inoperante? Em seguida, area_calc .CalculateArea(x, y)retornou um tipo de dados rico contendo um erro. É legal multiplicar isso por z? É uma boa pergunta. Você pode forçar os usuários a manipular a verificação imediatamente. No entanto, isso quebra a lógica com o tratamento de erros.

var area_calc = new AreaCalculator();
var area_result = area_calc.CalculateArea(x, y);
if (area_result.bad())
{
    //handle unhappy path
}
var volume = area_result.value() * z;

vs

var area_calc = new AreaCalculator();
var volume = area_calc.CalculateArea(x, y) * z;
if (volume.bad())
{
    //handle unhappy path
}

A lógica essencialmente é espalhada por duas linhas e dividida pelo tratamento de erros no primeiro caso, enquanto o segundo caso possui toda a lógica relevante em uma linha seguida pelo tratamento de erros.

Nesse segundo caso, volumeé um tipo de dados rico. Não é apenas um número. Isso aumenta o armazenamento e volumeainda precisará ser investigado quanto a uma condição de erro. Além disso, volumepode alimentar outros cálculos antes que o usuário decida manipular o erro, permitindo que ele se manifeste em vários locais diferentes. Isso pode ser bom ou ruim, dependendo das especificidades da situação.

Como alternativa, volumepode ser apenas um tipo de dados simples - apenas um número, mas o que acontece com a condição de erro? Pode ser que o valor converta implicitamente se estiver em uma condição feliz. Se estiver em uma condição infeliz, poderá retornar um valor padrão / erro (para a área 0 ou -1 pode parecer razoável). Como alternativa, isso poderia gerar uma exceção neste lado do limite da interface / idioma.

... foo() {
   var area_calc = new AreaCalculator();
   return area_calc.CalculateArea(x, y) * z;
}
var volume = foo();
if (volume <= 0)
{
    //handle error
}

vs.

... foo() {
   var area_calc = new AreaCalculator();
   return area_calc.CalculateArea(x, y) * z;
}

try { var volume = foo(); }
catch(...)
{
    //handle error
}

Ao distribuir um valor ruim ou possivelmente ruim, ele coloca muito ônus no usuário para validar os dados. Esta é uma fonte de erros, porque, no que diz respeito ao compilador, o valor de retorno é um número inteiro legítimo. Se algo não foi verificado, você descobrirá quando as coisas derem errado. O segundo caso combina o melhor dos dois mundos, permitindo que exceções tratem de caminhos infelizes, enquanto caminhos felizes seguem o processamento normal. Infelizmente, ele força o usuário a lidar com exceções com sabedoria, o que é difícil.

Só para esclarecer que um caminho infeliz é um caso desconhecido da lógica de negócios (o domínio de exceção), deixar de validar é um caminho feliz porque você sabe como lidar com isso pelas regras de negócios (o domínio de regras).

A solução final seria aquela que permita todos os cenários (dentro do razoável).

  • O usuário deve poder consultar uma condição ruim e tratá-la imediatamente
  • O usuário deve poder operar no tipo enriquecido como se o caminho feliz tivesse sido seguido e propagar os detalhes do erro.
  • O usuário deve conseguir extrair o valor do caminho feliz através da conversão (implícita / explícita, conforme for razoável), gerando uma exceção para caminhos infelizes.
  • O usuário deve conseguir extrair o valor do caminho feliz ou usar um padrão (fornecido ou não)

Algo como:

Rich::value_type value_or_default(Rich&, Rich::value_type default_value = ...);
bool bad(Rich&);
...unhappy path report... bad_state(Rich&);
Rich& assert_not_bad(Rich&);
class Rich
{
public:
   typedef ... value_type;

   operator value_type() { assert_not_bad(*this); return ...value...; }
   operator X(...) { if (bad(*this)) return ...propagate badness to new value...; /*operate and generate new value*/; }
}

//check
if (bad(x))
{
    var report = bad_state(x);
    //handle error
}

//rethrow
assert_not_bad(x);
var result = (assert_not_bad(x) + 23) / 45;

//propogate
var y = x * 23;

//implicit throw
Rich::value_type val = x;
var val = ((Rich::value_type)x) + 34;
var val2 = static_cast<Rich::value_type>(x) % 3;

//default value
var defaulted = value_or_default(x);
var defaulted_to = value_or_default(x, 55);
Kain0_0
fonte
@TobySpeight É justo, essas coisas são sensíveis ao contexto e têm seu alcance.
Kain0_0
Acho que o problema aqui são os blocos 'assert_not_bad'. Eu acho que eles acabarão no mesmo lugar que o código original tentou resolver. No teste, é necessário notar que, se realmente são afirmações, devem ser retiradas antes da produção em uma aeronave real. caso contrário, alguns grandes pontos.
drjpizzle 20/02
@drjpizzle Eu diria que, se for importante o suficiente para adicionar uma proteção para teste, é importante deixá-la no lugar durante a produção. A presença do próprio guarda implica dúvida. Se você duvida do código o suficiente para protegê-lo durante o teste, está duvidando por um motivo técnico. ou seja, a condição pode / ocorre realisticamente. A execução de testes não prova que a condição nunca será atingida na produção. Isso significa que existe uma condição conhecida, que pode ocorrer, que precisa ser tratada em algum lugar de alguma forma. Eu acho que como é tratado, é o problema.
Kain0_0
3

Eu vou responder do ponto de vista do C ++. Tenho certeza de que todos os conceitos principais são transferíveis para c #.

Parece que seu estilo preferido é "sempre lance exceções":

int CalculateArea(int x, int y) {
    if (x < 0 || y < 0) {
        throw Exception("negative side lengths");
    }
    return x * y;
}

Isso pode ser um problema para o código C ++ porque o tratamento de exceções é pesado - faz com que o caso de falha seja executado lentamente e aloca memória (que às vezes nem sequer está disponível) e geralmente torna as coisas menos previsíveis. O peso pesado de EH é uma das razões pelas quais você ouve pessoas dizendo coisas como "Não use exceções para controlar o fluxo".

Portanto, algumas bibliotecas (como <filesystem>) usam o que o C ++ chama de "API dupla" ou o que o C # chama de Try-Parsepadrão (obrigado Peter pela dica!)

int CalculateArea(int x, int y) {
    if (x < 0 || y < 0) {
        throw Exception("negative side lengths");
    }
    return x * y;
}

bool TryCalculateArea(int x, int y, int& result) {
    if (x < 0 || y < 0) {
        return false;
    }
    result = x * y;
    return true;
}

int a1 = CalculateArea(x, y);
int a2;
if (TryCalculateArea(x, y, a2)) {
    // use a2
}

Você pode ver o problema com as "APIs duplas" imediatamente: muita duplicação de código, nenhuma orientação para os usuários sobre qual API é a "correta" a ser usada, e o usuário deve fazer uma escolha difícil entre mensagens de erro úteis ( CalculateArea) e speed ( TryCalculateArea) porque a versão mais rápida pega nossa "negative side lengths"exceção útil e a torna inútil false- "algo deu errado, não me pergunte o que ou onde". (Alguns dupla APIs usar um tipo de erro mais expressivo, como int errnoou C ++ 's std::error_code, mas que ainda não lhe diz onde o erro ocorreu - só que ele fez ocorrer em algum lugar.)

Se você não consegue decidir como o seu código deve se comportar, sempre pode levar a decisão até o chamador!

template<class F>
int CalculateArea(int x, int y, F errorCallback) {
    if (x < 0 || y < 0) {
        return errorCallback(x, y, "negative side lengths");
    }
    return x * y;
}

int a1 = CalculateArea(x, y, [](auto...) { return 0; });
int a2 = CalculateArea(x, y, [](int, int, auto msg) { throw Exception(msg); });
int a3 = CalculateArea(x, y, [](int, int, auto) { return x * y; });

Isso é essencialmente o que seu colega de trabalho está fazendo; exceto que ele está fatorando o "manipulador de erros" em uma variável global:

std::function<int(const char *)> g_errorCallback;

int CalculateArea(int x, int y) {
    if (x < 0 || y < 0) {
        return g_errorCallback("negative side lengths");
    }
    return x * y;
}

g_errorCallback = [](auto) { return 0; };
int a1 = CalculateArea(x, y);
g_errorCallback = [](const char *msg) { throw Exception(msg); };
int a2 = CalculateArea(x, y);

Mover parâmetros importantes de parâmetros explícitos de função para o estado global é quase sempre uma má ideia. Eu não recomendo. (O fato de não ser um estado global no seu caso, mas simplesmente um estado membro em toda a instância atenua um pouco a maldade, mas não muito.)

Além disso, seu colega de trabalho está desnecessariamente limitando o número de possíveis comportamentos de manipulação de erros. Em vez de permitir qualquer lambda de manipulação de erros, ele decidiu apenas dois:

bool g_errorViaException;

int CalculateArea(int x, int y) {
    if (x < 0 || y < 0) {
        return g_errorViaException ? throw Exception("negative side lengths") : 0;
    }
    return x * y;
}

g_errorViaException = false;
int a1 = CalculateArea(x, y);
g_errorViaException = true;
int a2 = CalculateArea(x, y);

Este é provavelmente o "ponto azedo" de qualquer uma dessas estratégias possíveis. Você tirou toda a flexibilidade do usuário final, forçando-o a usar um de seus exatamente dois retornos de chamada de tratamento de erros; e você tem todos os problemas do estado global compartilhado; e você ainda está pagando por esse ramo condicional em qualquer lugar.

Finalmente, uma solução comum em C ++ (ou qualquer linguagem com compilação condicional) seria forçar o usuário a tomar a decisão de todo o programa, globalmente, em tempo de compilação, para que o caminho de código não utilizado possa ser totalmente otimizado:

int CalculateArea(int x, int y) {
    if (x < 0 || y < 0) {
#ifdef NEXCEPTIONS
        return 0;
#else
        throw Exception("negative side lengths");
#endif
    }
    return x * y;
}

// Now these two function calls *must* have the same behavior,
// which is a nice property for a program to have.
// Improves understandability.
//
int a1 = CalculateArea(x, y);
int a2 = CalculateArea(x, y);

Um exemplo de algo que funciona dessa maneira é a assertmacro em C e C ++, que condiciona seu comportamento na macro do pré-processador NDEBUG.

Quuxplusone
fonte
Se retornar um std::optionalde TryCalculateArea(), é simples unificar a implementação de ambas as partes da interface dupla em um único modelo de função com um sinalizador de tempo de compilação.
Deduplicator
@ Reduplicador: Talvez com um std::expected. Com apenas std::optional, a menos que eu não entenda sua solução proposta, ela ainda sofreria com o que eu disse: o usuário deve fazer uma escolha difícil entre mensagens de erro e velocidade, porque a versão mais rápida pega nossa "negative side lengths"exceção útil e a transforma em inútil false- " algo deu errado, não me pergunte o que ou onde ".
Quuxplusone
É por isso que libc ++ <filesystem> realmente faz algo muito próximo ao padrão de colegas de trabalho do OP: ele std::error_code *ecpercorre todos os níveis da API e, na parte inferior, faz o equivalente moral de if (ec == nullptr) throw something; else *ec = some error code. (Ele abstrai o real ifem algo chamado ErrorHandler, mas é a mesma idéia básica.)
Quuxplusone
Bem, isso seria uma opção para manter as informações de erro estendidas sem jogar. Pode ser apropriado ou não valer o custo adicional potencial.
Deduplicator
11
Tantos bons pensamentos contidos nesta resposta ... Definitivamente precisa de mais
votos positivos
1

Eu acho que deve ser mencionado de onde seu colega obteve o padrão deles.

Hoje em dia, C # tem o padrão TryGet public bool TryThing(out result). Isso permite que você obtenha seu resultado, enquanto ainda informa se esse resultado é mesmo um valor válido. (Por exemplo, todos os intvalores são resultados válidos para Math.sum(int, int), mas se o valor exceder, esse resultado específico pode ser lixo). Este é um padrão relativamente novo.

Antes da outpalavra - chave, você tinha que lançar uma exceção (cara, e o chamador tinha que capturá-la ou matar o programa inteiro), criar uma estrutura especial (classe antes da classe ou genéricos realmente ser uma coisa) para cada resultado para representar o valor e possíveis erros (demorado para criar e inchar software) ou retornar um valor "erro" padrão (que pode não ter sido um erro).

A abordagem que seu colega usa fornece a eles o privilégio de falha antecipada de exceções ao testar / depurar novos recursos, enquanto oferece segurança e desempenho em tempo de execução (o desempenho era uma questão crítica o tempo todo ~ 30 anos atrás) de apenas retornar um valor de erro padrão. Agora, esse é o padrão em que o software foi escrito e o padrão esperado avançando, por isso é natural continuar fazendo dessa maneira, mesmo que haja maneiras melhores agora. Muito provavelmente, esse padrão foi herdado da era do software, ou um padrão em que suas faculdades nunca cresceram (é difícil quebrar os velhos hábitos).

As outras respostas já abordam por que isso é considerado uma má prática, por isso, acabarei recomendando que você leia o padrão TryGet (talvez também encapsule o que as promessas que um objeto deve fazer a quem está chamando).

Tezra
fonte
Antes da outpalavra - chave, você escreveria uma boolfunção que leva um ponteiro para o resultado, ou seja, um refparâmetro. Você poderia fazer isso no VB6 em 1998. A outpalavra-chave apenas compra a certeza em tempo de compilação de que o parâmetro é atribuído quando a função retorna, isso é tudo o que existe. Ele é um teste padrão agradável e útil embora.
Mathieu Guindon
@MathieuGuindon Yeay, mas o GetTry ainda não era um padrão bem conhecido / estabelecido e, mesmo que fosse, não tenho muita certeza de que teria sido usado. Afinal, parte da liderança do Y2K era que armazenar algo maior que 0-99 era inaceitável.
Tezra 8/02
0

Há momentos em que você deseja fazer a abordagem dele, mas eu não consideraria a situação "normal". A chave para determinar em que caso você está é:

Sua lógica / raciocínio é que nosso programa precisa fazer uma coisa, mostrar dados ao usuário. Qualquer outra exceção que não nos impeça de fazê-lo deve ser ignorada.

Verifique os requisitos. Se seus requisitos realmente disserem que você tem um trabalho, que é mostrar dados ao usuário, ele está certo. No entanto, na minha experiência, na maioria das vezes o usuário também se importa com os dados mostrados. Eles querem os dados corretos. Alguns sistemas só querem falhar silenciosamente e permitem que um usuário descubra que algo deu errado, mas eu os consideraria a exceção à regra.

A principal pergunta que eu faria após uma falha é "O sistema está em um estado em que as expectativas do usuário e os invariantes do software são válidos?" Se assim for, então por todos os meios apenas retorne e continue. Na prática, isso não é o que acontece na maioria dos programas.

Quanto ao próprio sinalizador, o sinalizador de exceções geralmente é considerado cheiro de código, porque o usuário precisa saber de alguma forma em que modo o módulo está para entender como a função funciona. Se está em !shouldThrowExceptionsmodo, o usuário precisa saber que eles são responsáveis pela detecção de erros e manter as expectativas e invariantes quando eles ocorrem. Eles também são responsáveis ​​imediatamente, na linha em que a função é chamada. Uma bandeira como essa geralmente é altamente confusa.

No entanto, isso acontece. Considere que muitos processadores permitem alterar o comportamento do ponto flutuante no programa. Um programa que deseja ter padrões mais relaxados pode fazê-lo simplesmente alterando um registro (que é efetivamente um sinalizador). O truque é que você deve ser muito cauteloso, para evitar pisar acidentalmente nos dedos dos outros. O código geralmente verifica o sinalizador atual, define-o para a configuração desejada, realiza operações e, em seguida, volta a defini-lo. Dessa forma, ninguém fica surpreso com a mudança.

Cort Ammon
fonte
0

Este exemplo específico tem um recurso interessante que pode afetar as regras ...

CalculateArea(int x, int y)
{
    if(x < 0 || y < 0)
    {
        if(shouldThrowExceptions) 
            throwException;
        else
            return 0;
    }
}

O que vejo aqui é uma verificação de pré - condição . Uma falha na verificação de pré-condição implica um bug mais alto na pilha de chamadas. Assim, a questão é: esse código é responsável por relatar erros localizados em outro lugar?

Parte da tensão aqui é atribuída ao fato de que essa interface exibe obsessão primitiva - xe ypresume-se que represente medidas reais de comprimento. Em um contexto de programação em que tipos específicos de domínios são uma escolha razoável, na verdade, aproximaríamos a verificação de pré-condição da fonte dos dados - em outras palavras, aumentaria a responsabilidade pela integridade dos dados na pilha de chamadas, onde temos um melhor senso para o contexto.

Dito isto, não vejo nada de fundamentalmente errado em ter duas estratégias diferentes para gerenciar uma verificação com falha. Minha preferência seria usar a composição para determinar qual estratégia está sendo usada; o sinalizador do recurso seria usado na raiz da composição, e não na implementação do método da biblioteca.

// Configurable dependencies
AreaCalculator(PreconditionFailureStrategy strategy)

CalculateArea(int x, int y)
{
    if (x < 0 || y < 0) {
        return this.strategy.fail(0);
    }
    // ...
}

Eles trabalharam em aplicativos críticos que lidam com a aviação, onde o sistema não podia cair.

O Conselho Nacional de Trânsito e Segurança é muito bom; Eu posso sugerir técnicas alternativas de implementação para os barbas cinzentas, mas não estou inclinado a discutir com elas sobre o design de anteparas no subsistema de relatórios de erros.

Mais amplamente: qual é o custo para os negócios? É muito mais barato travar um site do que um sistema essencial para a vida.

VoiceOfUnreason
fonte
Eu gosto da sugestão de uma maneira alternativa de manter a flexibilidade desejada enquanto ainda está se modernizando.
drjpizzle 18/02
-1

Os métodos lidam com exceções ou não, não há necessidade de sinalizadores em idiomas como C #.

public int Method1()
{
  ...code

 return 0;
}

Se algo der errado no código ... essa exceção precisará ser tratada pelo chamador. Se ninguém lida com o erro, o programa será encerrado.

public int Method1()
{
try {  
...code
}
catch {}
 ...Handle error 
}
return 0;
}

Nesse caso, se algo de ruim acontecer no código ..., o Method1 está lidando com o problema e o programa deve continuar.

Você lida com as exceções. Certamente você pode ignorá-los pegando e não fazendo nada. Mas, garanto que você esteja ignorando apenas certos tipos específicos de exceções que você pode esperar que ocorram. Ignorar ( exception ex) é perigoso porque algumas exceções que você não deseja ignorar, como exceções do sistema relacionadas à falta de memória, etc.

Jon Raynor
fonte
3
A configuração atual que o OP postou refere-se à decisão de lançar uma exceção de boa vontade. O código do OP não leva à deglutição indesejada de coisas como exceções de falta de memória. De qualquer forma, a afirmação de que as exceções travam o sistema implica que a base de código não captura exceções e, portanto, não engole nenhuma exceção; aqueles que foram e não foram lançados intencionalmente pela lógica de negócios da OP.
Flater
-1

Essa abordagem quebra a filosofia "falhe rápido, falhe muito".

Por que você deseja falhar rapidamente:

  • Quanto mais rápido você falha, mais próximo o sintoma visível da falha é a causa real da falha. Isso facilita muito a depuração - na melhor das hipóteses, você tem a linha de erro na primeira linha do seu rastreamento de pilha.
  • Quanto mais rápido você falha (e captura o erro adequadamente), menos provável é que você confunda o restante do seu programa.
  • Quanto mais você falha (ou seja, lança uma exceção em vez de apenas retornar um código "-1" ou algo assim), mais provável é que o chamador realmente se preocupe com o erro e não continue trabalhando com valores errados.

Desvantagens de não falhar rápido e difícil:

  • Se você evitar falhas visíveis, ou seja, fingir que está tudo bem, você tende a tornar incrivelmente difícil encontrar o erro real. Imagine que o valor de retorno do seu exemplo faça parte de alguma rotina que calcula a soma de 100 áreas; ou seja, chamando essa função 100 vezes e somando os valores de retorno. Se você suprimir silenciosamente o erro, não há como descobrir onde ocorre o erro real; e todos os cálculos a seguir estarão silenciosamente errados.
  • Se você atrasar a falha (retornando um valor de retorno impossível como "-1" para uma área), você aumenta a probabilidade de o chamador de sua função simplesmente não se preocupar com isso e se esquece de lidar com o erro; mesmo que eles tenham as informações sobre a falha em mãos.

Por fim, o tratamento de erros com base em exceção real tem o benefício de que você pode fornecer uma mensagem ou objeto de erro "fora da banda", pode facilmente conectar o log de erros, alertar etc. sem escrever uma única linha extra no código de domínio .

Portanto, existem não apenas razões técnicas simples, mas também razões de "sistema" que tornam muito útil a falha rápida.

No final do dia, não falhar com força e rapidez nos nossos tempos atuais, onde o tratamento de exceções é leve e muito estável, é apenas meio criminoso. Entendo perfeitamente de onde vem o pensamento de que é bom suprimir exceções, mas não é mais aplicável.

Especialmente no seu caso particular, onde você ainda dá a opção de lançar exceções ou não: isso significa que o chamador deve decidir de qualquer maneira . Portanto, não há nenhuma desvantagem em fazer com que o chamador capture a exceção e lide com ela adequadamente.

Um ponto que aparece em um comentário:

Já foi apontado em outras respostas que falhar com rapidez e dificuldade não é desejável em um aplicativo crítico quando o hardware em que você está executando é um avião.

Falha rápida e difícil não significa que seu aplicativo inteiro trava. Isso significa que, no ponto em que o erro ocorre, está falhando localmente. No exemplo do OP, o método de baixo nível que calcula alguma área não deve substituir silenciosamente um erro por um valor errado. Deve falhar claramente.

Alguns chamadores da cadeia obviamente precisam capturar esse erro / exceção e manipulá-lo adequadamente. Se esse método foi usado em um avião, isso provavelmente deve acender algum LED de erro ou, pelo menos, exibir "área de cálculo de erro" em vez de uma área errada.

AnoE
fonte
3
Já foi apontado em outras respostas que falhar com rapidez e dificuldade não é desejável em um aplicativo crítico quando o hardware em que você está executando é um avião.
HAEM
11
@HAEM, então isso é um mal-entendido sobre o que significa falhar com rapidez e dificuldade. Eu adicionei um parágrafo sobre isso à resposta.
AnoE
Mesmo que a intenção não seja falhar "com tanta força", ainda é visto como arriscado brincar com esse tipo de fogo.
drjpizzle 18/02
Esse é o ponto, @drjpizzle. Se você está acostumado a falhar muito rápido, não é "arriscado" ou "brincando com esse tipo de fogo" na minha experiência. Au contraire. Isso significa que você se acostuma a pensar sobre "o que aconteceria se eu tivesse uma exceção aqui", onde "aqui" significa em toda parte e você tende a saber se o local em que está programando no momento terá um grande problema ( acidente de avião, o que for nesse caso). Brincar com fogo seria esperar que tudo estivesse indo bem, e todos os componentes, em dúvida, fingindo que está tudo bem ...
AnoE