¿Devolver valor mágico, lanzar excepción o devolver falso si falla?

78

A veces termino teniendo que escribir un método o propiedad para una biblioteca de clases para la cual no es excepcional no tener una respuesta real, sino un error. Algo no se puede determinar, no está disponible, no se encuentra, actualmente no es posible o no hay más datos disponibles.

Creo que hay tres soluciones posibles para una situación tan relativamente no excepcional para indicar un error en C # 4:

  • devuelve un valor mágico que no tiene otro significado (como null y -1 );
  • lanzar una excepción (por ejemplo, KeyNotFoundException );
  • devuelve false y proporciona el valor de retorno real en un parámetro out , (como Dictionary<,>.TryGetValue ).

Entonces, las preguntas son: ¿en qué situación no excepcional debo lanzar una excepción? Y si no debo lanzar: cuando se está devolviendo un valor mágico preferido arriba implementando un Try* método con un parámetro out ? (Para mí, el parámetro out parece sucio, y es más trabajo usarlo correctamente).

Estoy buscando respuestas objetivas, como respuestas que incluyan pautas de diseño (no conozco ninguno sobre los métodos Try* ), facilidad de uso (ya que solicito esto para una biblioteca de clases), coherencia con el BCL y legibilidad .

En la biblioteca de clases base de .NET Framework, se utilizan los tres métodos:

Tenga en cuenta que como Hashtable se creó en el momento en que no había genéricos en C #, utiliza object y, por lo tanto, puede devolver null como un valor mágico. Pero con los genéricos, las excepciones se utilizan en Dictionary<,> , e inicialmente no tenía TryGetValue . Aparentemente las ideas cambian.

Obviamente, la dualidad Item - TryGetValue y Parse - TryParse está ahí por una razón, así que asumo que lanzar excepciones para fallas no excepcionales está en C # 4 no hecho . Sin embargo, los métodos Try* no siempre existieron, incluso cuando existía Dictionary<,>.Item .

    
pregunta Daniel Pelsmaeker 01.08.2012 - 21:05

8 respuestas

53

No creo que tus ejemplos sean realmente equivalentes. Hay tres grupos distintos, cada uno con su propia justificación para su comportamiento.

  1. El valor mágico es una buena opción cuando hay una condición de "hasta" como StreamReader.Read o cuando hay un valor fácil de usar que nunca será una respuesta válida (-1 para IndexOf ).
  2. Lanzar excepción cuando la semántica de la función es que la persona que llama está segura de que funcionará. En este caso, una clave no existente o un formato doble incorrecto es realmente excepcional.
  3. Use un parámetro de salida y devuelva un valor bool si la semántica debe probar si la operación es posible o no.

Los ejemplos que proporciona son perfectamente claros para los casos 2 y 3. Para los valores mágicos, se puede argumentar si esta es una buena decisión de diseño o no en todos los casos.

El NaN devuelto por Math.Sqrt es un caso especial, sigue el estándar de punto flotante.

    
respondido por el Anders Abel 01.08.2012 - 21:11
29

Está intentando comunicar al usuario de la API lo que tiene que hacer. Si lanza una excepción, no hay nada que los obligue a atraparla, y solo leer la documentación les permitirá saber cuáles son todas las posibilidades. Personalmente, considero lento y tedioso profundizar en la documentación para encontrar todas las excepciones que puede lanzar un determinado método (incluso si está en modo inteligente, todavía tengo que copiarlas manualmente).

Un valor mágico aún requiere que lea la documentación, y posiblemente haga referencia a alguna tabla const para decodificar el valor. Al menos no tiene la sobrecarga de una excepción por lo que usted llama una ocurrencia no excepcional.

Es por eso que aunque los parámetros out son mal vistos, prefiero ese método, con la sintaxis Try... . Es una sintaxis canónica de .NET y C #. Se está comunicando al usuario de la API que debe verificar el valor de retorno antes de usar el resultado. También puede incluir un segundo parámetro out con un mensaje de error útil, que nuevamente ayuda con la depuración. Por eso voto por la solución de parámetro Try... con out .

Otra opción es devolver un objeto de "resultado" especial, aunque esto me parece más tedioso:

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; }
}

Entonces su función se ve bien porque solo tiene parámetros de entrada y solo devuelve una cosa. Es solo que lo único que devuelve es una especie de tupla.

De hecho, si te gusta ese tipo de cosas, puedes usar las nuevas clases Tuple<> que se introdujeron en .NET 4. Personalmente, no me gusta el hecho de que el significado de cada campo sea menos explícito porque no puedo dar nombres útiles a Item1 y Item2 .

    
respondido por el Scott Whitlock 02.08.2012 - 03:53
17

Como ya muestran sus ejemplos, cada uno de estos casos debe evaluarse por separado y existe un considerable espectro de grises entre "circunstancias excepcionales" y "control de flujo", especialmente si su método está destinado a ser reutilizable y podría usarse en Patrones bastante diferentes para los que fue diseñado originalmente. No espere que todos nosotros estemos de acuerdo en lo que significa "no excepcional", especialmente si discute de inmediato la posibilidad de usar "excepciones" para implementar eso.

También es posible que no estemos de acuerdo sobre qué diseño hace que el código sea más fácil de leer y mantener, pero asumiré que el diseñador de la biblioteca tiene una visión personal clara de eso y solo necesita equilibrarlo con las otras consideraciones involucradas.

Respuesta corta

Sigue tus instintos, excepto cuando estás diseñando métodos bastante rápidos y esperas una posibilidad de reutilización imprevista.

Respuesta larga

Cada persona que llama en el futuro puede traducir libremente entre códigos de error y excepciones como lo desee en ambas direcciones; esto hace que los dos enfoques de diseño sean casi equivalentes, excepto por el rendimiento, la facilidad de uso del depurador y algunos contextos de interoperabilidad restringida. Esto generalmente se reduce al rendimiento, así que concentrémonos en eso.

  • Como regla general, espere que lanzar una excepción sea 200 veces más lento que un retorno regular (en realidad, hay una variación significativa en eso).

  • Como otra regla de oro, lanzar una excepción a menudo puede permitir un código mucho más limpio en comparación con los valores mágicos más crudos, porque no está confiando en que el programador traduzca el código de error a otro código de error, ya que viaja a través de múltiples capas de código de cliente hacia un punto donde haya suficiente contexto para manejarlo de una manera consistente y adecuada. (Caso especial: null tiende a funcionar mejor aquí que otros valores mágicos debido a su tendencia a traducirse automáticamente a NullReferenceException en el caso de algunos, pero no todos los tipos de defectos; generalmente, pero no siempre, muy cerca de la fuente del defecto.)

Entonces, ¿cuál es la lección?

Para una función que se llama solo unas pocas veces durante la vida útil de una aplicación (como la inicialización de la aplicación), use cualquier código que le brinde un código más limpio y fácil de entender. El rendimiento no puede ser una preocupación.

Para una función desechable, use cualquier cosa que le dé un código más limpio. Luego realice algunos perfiles (si es necesario) y cambie las excepciones a los códigos de retorno si se encuentran entre los principales cuellos de botella sospechosos según las mediciones o la estructura general del programa.

Para una función reutilizable costosa, use cualquier cosa que le dé un código más limpio. Si, básicamente, siempre tiene que realizar un viaje de ida y vuelta a la red o analizar un archivo XML en el disco, la sobrecarga de lanzar una excepción es probablemente insignificante. Es más importante no perder detalles de cualquier falla, ni siquiera accidentalmente, que regresar de una "falla no excepcional" muy rápido.

Una función reutilizable magra requiere más pensamiento. Al emplear excepciones, usted está forzando algo así como una ralentización 100 veces mayor en las personas que llaman que verán la excepción en la mitad de sus (muchas) llamadas, si el cuerpo de la función se ejecuta muy rápido. Las excepciones siguen siendo una opción de diseño, pero tendrá que proporcionar una alternativa de bajo costo para las personas que llaman que no pueden pagar esto. Veamos un ejemplo.

Enumera un gran ejemplo de Dictionary<,>.Item , que, en términos generales, cambió de devolver los valores de null a lanzar KeyNotFoundException entre .NET 1.1 y .NET 2.0 (solo si está dispuesto a considerar Hashtable.Item a Ser su precursor práctico no genérico). La razón de este "cambio" no está aquí sin interés. La optimización del rendimiento de los tipos de valor (no más boxeo) convirtió el valor mágico original ( null ) en una no opción; Los parámetros out solo traerían una pequeña parte del costo de rendimiento. Esta última consideración de rendimiento es completamente insignificante en comparación con la sobrecarga de lanzar un KeyNotFoundException , pero el diseño de la excepción sigue siendo superior aquí. ¿Por qué?

  • los parámetros ref / out incurren en sus costos cada vez, no solo en el caso de "falla"
  • Cualquier persona que se preocupe puede llamar a Contains antes de cualquier llamada al indexador, y este patrón se lee de forma completamente natural. Si un desarrollador desea pero se olvida de llamar a Contains , no pueden surgir problemas de rendimiento; KeyNotFoundException es lo suficientemente alto como para ser notado y corregido.
respondido por el Jirka Hanika 02.08.2012 - 01:49
10
  

¿Qué es lo mejor que se puede hacer en una situación relativamente no excepcional para indicar el fracaso y por qué?

No debes permitir el error.

Lo sé, es mano-mano e idealista, pero escúchame. Al realizar el diseño, hay varios casos en los que tiene la oportunidad de favorecer una versión que no tiene modos de falla. En lugar de tener un 'FindAll' que falla, LINQ usa una cláusula where que simplemente devuelve un enumerable vacío. En lugar de tener un objeto que deba inicializarse antes de usarlo, deje que el constructor inicialice el objeto (o que se inicialice cuando se detecte el no inicializado). La clave es eliminar la rama de falla en el código del consumidor. Este es el problema, así que concéntrate en él.

Otra estrategia para esto es el KeyNotFound sscenario. En casi todas las bases de código en las que he trabajado desde la versión 3.0, existe algo así como este método de extensión:

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];
    }
}

No hay un modo de falla real para esto. ConcurrentDictionary tiene un GetOrAdd similar incorporado.

Dicho todo esto, siempre habrá momentos en los que es simplemente inevitable. Los tres tienen su lugar, pero yo preferiría la primera opción. A pesar de todo lo que está hecho del peligro de null, es bien conocido y encaja en muchos de los escenarios de "elemento no encontrado" o "resultado no aplicable" que conforman el conjunto de "falla no excepcional". Especialmente cuando estás haciendo tipos de valores anulables, el significado de "esto podría fallar" es muy explícito en el código y es difícil de olvidar / estropear.

La segunda opción es suficiente cuando tu usuario hace algo tonto. Te da una cadena con el formato incorrecto, intenta establecer la fecha en el 42 de diciembre ... algo que quieres que las cosas exploten de forma rápida y espectacular durante las pruebas para que el código incorrecto se identifique y solucione.

La última opción es una que cada vez me disgusta. Los parámetros externos son incómodos y tienden a violar algunas de las mejores prácticas al crear métodos, como centrarse en una cosa y no tener efectos secundarios. Además, el parámetro de salida generalmente solo es significativo durante el éxito. Dicho esto, son esenciales para ciertas operaciones que generalmente están limitadas por problemas de concurrencia o consideraciones de rendimiento (donde no desea hacer un segundo viaje a la base de datos, por ejemplo).

Si el valor de retorno y out param no son triviales, se prefiere la sugerencia de Scott Whitlock sobre un objeto de resultado (como la clase Match de Regex).

    
respondido por el Telastyn 02.08.2012 - 04:36
2

Como han señalado otros, el valor mágico (incluido un valor de retorno booleano) no es una solución tan buena, excepto como un marcador de "fin de rango". Motivo: la semántica no es explícita, incluso si examinas los métodos del objeto. Debes leer la documentación completa del objeto completo hasta "oh sí, si devuelve -42 significa bla bla bla".

Esta solución se puede usar por razones históricas o por razones de rendimiento, pero de lo contrario se debería evitar.

Esto deja dos casos generales: sondeo o excepciones.

Aquí, la regla general es que el programa no debe reaccionar a las excepciones, excepto para controlar cuándo el programa / involuntariamente / infringe alguna condición. Se debe utilizar el sondeo para garantizar que esto no suceda. Por lo tanto, una excepción significa que el sondeo relevante no se realizó por adelantado, o que algo totalmente inesperado ha sucedido.

Ejemplo:

Desea crear un archivo a partir de una ruta determinada.

Debe usar el objeto Archivo para evaluar de antemano si esta ruta es legal para la creación o escritura de archivos.

Si su programa de alguna manera todavía termina de escribir en una ruta que es ilegal o que no se puede escribir, debería obtener un resumen. Esto podría suceder debido a una condición de carrera (algún otro usuario eliminó el directorio o lo hizo de solo lectura, después de que usted lo haya hecho)

La tarea de manejar un error inesperado (señalado por una excepción) y verificar si las condiciones son correctas para la operación por adelantado (sondeo) generalmente se estructurará de manera diferente y, por lo tanto, deberá utilizar diferentes mecanismos.

    
respondido por el Anders Johansen 02.08.2012 - 11:17
1

Siempre prefiere lanzar una excepción. Tiene una interfaz uniforme entre todas las funciones que podrían fallar, e indica el fallo lo más ruidosamente posible, una propiedad muy deseable.

Tenga en cuenta que Parse y TryParse no son lo mismo, aparte de los modos de falla. El hecho de que TryParse también pueda devolver el valor es algo ortogonal, en realidad. Considere la situación en la que, por ejemplo, está validando alguna entrada. En realidad no te importa cuál es el valor, siempre y cuando sea válido. Y no hay nada de malo en ofrecer un tipo de función IsValidFor(arguments) . Pero nunca puede ser el modo de operación primario .

    
respondido por el DeadMG 02.08.2012 - 05:04
0

Creo que el patrón Try es la mejor opción, cuando el código simplemente indica lo que sucedió. Odio a param y como objeto anulable. He creado la siguiente clase

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 pueda escribir el siguiente 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 anulable, pero no solo por el tipo de valor

Otro ejemplo

    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;
        }
    }
    
respondido por el GSerjo 01.08.2012 - 23:07
0

Si estás interesado en la ruta del "valor mágico", otra forma de resolver esto es sobrecargar el propósito de la clase perezosa. A pesar de que la intención de Lazy es diferir la creación de instancias, nada realmente le impide usar como un Maybe o una Option. Por ejemplo:

    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));
    }
    
respondido por el zumalifeguard 04.12.2014 - 03:48

Lea otras preguntas en las etiquetas