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

83

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, no es posible actualmente o no hay más datos disponibles.

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

  • devolver un valor mágico que no tiene sentido de otra manera (como nully -1);
  • lanzar una excepción (por ejemplo KeyNotFoundException);
  • devolver falsey proporcionar el valor de retorno real en un outparámetro, (como Dictionary<,>.TryGetValue).

Entonces las preguntas son: ¿ en qué situación no excepcional debo lanzar una excepción? Y si no debo lanzar: ¿ cuándo se devuelve un valor mágico perferido arriba implementando un Try*método con un outparámetro ? (Para mí, el outparámetro parece sucio, y es más difícil usarlo correctamente).

Estoy buscando respuestas fácticas, como respuestas que involucren pautas de diseño (no conozco ninguno sobre los Try*métodos), usabilidad (como pido esto para una biblioteca de clase), coherencia con el BCL y legibilidad.


En .NET Framework Base Class Library, se utilizan los tres métodos:

Tenga en cuenta que, como Hashtablese creó en el momento en que no había genéricos en C #, se usa objecty, por lo tanto, puede regresar nullcomo un valor mágico. Pero con los genéricos, se usan excepciones Dictionary<,>, y al principio no TryGetValue. Al parecer, las ideas cambian.

Obviamente, el Item- TryGetValuey Parse- TryParsela dualidad está ahí por una razón, por lo que suponen que lanzar excepciones de fallos no es excepcional en C # 4 no se realiza . Sin embargo, los Try*métodos no siempre existieron, incluso cuando Dictionary<,>.Itemexistían.

Daniel AA Pelsmaeker
fuente
66
"excepciones significan errores". pedirle a un diccionario un artículo que no existe es un error; pedirle a un flujo que lea datos cuando es un EOF ocurre cada vez que usa un flujo. (esto resume la respuesta larga y bellamente formateada que no tuve la oportunidad de enviar :))
KutuluMike
66
No es que piense que su pregunta es demasiado amplia, es que no es una pregunta constructiva. No hay una respuesta canónica ya que las respuestas ya se muestran.
George Stocker
2
@GeorgeStocker Si la respuesta fuera directa y obvia, entonces no habría hecho la pregunta. El hecho de que las personas puedan discutir por qué una opción dada es preferible desde cualquier punto de vista (como el rendimiento, la legibilidad, la usabilidad, el mantenimiento, las pautas de diseño o la coherencia) hace que sea inherentemente no canónica pero responda a mi satisfacción. Todas las preguntas pueden ser respondidas de manera subjetiva. Aparentemente espera que una pregunta tenga respuestas en su mayoría similares para que sea una buena pregunta.
Daniel AA Pelsmaeker
3
@Virtlink: George es un moderador elegido por la comunidad que ofrece gran parte de su tiempo como voluntario para ayudar a mantener Stack Overflow. Dijo por qué cerró la pregunta, y las preguntas frecuentes lo respaldan.
Eric J.
15
La pregunta pertenece aquí, no porque sea subjetiva, sino porque es conceptual. Regla general: si está sentado frente a su codificación IDE, pregúntelo en Stack Overflow. Si está de pie frente a una lluvia de ideas en la pizarra, pregúntelo aquí en Programadores.
Robert Harvey

Respuestas:

56

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

  1. El valor mágico es una buena opción cuando hay una condición de "hasta" como StreamReader.Reado cuando hay un valor fácil de usar que nunca será una respuesta válida (-1 para IndexOf).
  2. Lanza una 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 inexistente o un formato doble incorrecto es realmente excepcional.
  3. Utilice un parámetro out y devuelva un valor bool si la semántica es para sondear 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 NaNdevuelto por Math.Sqrtes un caso especial: sigue el estándar de coma flotante.

Anders Abel
fuente
10
No está de acuerdo con el número 1. Los valores mágicos nunca son una buena opción, ya que requieren que el siguiente codificador conozca la importancia del valor mágico. También perjudican la legibilidad. No se me ocurre ninguna instancia en la que el uso de un valor mágico sea mejor que el patrón Try.
0b101010
1
¿Pero qué hay de la Either<TLeft, TRight>mónada?
Sara
2
@ 0b101010: acabo de pasar un tiempo buscando cómo streamreader.read puede devolver -1 ...
jmoreno
33

Estás intentando comunicar al usuario de la API lo que tienen que hacer. Si lanzas una excepción, no hay nada que los obligue a atraparla, y solo leer la documentación les hará saber cuáles son todas las posibilidades. Personalmente, considero que es lento y tedioso profundizar en la documentación para encontrar todas las excepciones que un determinado método podría arrojar (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 consttabla para decodificar el valor. Al menos no tiene la sobrecarga de una excepción para lo que llama una ocurrencia no excepcional.

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

Otra opción es devolver un objeto "resultado" especial, aunque esto me parece mucho 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 Tuple<>clases que se introdujeron en .NET 4. Personalmente, no me gusta el hecho de que el significado de cada campo es menos explícito porque no puedo dar Item1y Item2nombres útiles.

Scott Whitlock
fuente
3
En lo personal, que a menudo utilizan un contenedor como resultado de su IMyResultcausa, es posible comunicar un resultado más complejo que la simple trueo falseo el valor del resultado. Try*()solo es útil para cosas simples como la conversión de cadenas a int.
this.myself
1
Buen post. Para mí, prefiero el lenguaje de estructura de resultados que describió anteriormente en lugar de dividir entre valores de "retorno" y parámetros de "salida". Lo mantiene ordenado y ordenado.
Ocean Airdrop
2
El problema con el segundo parámetro de salida es cuando tienes 50 funciones en un programa complejo, ¿cómo comunicas ese mensaje de error al usuario? Lanzar una excepción es mucho más simple que tener capas de errores de verificación. Cuando consigas uno, solo tíralo y no importa qué tan profundo estés.
llega el
@rolls: cuando usamos objetos de salida, suponemos que la persona que llama inmediatamente hará lo que quiera con la salida: trátelo localmente, ignórelo o burbujee arrojando eso envuelto en una excepción. Es la mejor de las dos palabras: la persona que llama puede ver claramente todas las salidas posibles (con enumeraciones, etc.), puede decidir qué hacer con el error y no necesita intentar atrapar cada llamada. Si en su mayoría espera manejar el resultado de inmediato o ignorarlo, los objetos devueltos son más fáciles. Si desea arrojar todos los errores a las capas superiores, las excepciones son más fáciles.
drizin
2
Esto es solo reinventar las excepciones comprobadas en C # de una manera mucho más tediosa que las excepciones comprobadas en Java.
Invierno
17

Como sus ejemplos ya muestran, cada uno de estos casos debe evaluarse por separado y hay un espectro considerable de gris 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 de lo que fue originalmente diseñado. No espere que todos acordamos lo que significa "no excepcional", especialmente si discute de inmediato la posibilidad de utilizar "excepciones" para implementar eso.

Es posible que tampoco estemos de acuerdo sobre qué diseño hace que el código sea más fácil de leer y mantener, pero supondré 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

Siga sus instintos, excepto cuando diseñe métodos bastante rápidos y espere una posibilidad de reutilización imprevista.

Respuesta larga

Cada futuro llamante 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 depuración y algunos contextos de interoperabilidad restringidos. 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 rendimiento regular (en realidad, hay una variación significativa en eso).

  • Como otra regla general, 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 confía 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 hay suficiente contexto para manejarlo de manera consistente y adecuada. (Caso especial: nulltiende a tener mejores resultados aquí que otros valores mágicos debido a su tendencia a traducirse automáticamente a uno NullReferenceExceptionen el caso de algunos, pero no todos los tipos de defectos; generalmente, pero no siempre, bastante cerca de la fuente del defecto. )

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

Para una función que se llama solo algunas veces durante la vida útil de una aplicación (como la inicialización de la aplicación), use cualquier cosa 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 proporcione 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 proporcione un código más limpio. Si básicamente tiene que someterse a un viaje de ida y vuelta a la red o analizar un archivo XML en el disco, la sobrecarga de lanzar una excepción probablemente sea insignificante. Es más importante no perder detalles de cualquier falla, ni siquiera accidentalmente, que regresar de una "falla no excepcional" extra rápido.

Una función magra reutilizable requiere más reflexión. Al emplear excepciones, está forzando 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 adicional 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 nullvalores devueltos a arrojar KeyNotFoundExceptionentre .NET 1.1 y .NET 2.0 (solo si está dispuesto a considerar Hashtable.Itemsu precursor práctico no genérico). La razón de este "cambio" no carece de interés aquí. La optimización del rendimiento de los tipos de valor (no más boxeo) hizo que el valor mágico original ( null) no fuera una opción; outlos parámetros 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 a KeyNotFoundException, pero el diseño de excepción todavía es superior aquí. ¿Por qué?

  • los parámetros de ref / out incurren en sus costos cada vez, no solo en el caso de "falla"
  • Cualquiera a quien le importe puede llamar Containsantes de cualquier llamada al indexador, y este patrón se lee de forma completamente natural. Si un desarrollador quiere pero se olvida de llamar Contains, no pueden aparecer problemas de rendimiento; KeyNotFoundExceptiones lo suficientemente fuerte como para ser notado y reparado.
Jirka Hanika
fuente
Creo que 200x está siendo optimista sobre el rendimiento de las excepciones ... ver blogs.msdn.com/b/cbrumme/archive/2003/10/01/51524.aspx sección "Rendimiento y tendencias" justo antes de los comentarios.
gbjbaanb
@gbjbaanb - Bueno, tal vez. Ese artículo utiliza una tasa de fracaso del 1% para discutir el tema, que no es un estadio completamente diferente. Mis propios pensamientos y mediciones vagamente recordadas serían del contexto de C ++ implementado en la tabla (consulte la Sección 5.4.1.2 de este informe , donde un problema es que la primera excepción de este tipo probablemente comience con un error de página (drástico y variable pero amortizado una vez por encima). Pero lo haré y publicaré un experimento con .NET 4 y posiblemente modifique este valor de estadio. Ya enfatizo la variación.
Jirka Hanika
¿El costo de los parámetros de ref / out es tan alto entonces? ¿Cómo es eso? Y llamar Containsantes de una llamada a un indexador puede causar una condición de carrera que no tiene que estar presente TryGetValue.
Daniel AA Pelsmaeker
@gbjbaanb - Experimentado terminado. Era flojo y usaba mono en Linux. Las excepciones me dieron ~ 563000 lanzamientos en 3 segundos. Las devoluciones me dieron ~ 10900000 devoluciones en 3 segundos. Eso es 1:20, ni siquiera 1: 200. Todavía recomiendo pensar 1: 100+ para cualquier código más realista. (La variante param, como había predicho, tenía costos insignificantes; en realidad sospecho que la inquietud probablemente había optimizado completamente la llamada en mi ejemplo minimalista si no se lanzaba una excepción, independientemente de la firma).
Jirka Hanika,
@Virtlink: acordó la seguridad de los subprocesos en general, pero dado que se refirió a .NET 4 en particular, utilícelo ConcurrentDictionarypara el acceso de subprocesos múltiples y Dictionarypara el acceso de subprocesos simples para un rendimiento óptimo. Es decir, no usar `` Contiene '' NO hace que el hilo del código sea seguro con esta Dictionaryclase en particular .
Jirka Hanika
10

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

No debes permitir el fracaso.

Lo sé, es de onda manual e idealista, pero escúchame. Al hacer el diseño, hay una serie de 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 necesita inicializarse antes de usarse, deje que el constructor inicialice el objeto (o inicialice cuando se detecte lo 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 ello.

Otra estrategia para esto es el KeyNotFoundsscenario. En casi todas las bases de código en las que he trabajado desde 3.0, existe algo 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 modo de falla real para esto. ConcurrentDictionarytiene un similar GetOrAddconstruido en.

Dicho todo esto, siempre habrá momentos en los que simplemente será inevitable. Los tres tienen su lugar, pero yo preferiría la primera opción. A pesar de todo lo que está hecho del peligro nulo, 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á haciendo tipos de valores anulables, la importancia de 'esto podría fallar' es muy explícita en el código y difícil de olvidar / arruinar.

La segunda opción es lo suficientemente buena cuando su usuario hace algo tonto. Le da una cadena con el formato incorrecto, intenta establecer la fecha para el 42 de diciembre ... algo que desea que explote rápidamente y espectacularmente durante las pruebas para que se identifique y corrija el código incorrecto.

La última opción es una que no me gusta cada vez más. Los parámetros de salida son incómodos y tienden a violar algunas de las mejores prácticas al hacer métodos, como centrarse en una cosa y no tener efectos secundarios. Además, el parámetro externo generalmente solo tiene sentido durante el éxito. Dicho esto, son esenciales para ciertas operaciones que generalmente están limitadas por preocupaciones 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 Matchclase de Regex ).

Telastyn
fuente
77
Mascota molesta aquí: los outparámetros son completamente ortogonales a la cuestión de los efectos secundarios. Modificar un refparámetro es un efecto secundario, y modificar el estado de un objeto que pasa a través de un parámetro de entrada es un efecto secundario, pero un outparámetro es solo una forma incómoda de hacer que una función devuelva más de un valor. No hay efectos secundarios, solo múltiples valores de retorno.
Scott Whitlock
Dije que tienden a hacerlo , debido a cómo la gente los usa. Como usted dice, son simplemente múltiples valores de retorno.
Telastyn el
Pero si no le gustan los parámetros y utiliza excepciones para hacer explotar las cosas espectacularmente cuando el formato es incorrecto ... ¿cómo maneja el caso en el que su formato es la entrada del usuario? Entonces, un usuario puede hacer explotar las cosas, o uno incurre en la penalización de rendimiento de lanzar y luego capturar una excepción. ¿Derecho?
Daniel AA Pelsmaeker
@virtlink al tener un método de validación distinto. Lo necesita de todos modos para proporcionar mensajes adecuados a la interfaz de usuario antes de que lo envíen.
Telastyn el
1
Hay un patrón legítimo para los parámetros de salida, y esa es una función que tiene sobrecargas que devuelven diferentes tipos. La resolución de sobrecarga no funcionará para los tipos de retorno, pero sí para los parámetros.
Robert Harvey
2

Siempre prefiero lanzar una excepción. Tiene una interfaz uniforme entre todas las funciones que podrían fallar, e indica la falla tan ruidosamente como sea posible, una propiedad muy deseable.

Tenga en cuenta que Parsey TryParseno son realmente lo mismo, aparte de los modos de falla. El hecho de que TryParsetambién puede 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 que sea válido. Y no hay nada de malo en ofrecer un tipo de IsValidFor(arguments)función. Pero nunca puede ser el modo primario de operación.

DeadMG
fuente
44
Si está tratando con una gran cantidad de llamadas a un método, las excepciones pueden tener un gran efecto negativo en el rendimiento. Las excepciones deben reservarse para condiciones excepcionales, y serían perfectamente aceptables para validar la entrada del formulario, pero no para analizar números de archivos grandes.
Robert Harvey
1
Esa es una necesidad más especializada, no el caso general.
DeadMG
2
Así que tú dices. Pero usaste la palabra "siempre". :)
Robert Harvey
@DeadMG, estuvo de acuerdo con Robert Harvey, aunque creo que la respuesta fue rechazada en exceso, si se modificó para reflejar "la mayor parte del tiempo" y luego señaló excepciones (sin juego de palabras) al caso general cuando se trata de alto rendimiento de uso frecuente llama a considerar otras opciones.
Gerald Davis
Las excepciones no son caras. Capturar excepciones profundas puede ser costoso ya que el sistema tiene que desenrollar la pila hasta el punto crítico más cercano. Pero ese "costoso" es relativamente pequeño y no debe temerlo incluso en bucles anidados.
Matthew Whited
2

Como otros han señalado, 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 examina los métodos del objeto. Debes leer la documentación completa de todo el objeto hasta "oh sí, si devuelve -42, eso significa bla bla bla".

Esta solución puede usarse por razones históricas o por razones de rendimiento, pero de lo contrario debe evitarse.

Esto deja dos casos generales: sondeo o excepciones.

Aquí, la regla general es que el programa no debe reaccionar a las excepciones, excepto para manejar cuando el programa / involuntariamente / viola alguna condición. El sondeo debe usarse para garantizar que esto no suceda. Por lo tanto, una excepción significa que el sondeo relevante no se realizó por adelantado o que sucedió algo completamente inesperado.

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 tratando de escribir en una ruta que es ilegal o de lo contrario no se puede escribir, debería obtener una excaption. 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 probablemente)

La tarea de manejar una falla inesperada (señalada por una excepción) y verificar si las condiciones son adecuadas para la operación por adelantado (sondeo) generalmente se estructurará de manera diferente y, por lo tanto, debe usar mecanismos diferentes.

Anders Johansen
fuente
0

Creo que el Trypatrón es la mejor opción, cuando el código solo indica lo que sucedió. Odio a param y me gusta el 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 poder 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 para 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;
        }
    }
GSerjo
fuente
3
try catchcausará estragos en su rendimiento si llama GetXElement()y falla muchas veces.
Robert Harvey
a veces no importa. Echa un vistazo a la llamada Bolsa. Gracias por su observación
su bolsa <T> clas es casi idéntica a System.Nullable <T> alias "objeto anulable"
aeroson
Sí, casi public struct Nullable<T> where T : structla principal diferencia en una restricción. por cierto, la última versión está aquí github.com/Nelibur/Nelibur/blob/master/Source/Nelibur.Sword/…
GSerjo
0

Si está interesado en la ruta del "valor mágico", otra forma de resolver esto es sobrecargar el propósito de la clase Lazy. Aunque Lazy está destinado a diferir la creación de instancias, nada realmente le impide usarlo como un Quizás o una Opción. 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));
    }
zumalifeguard
fuente