Retorna o valor mágico, lança uma exceção ou retorna falso na falha?

78

Eu à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 atualmente ou não há mais dados disponíveis.

Acho que há três soluções possíveis para uma situação relativamente não excepcional para indicar falha em C # 4:

  • retorna um valor mágico que não tem nenhum significado (como null e -1 );
  • lance uma exceção (por exemplo, KeyNotFoundException );
  • retorne false e forneça o valor de retorno real em um parâmetro out (como Dictionary<,>.TryGetValue ).

Então as perguntas são: em qual situação não excepcional eu deveria jogar uma exceção? E se eu não deveria jogar: quando estiver retornando um valor mágico perferido acima de implementar um Try* método com um parâmetro out ? (Para mim, o parâmetro out parece sujo e é mais trabalhoso usá-lo corretamente.)

Estou procurando respostas factuais, como respostas envolvendo diretrizes de design (não sei nada sobre Try* methods), usabilidade (como eu solicito isso para 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 Hashtable foi criado no momento em que não havia genéricos em C #, ele usa object e pode, portanto, retornar null como um valor mágico. Mas com genéricos, as exceções são usadas em Dictionary<,> e inicialmente não tinham TryGetValue . Aparentemente, os insights mudam.

Obviamente, a Item - TryGetValue e Parse - TryParse duality está lá por um motivo, então suponho que lançar exceções para falhas não excepcionais esteja em C # 4 não concluído . No entanto, os métodos Try* nem sempre existiam, mesmo quando Dictionary<,>.Item existia.

    
por Daniel Pelsmaeker 01.08.2012 / 21:05
fonte

8 respostas

53

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

  1. O valor mágico é uma boa opção quando existe uma condição "até" como StreamReader.Read ou quando há um valor simples de usar que nunca será uma resposta válida (-1 para IndexOf ).
  2. Lança a exceção quando a semântica da função é que o chamador tem certeza de que funcionará. Neste caso, uma chave inexistente ou um mau formato duplo é verdadeiramente 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 esta é uma boa decisão de projeto ou não em todos os casos.

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

    
por 01.08.2012 / 21:11
fonte
29

Você está tentando comunicar ao usuário da API o que precisa fazer. Se você der uma exceção, não há nada que os obrigue a capturá-los, e apenas ler a documentação permitirá que eles saibam quais são as possibilidades. Pessoalmente eu 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 intellisense, eu ainda tenho que copiá-los manualmente).

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

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

Outra opção é retornar um objeto "result" 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 a sua função parece correta porque ela só tem parâmetros de entrada e só retorna uma coisa. É só que a única coisa que retorna é uma espécie de tupla.

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

    
por 02.08.2012 / 03:53
fonte
17

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

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

Resposta curta

Siga as suas intuições, exceto quando estiver projetando métodos 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, como deseja em ambas as direções; Isso faz com que as duas abordagens de design sejam quase equivalentes, exceto pelo desempenho, pela facilidade com o depurador e por 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 lenta que um retorno regular (na realidade, há uma variação significativa nisso).

  • Como outra regra prática, lançar uma exceção pode muitas vezes permitir um código muito mais limpo em comparação com os valores mágicos mais cruéis, porque você não está confiando no programador para traduzir o código de erro em outro código de erro múltiplas camadas de código do cliente em um ponto onde há contexto suficiente para lidar com isso de maneira consistente e adequada. (Caso especial: null tende a se sair melhor aqui do que outros valores mágicos devido a sua tendência de se traduzir automaticamente para um NullReferenceException no caso de alguns, mas nem todos os tipos de defeitos; geralmente, mas nem sempre, muito próximos a fonte do defeito.)

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

Para uma função que é chamada apenas algumas vezes durante o tempo de vida de um aplicativo (como a inicialização do aplicativo), use qualquer coisa que ofereça 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 código mais limpo. Em seguida, faça algum perfil (se necessário) e altere as exceções para os códigos de retorno se 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 tem que passar por uma rede de ida e volta ou analisar um arquivo XML em disco, a sobrecarga de lançar uma exceção provavelmente é insignificante. É mais importante não perder detalhes de qualquer falha, nem mesmo acidentalmente, do que retornar de uma "falha não excepcional" extra rápido.

Uma função enxuta reutilizável requer mais reflexão. Ao empregar exceções, você está forçando algo como uma diminuição de 100 vezes em chamadores que verão a exceção na metade de suas (muitas) chamadas, se o corpo da função for executado muito rapidamente. Exceções ainda são uma opção de design, mas você terá que fornecer uma alternativa de baixa sobrecarga para os chamadores que não podem pagar isso. Vamos ver um exemplo.

Você lista um ótimo exemplo de Dictionary<,>.Item , que, falando livremente, mudou de retornar null valores para % KeyNotFoundException entre o .NET 1.1 e o .NET 2.0 (somente se você estiver disposto a considerar Hashtable.Item como ser seu precursor prático não genérico). A razão desta "mudança" não é sem interesse aqui. A otimização de desempenho de tipos de valor (sem mais boxe) fez com que o valor mágico original ( null ) fosse uma não opção; Os parâmetros out apenas devolveriam uma pequena parte do custo de desempenho. Esta última consideração de desempenho é completamente insignificante em comparação com a sobrecarga de lançar um KeyNotFoundException , mas o design de exceção ainda é superior aqui. Por quê?

    Os parâmetros de
  • ref / out incorrem em seus custos sempre, não apenas no caso de "falha"
  • Qualquer pessoa que se importe pode chamar Contains antes de qualquer chamada para o indexador, e esse padrão é lido completamente naturalmente. Se um desenvolvedor quiser, mas esquecer de chamar Contains , nenhum problema de desempenho poderá ocorrer; KeyNotFoundException é alto o suficiente para ser notado e corrigido.
por 02.08.2012 / 01:49
fonte
10

What is the best thing to do in such a relatively non-exceptional situation to indicate failure, and why?

Você não deve permitir falhas.

Eu sei, é mão-wavey 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 sem 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 inicialize quando o não inicializado for detectado). A chave é remover o ramo de falha no código do consumidor. Esse é o problema, então concentre-se nele.

Outra estratégia para isso é o KeyNotFound sscenario. Em quase todas as bases de código em que trabalhei desde a 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 há modo de falha real para isso. ConcurrentDictionary tem um GetOrAdd integrado.

Tudo isso dito, sempre haverá momentos em que é simplesmente inevitável. Todos os três têm o seu lugar, mas eu preferiria a primeira opção. Apesar de tudo o que é feito de 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 de 'falha não excepcional'. Especialmente quando você está fazendo tipos de valor anulável, a significância do 'isso pode falhar' é muito explícita no código e difícil de esquecer / estragar.

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

A última opção é a que eu menos gosto. Os parâmetros externos são inadequados e tendem a violar algumas das práticas recomendadas ao criar métodos, como se concentrar em uma coisa e não ter efeitos colaterais. Além disso, o parâmetro out geralmente só é significativo durante o sucesso. Dito isso, elas são essenciais para determinadas operações que normalmente são restritas por preocupações de concorrência ou considerações de desempenho (onde você não deseja fazer uma segunda viagem ao banco de dados, por exemplo).

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

    
por 02.08.2012 / 04:36
fonte
2

Como outros já observaram, o valor mágico (incluindo um valor de retorno booleano) não é uma ótima solução, 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 ler a documentação completa do objeto inteiro até "oh yeah, se ele retornar -42 significa bla bla bla".

Esta solução pode ser usada por razões históricas ou de desempenho, mas 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 quando lidar com o programa / involuntariamente / violar alguma condição. A pesquisa 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 inteiramente 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 a criação ou gravação de arquivos.

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

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.

    
por 02.08.2012 / 11:17
fonte
1

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

Observe que Parse e TryParse não são a mesma coisa, exceto os modos de falha. O fato de que TryParse também pode retornar o valor é um pouco ortogonal, na verdade. Considere a situação em que, por exemplo, você está validando alguma entrada. Você não se importa realmente com o valor, desde que seja válido. E não há nada errado em oferecer um tipo de função IsValidFor(arguments) . Mas nunca pode ser o modo de operação primário .

    
por 02.08.2012 / 05:04
fonte
0

Acho que o padrão Try é a melhor escolha, quando o código indica apenas o que aconteceu. Eu odeio param e como objeto anulável. Eu criei a seguinte classe

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;
        }
    }
    
por 01.08.2012 / 23:07
fonte
0

Se você estiver interessado na rota do "valor mágico", outra maneira de resolver isso é sobrecarregar o propósito da classe Preguiçoso. Embora o Lazy tenha a intenção de adiar a instanciação, nada impede que você use como um Maybe ou uma Option. 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));
    }
    
por 04.12.2014 / 03:48
fonte