Retornar valor mágico, lançar exceção ou retornar falso em caso de falha?


83

Às vezes, acabo tendo que escrever um método ou propriedade para uma biblioteca de classes para a qual não é excepcional não ter uma resposta real, mas uma falha. Algo não pode ser determinado, não está disponível, não foi encontrado, não é possível no momento ou não há mais dados disponíveis.

Eu acho que existem três soluções possíveis para uma situação relativamente não excepcional que indique falha no C # 4:

  • retornar um valor mágico que não tem significado de outra forma (como nulle -1);
  • lançar uma exceção (por exemplo KeyNotFoundException);
  • return falsee forneça o valor de retorno real em um outparâmetro (como Dictionary<,>.TryGetValue).

Portanto, as perguntas são: em que situação não excepcional devo lançar uma exceção? E se eu não deveria jogar: quando está retornando um valor mágico, perferido acima da implementação de um Try*método com um outparâmetro ? (Para mim, o outparâmetro parece sujo e é mais trabalhoso usá-lo adequadamente.)

Estou procurando respostas factuais, como respostas que envolvam diretrizes de design (não conheço nenhum Try*método), usabilidade (como solicito uma biblioteca de classes), consistência com a BCL e legibilidade.


Na biblioteca de classes base do .NET Framework, todos os três métodos são usados:

Observe que, como Hashtablefoi criado no momento em que não havia genéricos em C #, ele usa objecte pode, portanto, retornar nullcomo um valor mágico. Mas com os genéricos, as exceções são usadas Dictionary<,>e, inicialmente, não existiam TryGetValue. Aparentemente, as idéias mudam.

Obviamente, o Item- TryGetValuee Parse- TryParsedualidade está lá por uma razão, assim que eu supor que lançar exceções para falhas não-excepcionais está em C # 4 não feito . No entanto, os Try*métodos nem sempre existiam, mesmo quando Dictionary<,>.Itemexistiam.


6
"exceções significam bugs". pedir um dicionário para um item inexistente é um bug; solicitar que um fluxo leia dados quando é um EOF toda vez que você usa um fluxo. (isto resume a tempo uma resposta lindamente formatada eu não tive a chance de enviar :))
KutuluMike

6
Não é que eu ache sua pergunta muito ampla, é que não é uma pergunta construtiva. Não há resposta canônica, pois as respostas já estão sendo exibidas.
George Stocker

2
@GeorgeStocker Se a resposta fosse direta e óbvia, eu não teria feito a pergunta. O fato de as pessoas poderem argumentar por que uma determinada escolha é preferível de qualquer ponto de vista (como desempenho, legibilidade, usabilidade, manutenibilidade, diretrizes de design ou consistência) a torna inerentemente não-canônica, mas responsável pela minha satisfação. Todas as perguntas podem ser respondidas de alguma forma subjetiva. Aparentemente, você espera que uma pergunta tenha respostas semelhantes para que seja uma boa pergunta.
Daniel AA Pelsmaeker

3
@Virtlink: George é um moderador eleito pela comunidade que oferece grande parte de seu tempo para ajudar a manter o Stack Overflow. Ele afirmou por que encerrou a pergunta e o FAQ o apoia.
Eric J.

15
A questão pertence aqui, não porque é subjetiva, mas porque é conceitual. Regra geral: se você estiver sentado na frente da codificação IDE, pergunte no Stack Overflow. Se você estiver diante de um brainstorming de quadro branco, pergunte aqui em Programadores.
Robert Harvey

Respostas:


56

Não acho que seus exemplos sejam realmente equivalentes. Existem três grupos distintos, cada um com sua própria justificativa para seu comportamento.

  1. O valor mágico é uma boa opção quando existe uma condição "até", como por exemplo, StreamReader.Readou quando existe um valor simples de usar que nunca será uma resposta válida (-1 para IndexOf).
  2. Lance exceção quando a semântica da função é que o chamador tem certeza de que funcionará. Nesse caso, uma chave inexistente ou um formato duplo incorreto é realmente excepcional.
  3. Use um parâmetro out e retorne um bool se a semântica for investigar se a operação é possível ou não.

Os exemplos que você fornece são perfeitamente claros para os casos 2 e 3. Para os valores mágicos, pode-se argumentar se essa é uma boa decisão de design ou não em todos os casos.

O NaNretornado por Math.Sqrté um caso especial - segue o padrão de ponto flutuante.


10
Discordo do número 1. Os valores mágicos nunca são uma boa opção, pois exigem que o próximo codificador saiba o significado do valor mágico. Eles também prejudicam a legibilidade. Não consigo pensar em nenhum caso em que o uso de um valor mágico seja melhor que o padrão Try.
0101010

1
Mas e a Either<TLeft, TRight>mônada?
Sara

2
@ 0b101010: apenas passou algum tempo olhando para cima como streamreader.read pode retornar com segurança -1 ...
jmoreno

33

Você está tentando comunicar ao usuário da API o que ele precisa fazer. Se você lançar uma exceção, não há nada que os force a capturá-la, e somente a leitura da documentação permitirá que eles saibam quais são todas as possibilidades. Pessoalmente, acho lento e tedioso cavar a documentação para encontrar todas as exceções que um determinado método pode lançar (mesmo que seja no sentido sensato, ainda preciso copiá-las manualmente).

Um valor mágico ainda requer que você leia a documentação e, possivelmente, faça referência a alguma consttabela para decodificar o valor. Pelo menos, não possui a sobrecarga de uma exceção para o que você chama de ocorrência não excepcional.

É por isso que, embora os outparâmetros às vezes sejam mal vistos, eu prefiro esse método, com a Try...sintaxe. É canônico .NET e sintaxe C #. Você está comunicando ao usuário da API que ele precisa verificar o valor de retorno antes de usar o resultado. Você também pode incluir um segundo outparâmetro com uma mensagem de erro útil, que novamente ajuda na depuração. É por isso que voto na solução Try...with outparameter.

Outra opção é retornar um objeto "resultado" especial, embora eu ache isso muito mais entediante:

interface IMyResult
{
    bool Success { get; }
    // Only access this if Success is true
    MyOtherClass Result { get; }
    // Only access this if Success is false
    string ErrorMessage { get; }
}

Então sua função parece correta, pois possui apenas parâmetros de entrada e retorna apenas uma coisa. É que a única coisa que retorna é uma tupla.

De fato, se você gosta desse tipo de coisa, pode usar as novas Tuple<>classes que foram introduzidas no .NET 4. Pessoalmente, não gosto do fato de que o significado de cada campo é menos explícito porque não posso dar Item1e Item2nomes úteis.


3
Pessoalmente, costumo usar um recipiente resultado como a sua IMyResultcausa é possível comunicar um resultado mais complexo do que apenas trueou falseou o valor do resultado. Try*()só é útil para coisas simples, como conversão de string para int.
this.myself

1
Bom post. Para mim, eu prefiro o idioma da estrutura de resultados que você descreveu acima, em vez de dividir entre os valores "return" e os parâmetros "out". Mantém limpo e arrumado.
Ocean Airdrop

2
O problema com o segundo parâmetro de saída é quando você tem 50 funções em um programa complexo. Como você comunica essa mensagem de erro de volta ao usuário? Lançar uma exceção é muito mais simples do que ter camadas de verificação de erros. Quando você conseguir um, jogue-o e não importa o quão profundo você seja.
rola

@rolls - Quando usamos objetos de saída, supomos que o chamador imediato faça o que quiser com a saída: trate-o localmente, ignore-o ou borbulhe jogando o que está envolvido em uma exceção. É a melhor das duas palavras - o chamador pode ver claramente todas as saídas possíveis (com enumerações, etc.), pode decidir o que fazer com o erro e não precisa tentar capturar cada chamada. Se você espera manipular o resultado imediatamente ou ignorá-lo, objetos de retorno são mais fáceis. Se você deseja lançar todos os erros para as camadas superiores, as exceções são mais fáceis.
Drizin

2
Isso está apenas reinventando exceções verificadas no C # de uma maneira muito mais entediante do que as exceções verificadas no Java.
Inverno

17

Como seus exemplos já mostram, cada um desses casos deve ser avaliado separadamente e há um espectro considerável de cinza entre "circunstâncias excepcionais" e "controle de fluxo", especialmente se o seu método for reutilizável e pode ser usado em padrões bastante diferentes do que foi originalmente projetado. Não espere que todos aqui concordemos com o que "não excepcional" significa, especialmente se você discutir imediatamente a possibilidade de usar "exceções" para implementá-lo.

Também podemos não concordar com o design que torna o código mais fácil de ler e manter, mas assumirei que o designer da biblioteca tem uma visão pessoal clara disso e só precisa equilibrá-lo com as outras considerações envolvidas.

Resposta curta

Siga seus sentimentos, exceto quando estiver criando métodos bem rápidos e espere uma possibilidade de reutilização imprevista.

Resposta longa

Cada futuro chamador pode traduzir livremente entre códigos de erro e exceções, conforme desejar nas duas direções; isso torna as duas abordagens de design quase equivalentes, exceto para desempenho, facilidade de depuração e alguns contextos restritos de interoperabilidade. Isso geralmente se resume ao desempenho, então vamos nos concentrar nisso.

  • Como regra geral, espere que lançar uma exceção seja 200x mais lento que um retorno regular (na realidade, há uma variação significativa nisso).

  • Como outra regra prática, lançar uma exceção geralmente pode permitir um código muito mais limpo em comparação com os valores mágicos mais brutos, porque você não depende do programador converter o código de erro em outro código de erro, pois ele percorre várias camadas do código do cliente em direção a um ponto em que existe contexto suficiente para lidar com isso de maneira consistente e adequada. (Caso especial: nulltende a se sair melhor aqui do que outros valores mágicos por causa de sua tendência a se traduzir automaticamente em um NullReferenceExceptioncaso de alguns, mas não todos os tipos de defeitos; geralmente, mas nem sempre, muito perto da fonte do defeito. )

Então, qual é a lição?

Para uma função chamada apenas algumas vezes durante a vida útil de um aplicativo (como a inicialização do aplicativo), use qualquer coisa que ofereça um código mais limpo e fácil de entender. O desempenho não pode ser uma preocupação.

Para uma função descartável, use qualquer coisa que forneça um código mais limpo. Em seguida, faça alguns perfis (se necessário) e altere as exceções nos códigos de retorno se eles estiverem entre os principais gargalos suspeitos com base nas medições ou na estrutura geral do programa.

Para uma função reutilizável cara, use qualquer coisa que forneça um código mais limpo. Se você basicamente sempre precisa passar por uma ida e volta da rede ou analisar um arquivo XML em disco, a sobrecarga de gerar uma exceção provavelmente é insignificante. É mais importante não perder detalhes de nenhuma falha, nem mesmo acidentalmente, do que retornar de uma "falha não excepcional" extremamente rápido.

Uma função lean reutilizável requer mais reflexão. Ao empregar exceções, você está forçando algo como um abrandamento de 100 vezes nos chamadores que verão a exceção em metade de suas (muitas) chamadas, se o corpo da função executar muito rapidamente. As exceções ainda são uma opção de design, mas você precisará fornecer uma alternativa de baixo custo para os chamadores que não podem pagar por isso. Vejamos um exemplo.

Você lista um ótimo exemplo de Dictionary<,>.Itemque, vagamente, mudou de retornar nullvalores para jogar KeyNotFoundExceptionentre o .NET 1.1 e o .NET 2.0 (somente se você estiver disposto a considerar o Hashtable.Itemseu precursor não genérico prático). A razão dessa "mudança" não deixa de interessar aqui. A otimização do desempenho dos tipos de valor (sem mais boxe) transformou o valor mágico original ( null) em uma não opção; outparâmetros trariam apenas uma pequena parte do custo de desempenho. Essa última consideração de desempenho é completamente insignificante em comparação com a sobrecarga de lançar a KeyNotFoundException, mas o design da exceção ainda é superior aqui. Por quê?

  • Os parâmetros ref / out incorrem em seus custos todas as vezes, não apenas no caso de "falha"
  • Qualquer pessoa que se preocupe pode ligar Containsantes de qualquer chamada para o indexador, e esse padrão é totalmente natural. Se um desenvolvedor quiser, mas se esquecer de ligar Contains, nenhum problema de desempenho poderá surgir; KeyNotFoundExceptioné alto o suficiente para ser notado e corrigido.

Acho que o 200x está otimista quanto ao desempenho das exceções ... consulte a seção "Desempenho e tendências", logo blogs.msdn.com/b/cbrumme/archive/2003/10/01/51524.aspx , antes dos comentários.
Gbjbaanb

@gbjbaanb - Bem, talvez. Esse artigo usa uma taxa de falha de 1% para discutir o tópico que não é uma estimativa totalmente diferente. Meus próprios pensamentos e medições vagamente lembradas seriam do contexto do C ++ implementado em tabela (consulte a Seção 5.4.1.2 deste relatório , onde um problema é que a primeira exceção desse tipo provavelmente começa com uma falha de página (drástica e variável .. mas amortizados uma sobrecarga de tempo) mas eu vou fazer e postar uma experiência com .NET 4 e, possivelmente, ajustar este valor estádio já salientar a variância.
Jirka Hanika

O custo dos parâmetros de ref / out é alto ? Como assim? E telefonar Containsantes de uma chamada para um indexador pode causar uma condição de corrida que não precisa estar presente TryGetValue.
Daniel AA Pelsmaeker

@gbjbaanb - Experimentado concluído. Eu era preguiçoso e usava mono no Linux. Exceções me deram ~ 563000 jogadas em 3 segundos. Retornos me deram ~ 10900000 retornos em 3 segundos. Isso é 1:20, nem 1: 200. Eu ainda recomendo pensar 1: 100+ para qualquer código mais realista. (fora a variante param, como eu havia previsto, tinha custos insignificantes - na verdade, suspeito que o tremor provavelmente tenha otimizado completamente a ligação no meu exemplo minimalista se não houvesse nenhuma exceção, independentemente da assinatura.)
Jirka Hanika

@Virtlink - Concordou com a segurança do encadeamento em geral, mas, como você se referiu ao .NET 4 em particular, use ConcurrentDictionarypara acesso multithread e Dictionarypara acesso thread único para desempenho ideal. Ou seja, não usar `` Contém` NÃO torna o encadeamento de código seguro com essa Dictionaryclasse específica .
Jirka Hanika

10

Qual é a melhor coisa a fazer em uma situação relativamente não excepcional para indicar falha e por quê?

Você não deve permitir falhas.

Eu sei, é ondulado e idealista, mas me ouça. Ao fazer o design, há vários casos em que você tem a oportunidade de favorecer uma versão que não possui modos de falha. Em vez de ter um 'FindAll' que falha, o LINQ usa uma cláusula where que simplesmente retorna um enumerável vazio. Em vez de ter um objeto que precisa ser inicializado antes de ser usado, deixe o construtor inicializar o objeto (ou inicializar quando o não inicializado for detectado). A chave é remover a ramificação de falha no código do consumidor. Esse é o problema, então concentre-se nele.

Outra estratégia para isso é o KeyNotFoundsscenario. Em quase todas as bases de código em que trabalhei desde o 3.0, existe algo como este método de extensão:

public static class DictionaryExtensions {
    public static V GetValue<K, V>(this IDictionary<K, V> arg, K key, Func<K,V> ifNotFound) {
        if (!arg.ContainsKey(key)) {
            return ifNotFound(key);
        }

        return arg[key];
    }
}

Não existe um modo de falha real para isso. ConcurrentDictionarypossui um similar GetOrAddincorporado.

Tudo isso dito, sempre haverá momentos em que isso é inevitável. Todos os três têm o seu lugar, mas eu preferiria a primeira opção. Apesar de tudo o que é feito com o perigo de nulo, é bem conhecido e se encaixa em muitos dos cenários 'item não encontrado' ou 'resultado não aplicável' que compõem o conjunto "falha não excepcional". Especialmente quando você cria tipos de valor anuláveis, o significado de 'isso pode falhar' é muito explícito no código e difícil de esquecer / estragar.

A segunda opção é boa o suficiente quando o usuário faz algo estúpido. Dá a você uma string com o formato errado, tenta definir a data para 42 de dezembro ... algo que você deseja que as coisas explodam de maneira rápida e espetacular durante o teste, para que o código incorreto seja identificado e corrigido.

A última opção é uma que eu não gosto cada vez mais. Os parâmetros de saída são estranhos e tendem a violar algumas das práticas recomendadas ao criar métodos, como focar em uma coisa e não ter efeitos colaterais. Além disso, o parâmetro out geralmente é significativo apenas durante o sucesso. Dito isto, eles são essenciais para certas operações que geralmente são limitadas por preocupações de concorrência ou por considerações de desempenho (onde você não deseja fazer uma segunda viagem ao DB, por exemplo).

Se o valor de retorno e o parâmetro out não forem triviais, a sugestão de Scott Whitlock sobre um objeto de resultado é a preferida (como a Matchclasse Regex ).


7
Pet peeve here: os outparâmetros são completamente ortogonais à questão dos efeitos colaterais. Modificar um refparâmetro é um efeito colateral, e modificar o estado de um objeto que você passa através de um parâmetro de entrada é um efeito colateral, mas um outparâmetro é apenas uma maneira incômoda de fazer uma função retornar mais de um valor. Não há efeito colateral, apenas vários valores de retorno.
Scott Whitlock

Eu disse que tendem , por causa de como as pessoas as usam. Como você diz, eles são simplesmente múltiplos valores de retorno.
Telastyn

Mas se você não gosta de parâmetros e usa exceções para explodir de maneira espetacular quando o formato está errado ... como você lida com o caso em que o formato é usado pelo usuário? Em seguida, um usuário pode explodir as coisas ou incorrer na penalidade de desempenho ao lançar e, em seguida, capturar uma exceção. Direito?
Daniel AA Pelsmaeker

@virtlink por ter um método de validação distinto. Você precisa de qualquer maneira para fornecer mensagens adequadas para a interface do usuário antes de apresentá-lo.
Telastyn

1
Há um padrão legítimo para os parâmetros out, e essa é uma função que possui sobrecargas que retornam tipos diferentes. A resolução de sobrecarga não funcionará para tipos de retorno, mas para parâmetros de saída.
Robert Harvey

2

Sempre prefira lançar uma exceção. Ele possui uma interface uniforme entre todas as funções que podem falhar e indica falhas o mais ruidosamente possível - uma propriedade muito desejável.

Observe que Parsee TryParsenão são realmente a mesma coisa além dos modos de falha. O fato de que TryParsetambém pode retornar o valor é um tanto ortogonal, na verdade. Considere a situação em que, por exemplo, você está validando alguma entrada. Na verdade, você não se importa com o valor, desde que seja válido. E não há nada de errado em oferecer um tipo de IsValidFor(arguments)função. Mas nunca pode ser o principal modo de operação.


4
Se você estiver lidando com um grande número de chamadas para um método, as exceções podem ter um enorme efeito negativo no desempenho. As exceções devem ser reservadas para condições excepcionais e seriam perfeitamente aceitáveis ​​para validar a entrada de formulário, mas não para analisar números de arquivos grandes.
Robert Harvey

1
Essa é uma necessidade mais especializada, não o caso geral.
DeadMG

2
Então você diz. Mas você usou a palavra "sempre". :)
Robert Harvey

@DeadMG, concordou com RobertHarvey, embora eu ache que a resposta foi exageradamente rejeitada, se ela foi modificada para refletir "a maior parte do tempo" e depois apontou exceções (sem trocadilhos) para o caso geral quando se trata de alto desempenho frequentemente usado chamadas para considerar outras opções.
Gerald Davis

Exceções não são caras. Capturar exceções profundamente lançadas pode ser caro, pois o sistema precisa desenrolar a pilha até o ponto crítico mais próximo. Mas esse "caro" é relativamente pequeno e não deve ser temido, mesmo em laços aninhados apertados.
Matthew Whited

2

Como outros observaram, o valor mágico (incluindo um valor de retorno booleano) não é uma solução tão boa, exceto como um marcador de "fim de intervalo". Razão: A semântica não é explícita, mesmo se você examinar os métodos do objeto. Você precisa realmente ler a documentação completa de todo o objeto até "oh sim, se retornar -42 significa bla bla bla".

Esta solução pode ser usada por razões históricas ou por desempenho, mas, caso contrário, deve ser evitada.

Isso deixa dois casos gerais: sondagem ou exceções.

Aqui, a regra geral é que o programa não deve reagir a exceções, exceto para lidar com quando o programa / involuntariamente / viola alguma condição. A sondagem deve ser usada para garantir que isso não aconteça. Portanto, uma exceção significa que a investigação relevante não foi realizada com antecedência ou que algo totalmente inesperado aconteceu.

Exemplo:

Você deseja criar um arquivo a partir de um determinado caminho.

Você deve usar o objeto File para avaliar antecipadamente se esse caminho é legal para criação ou gravação de arquivo.

Se, de alguma forma, o seu programa ainda tentar gravar em um caminho ilegal ou não gravável, você deverá obter uma exceção. Isso pode acontecer devido a uma condição de corrida (algum outro usuário removeu o diretório ou tornou-o somente leitura, depois que você tiver problemas)

A tarefa de lidar com uma falha inesperada (sinalizada por uma exceção) e verificar se as condições são adequadas para a operação com antecedência (sondagem) geralmente será estruturada de maneira diferente e, portanto, deve usar mecanismos diferentes.


0

Eu acho que o Trypadrão é a melhor escolha, quando o código apenas indica o que aconteceu. Eu odeio param e como objeto anulável. Eu criei a seguinte turma

public sealed class Bag<TValue>
{
    public Bag(TValue value, bool hasValue = true)
    {
        HasValue = hasValue;
        Value = value;
    }

    public static Bag<TValue> Empty
    {
        get { return new Bag<TValue>(default(TValue), false); }
    }

    public bool HasValue { get; private set; }
    public TValue Value { get; private set; }
}

para que eu possa escrever o seguinte código

    public static Bag<XElement> GetXElement(this XElement element, string elementName)
    {
        try
        {
            XElement result = element.Element(elementName);
            return result == null
                       ? Bag<XElement>.Empty
                       : new Bag<XElement>(result);
        }
        catch (Exception)
        {
            return Bag<XElement>.Empty;
        }
    }

Parece anulável, mas não apenas para o tipo de valor

Outro exemplo

    public static Bag<string> TryParseString(this XElement element, string attributeName)
    {
        Bag<string> attributeResult = GetString(element, attributeName);
        if (attributeResult.HasValue)
        {
            return new Bag<string>(attributeResult.Value);
        }
        return Bag<string>.Empty;
    }

    private static Bag<string> GetString(XElement element, string attributeName)
    {
        try
        {
            string result = element.GetAttribute(attributeName).Value;
            return new Bag<string>(result);
        }
        catch (Exception)
        {
            return Bag<string>.Empty;
        }
    }

3
try catchcausará estragos no seu desempenho se você estiver ligando GetXElement()e falhando várias vezes.
Robert Harvey

às vezes não importa. Dê uma olhada na chamada Bag. Obrigado pela sua observação

Seu saco <T> clas é quase idêntico ao System.Nullable <T> aka "objeto nulo"
aeroson

sim, quase public struct Nullable<T> where T : structa principal diferença em uma restrição. btw a versão mais recente está aqui github.com/Nelibur/Nelibur/blob/master/Source/Nelibur.Sword/…
GSerjo

0

Se você está interessado na rota do "valor mágico", outra maneira de resolver isso é sobrecarregar o objetivo da classe Lazy. Embora o Lazy tenha a intenção de adiar a instanciação, nada realmente o impede de usar como um Talvez ou uma Opção. Por exemplo:

    public static Lazy<TValue> GetValue<TValue, TKey>(
        this IDictionary<TKey, TValue> dictionary,
        TKey key)
    {
        TValue retVal;
        if (dictionary.TryGetValue(key, out retVal))
        {
            var retValRef = retVal;
            var lazy = new Lazy<TValue>(() => retValRef);
            retVal = lazy.Value;
            return lazy;
        }

        return new Lazy<TValue>(() => default(TValue));
    }
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.