Comprobado vs No comprobado vs Sin excepción ... Una mejor práctica de creencias contrarias

10

Hay muchos requisitos necesarios para que un sistema transmita y maneje adecuadamente las excepciones. También hay muchas opciones para elegir un idioma para implementar el concepto.

Requisitos para excepciones (sin ningún orden en particular):

  1. Documentación : un idioma debe tener un medio para documentar excepciones que una API puede generar. Idealmente, este medio de documentación debería ser utilizable en la máquina para permitir que los compiladores e IDE brinden soporte al programador.

  2. Transmitir situaciones excepcionales : esta es obvia, para permitir que una función transmita situaciones que impiden que la funcionalidad llamada realice la acción esperada. En mi opinión, hay tres grandes categorías de tales situaciones:

    2.1 Errores en el código que hacen que algunos datos sean inválidos.

    2.2 Problemas en la configuración u otros recursos externos.

    2.3 Recursos inherentemente poco confiables (red, sistemas de archivos, bases de datos, usuarios finales, etc.). Estos son un poco arrinconados, ya que su naturaleza poco confiable debería hacernos esperar sus fallas esporádicas. En este caso, ¿estas situaciones se consideran excepcionales?

  3. Proporcione suficiente información para que el código lo maneje : las excepciones deben proporcionar suficiente información a la persona que llama para que pueda reaccionar y posiblemente manejar la situación. la información también debe ser suficiente para que, cuando se registre, estas excepciones proporcionen suficiente contexto a un programador para identificar y aislar las declaraciones ofensivas y proporcionar una solución.

  4. Proporcione confianza al programador sobre el estado actual del estado de ejecución de su código : las capacidades de manejo de excepciones de un sistema de software deben estar lo suficientemente presentes como para proporcionar las garantías necesarias mientras se mantiene alejado del programador para que pueda concentrarse en la tarea en mano.

Para cubrir estos, se implementaron los siguientes métodos en varios idiomas:

  1. Excepciones marcadas Proporcionan una excelente manera de documentar excepciones, y teóricamente, cuando se implementa correctamente, debe proporcionar una amplia garantía de que todo está bien. Sin embargo, el costo es tal que muchos sienten que es más productivo eludir simplemente al tragar excepciones o volver a lanzarlas como excepciones no controladas. Cuando se usan excepciones marcadas de manera inapropiada, se pierde toda su utilidad. Además, las excepciones marcadas dificultan la creación de una API estable en el tiempo. Las implementaciones de un sistema genérico dentro de un dominio específico traerá una carga de situación excepcional que sería difícil de mantener utilizando únicamente excepciones marcadas.

  2. Excepciones no verificadas: mucho más versátiles que las excepciones marcadas, no logran documentar adecuadamente las posibles situaciones excepcionales de una implementación determinada. Dependen de la documentación ad-hoc, si es que lo hacen. Esto crea situaciones en las que la naturaleza poco confiable de un medio está enmascarada por una API que da la apariencia de confiabilidad. Además, cuando se lanzan, estas excepciones pierden su significado a medida que avanzan de nuevo a través de las capas de abstracción. Dado que están mal documentados, un programador no puede enfocarse específicamente en ellos y, a menudo, necesita lanzar una red mucho más amplia de la necesaria para garantizar que los sistemas secundarios, en caso de que fallen, no derriben todo el sistema. Lo que nos lleva de vuelta al problema de la deglución, verificó las excepciones proporcionadas.

  3. Tipos de retorno de estado múltiple Aquí es confiar en un conjunto disjunto, tupla u otro concepto similar para devolver el resultado esperado o un objeto que representa la excepción. Aquí no se desenrolla la pila, no se corta el código, todo se ejecuta normalmente, pero el valor de retorno debe validarse por error antes de continuar. Realmente no he trabajado con esto todavía, así que no puedo comentar por experiencia. Reconozco que resuelve algunas excepciones de problemas sin pasar por el flujo normal, pero aún sufrirá los mismos problemas que las excepciones comprobadas como ser tedioso y constantemente "en su cara".

Entonces la pregunta es:

¿Cuál es su experiencia en este asunto y qué, según usted, es el mejor candidato para hacer un buen sistema de manejo de excepciones para un idioma?


EDITAR: Pocos minutos después de escribir esta pregunta, me encontré con esta publicación , ¡espeluznante!

Newtopian
fuente
2
"Sufrirá los mismos problemas que las excepciones comprobadas como ser tedioso y constantemente en su cara": en realidad no: con el soporte de lenguaje adecuado solo tiene que programar el "camino del éxito", con la maquinaria del lenguaje subyacente encargada de propagarse errores
Giorgio
"Un lenguaje debe tener un medio para documentar excepciones que una API puede lanzar". - weeeel. En C ++ "nosotros" aprendimos que esto realmente no funciona. Todo lo que realmente puede hacer es indicar si una API puede lanzar alguna excepción. (Eso realmente corta una historia larga, pero creo que mirar la noexcepthistoria en C ++ puede proporcionar muy buenas ideas para EH en C # y Java también.)
Martin Ba

Respuestas:

10

En los primeros días de C ++ descubrimos que sin algún tipo de programación genérica, los lenguajes fuertemente tipados eran extremadamente difíciles de manejar. También descubrimos que las excepciones verificadas y la programación genérica no funcionaban bien juntas, y las excepciones verificadas fueron esencialmente abandonadas.

Los tipos de devolución de varios conjuntos son excelentes, pero no reemplazan las excepciones. Sin excepciones, el código está lleno de ruido de comprobación de errores.

El otro problema con las excepciones marcadas es que un cambio en las excepciones generadas por una función de bajo nivel fuerza una cascada de cambios en todas las personas que llaman, y sus llamadas, y así sucesivamente. La única forma de evitar esto es que cada nivel de código capture las excepciones lanzadas por los niveles inferiores y las envuelva en una nueva excepción. Nuevamente, terminas con un código muy ruidoso.

Kevin Cline
fuente
2
Los genéricos ayudan a resolver toda una clase de errores que se deben principalmente a una limitación del soporte del lenguaje al paradigma OO. sin embargo, las alternativas parecen ser tener un código que en su mayoría realiza la verificación de errores o que se ejecuta con la esperanza de que nada salga mal. O tienes situaciones excepcionales constantemente en tu cara o vives en una tierra de ensueño de conejitos blancos y esponjosos que se vuelven realmente feos cuando sueltas un lobo feroz en el medio.
Newtopian
3
+1 para el problema en cascada. Cualquier sistema / arquitectura que dificulte el cambio solo conduce a parches de mono y sistemas desordenados, sin importar cuán bien diseñados estén los autores.
Matthieu M.
2
@Newtopian: las plantillas hacen cosas que no se pueden hacer en estricta orientación a los objetos, como proporcionar seguridad de tipo estático para contenedores genéricos.
David Thornley
2
Me gustaría ver un sistema de excepción con un concepto de "excepciones verificadas", pero uno muy diferente al de Java. La comprobación no debe ser un atributo de un tipo de excepción , sino sitios de lanzamiento, sitios de captura e instancias de excepción; Si un método se anuncia como una excepción marcada, eso debería tener dos efectos: (1) la función debe manejar un "lanzamiento" de la excepción marcada haciendo algo especial en el retorno (por ejemplo, establecer el indicador de acarreo, etc., dependiendo de la plataforma exacta) para qué código de llamada debería estar preparado.
Supercat
77
"Sin excepciones, el código está lleno de ruido de verificación de errores": no estoy seguro de esto: en Haskell puede usar mónadas para esto y todo el ruido de verificación de errores se ha ido. El ruido introducido por los "tipos de retorno de estado múltiple" es más una limitación del lenguaje de programación que de la solución en sí misma.
Giorgio
9

Durante mucho tiempo los lenguajes OO, el uso de excepciones ha sido el estándar de facto para comunicar errores. Pero los lenguajes de programación funcionales ofrecen la posibilidad de un enfoque diferente, por ejemplo, usando mónadas (que no he estado usando), o la "Programación orientada al ferrocarril" más liviana, como lo describe Scott Wlaschin.

Realmente es una variante del tipo de resultado multiestado.

  • Una función devuelve un éxito o un error. No puede devolver ambos (como es el caso con una tupla).
  • Todos los posibles errores se han documentado sucintamente (al menos en F # con tipos de resultados como uniones discriminadas).
  • La persona que llama no puede usar el resultado sin tener en cuenta si el resultado fue un éxito o un fracaso.

El tipo de resultado podría declararse así

type Result<'TSuccess,'TFail> =
| Success of 'TSuccess
| Fail of 'TFail

Entonces, el resultado de una función que devuelve este tipo sería a Successo a Failtype. No puede ser ambos.

En lenguajes de programación orientados más imperativos, este tipo de estilo podría requerir una gran cantidad de código en el sitio de la persona que llama. Pero la programación funcional le permite construir funciones de enlace u operadores para vincular múltiples funciones para que la verificación de errores no ocupe la mitad del código. Como ejemplo:

// Create an updateUser function that takes an id, and new state
// as input, and updates an existing user.
let updateUser id input =
    validateInput input
    >>= loadUser id
    >>= updateUser input
    >>= saveUser id
    >>= notifyAboutUserUpdated

La updateUserfunción llama a cada una de estas funciones en sucesión, y cada una de ellas podría fallar. Si todos tienen éxito, se devuelve el resultado de la última función llamada. Si una de las funciones falla, el resultado de esa función será el resultado de la updateUserfunción general . Todo esto lo maneja el operador personalizado >> =.

En el ejemplo anterior, los tipos de error podrían ser

type UserValidationErrorType =
| InvalidEmail of string
| MissingFirstName of string
... etc

type DbErrorType =
| RecordNotFound of int
| ConcurrencyError of int

type UpdateUserErrorType =
| InvalidInput of UserValidationErrorType
| DbError of DbErrorType

Si la persona que llama updateUserno maneja explícitamente todos los posibles errores de la función, el compilador emitirá una advertencia. Entonces tienes todo documentado.

En Haskell, hay una donotación que puede hacer que el código sea aún más limpio.

Pete
fuente
2
Muy buena respuesta y referencias (programación orientada al ferrocarril), +1. Es posible que desee mencionar la donotación de Haskell , que hace que el código resultante sea aún más limpio.
Giorgio
1
@Giorgio: lo hice ahora, pero no he trabajado con Haskell, solo F #, por lo que realmente no pude escribir mucho al respecto. Pero podría agregar a la respuesta si lo desea.
Pete
Gracias, escribí un pequeño ejemplo, pero como no era lo suficientemente pequeño como para agregarlo a su respuesta, escribí una respuesta completa (con información adicional).
Giorgio
2
El Railway Oriented Programmingcomportamiento es exactamente monádico.
Daenyth
5

La respuesta de Pete me parece muy buena y me gustaría agregar un poco de consideración y un ejemplo. Una discusión muy interesante sobre el uso de excepciones frente a la devolución de valores de error especiales se puede encontrar en Programación en ML estándar, por Robert Harper , al final de la Sección 29.3, página 243, 244.

El problema es implementar una función parcial que fdevuelve un valor de algún tipo t. Una solución es hacer que la función tenga tipo

f : ... -> t

y lanzar una excepción cuando no hay resultado posible. La segunda solución es implementar una función con tipo

f : ... -> t option

y retorno SOME val éxito y NONEal fracaso.

Aquí está el texto del libro, con una pequeña adaptación hecha por mí mismo para hacer que el texto sea más general (el libro se refiere a un ejemplo particular). El texto modificado está escrito en cursiva .

¿Cuáles son las compensaciones entre las dos soluciones?

  1. La solución basada en tipos de opciones hace explícita en el tipo de la función fla posibilidad de falla. Esto obliga al programador a probar explícitamente la falla utilizando un análisis de caso en el resultado de la llamada. El verificador de tipo garantizará que no se pueda usar t optiondondet se espera a. La solución basada en excepciones no indica explícitamente una falla en su tipo. Sin embargo, el programador se ve obligado a manejar la falla, ya que de lo contrario se generaría un error de excepción no detectado en tiempo de ejecución, en lugar de tiempo de compilación.
  2. La solución basada en tipos de opciones requiere un análisis de caso explícito sobre el resultado de cada llamada. Si "la mayoría" de los resultados son exitosos, la verificación es redundante y, por lo tanto, excesivamente costosa. La solución basada en excepciones está libre de esta sobrecarga: está sesgada hacia el caso "normal" de devolver a t, en lugar del caso de "falla" de no devolver un resultado en absoluto. La implementación de excepciones asegura que el uso de un controlador sea más eficiente que un análisis de caso explícito en el caso de que la falla sea rara en comparación con el éxito.

[corte] En general, si la eficiencia es primordial, tendemos a preferir excepciones si la falla es una rareza, y preferimos opciones si la falla es relativamente común. Si, por otro lado, la verificación estática es primordial, entonces es ventajoso usar opciones, ya que el verificador de tipo impondrá el requisito de que el programador verifique la falla, en lugar de que el error surja solo en tiempo de ejecución.

Esto en lo que respecta a la elección entre excepciones y tipos de devolución de opciones.

Con respecto a la idea de que representar un error en el tipo de retorno conduce a verificaciones de error repartidas por todo el código: este no tiene por qué ser el caso. Aquí hay un pequeño ejemplo en Haskell que ilustra esto.

Supongamos que queremos analizar dos números y luego dividir el primero por el segundo. Por lo tanto, podría haber un error al analizar cada número o al dividir (división por cero). Entonces tenemos que verificar si hay un error después de cada paso.

import Text.Read

parseInt :: String -> Maybe Int
parseInt s = readMaybe s :: Maybe Int

safeDiv :: Int -> Int -> Maybe Int
safeDiv n d = if d /= 0 then Just (n `div` d) else Nothing

toString :: Maybe Int -> String
toString (Just i) = show i
toString Nothing  = "error"

main = do
         -- Get two lines from the terminal.
         nStr <- getLine
         dStr <- getLine

         -- Parse each string and divide.
         let r = do n <- parseInt nStr
                    d <- parseInt dStr
                    safeDiv n d

         -- Print the result.
         putStrLn $ toString r

El análisis y la división se realizan en el let ...bloque. Tenga en cuenta que al usar la Maybemónada y la donotación, solo se especifica la ruta de éxito : la semántica de la Maybemónada propaga implícitamente el valor de error ( Nothing). Sin gastos generales para el programador.

Giorgio
fuente
2
Creo que en casos como este donde desea imprimir algún tipo de mensaje de error útil, el Eithertipo sería más adecuado. ¿Qué haces si llegas Nothingaquí? Simplemente recibe el mensaje "error". No es muy útil para la depuración.
sara
1

Me he convertido en un gran admirador de las Excepciones marcadas y me gustaría compartir mi regla general sobre cuándo usarlas.

He llegado a la conclusión de que básicamente hay 2 tipos de errores con los que mi código tiene que lidiar. Hay errores que se pueden probar antes de que se ejecute el código y hay errores que no se pueden probar antes de que se ejecute el código. Un ejemplo simple de un error que es comprobable antes de que el código se ejecute en una NullPointerException.

//... bad code below.  the runnable variable
// tries to call the run() method before the variable
// is instantiated.  Running the code below will cause
// a NullPointerException.
Runnable runnable = null;
runnable.run();

Una prueba simple podría haber evitado el error como ...

Runnable runnable = null;
...
if (runnable != null)
{   runnable.run(); }

Hay momentos en la informática en los que puede ejecutar 1 o más pruebas antes de ejecutar el código para asegurarse de que está seguro Y TODAVÍA OBTENDRÁ UNA EXCEPCIÓN. Por ejemplo, puede probar un sistema de archivos para asegurarse de que haya suficiente espacio en el disco duro antes de escribir sus datos en la unidad. En un sistema operativo multiprocesamiento, como los que se usan hoy en día, su proceso podría probar el espacio en disco y el sistema de archivos devolverá un valor que indica que hay suficiente espacio, luego un cambio de contexto a otro proceso podría escribir los bytes restantes disponibles para el funcionamiento sistema. Cuando el contexto del sistema operativo vuelve a su proceso en ejecución donde escribe sus contenidos en el disco, se producirá una Excepción simplemente porque no hay suficiente espacio en disco en el sistema de archivos.

Considero el escenario anterior como un caso perfecto para una excepción marcada. Es una excepción en el código que te obliga a lidiar con algo malo a pesar de que tu código podría estar perfectamente escrito. Si elige hacer cosas malas como "tragar la excepción", usted es el mal programador. Por cierto, he encontrado casos en los que es razonable tragar la excepción, pero deje un comentario en el código sobre por qué se tragó la excepción. El mecanismo de manejo de excepciones no tiene la culpa. Comúnmente bromeo diciendo que preferiría que mi marcapasos esté escrito en un idioma que tenga Excepciones marcadas.

Hay momentos en que se hace difícil decidir si el código es comprobable o no. Por ejemplo, si está escribiendo un intérprete y se emite una SyntaxException cuando el código no se ejecuta por alguna razón sintáctica, ¿debería ser SyntaxException una excepción comprobada o (en Java) una excepción RuntimeException? Respondería si el intérprete verifica la sintaxis del código antes de que se ejecute el código, entonces la Excepción debería ser una RuntimeException. Si el intérprete simplemente ejecuta el código 'hot' y simplemente encuentra un error de sintaxis, diría que la excepción debería ser una excepción marcada.

Admitiré que no siempre estoy feliz de tener que atrapar o lanzar una excepción marcada porque hay momentos en los que no estoy seguro de qué hacer. Las excepciones marcadas son una forma de obligar a un programador a tener en cuenta el posible problema que puede ocurrir. Una de las razones por las que programo en Java es porque tiene Excepciones marcadas.

James Moliere
fuente
1
Prefiero que mi marcapasos esté escrito en un lenguaje que no tiene excepciones, y todas las líneas de código manejan los errores a través de los códigos de retorno. Cuando lanzas una excepción estás diciendo "todo salió mal" y la única forma segura de continuar el procesamiento es detener y reiniciar. Un programa que tan fácilmente termina en un estado inválido no es algo que desee para un software crítico (y Java explícitamente prohíbe su uso para software crítico en el EULA)
gbjbaanb
Usar excepción y no verificarlos versus usar el código de retorno y no verificarlos al final todo produce el mismo paro cardíaco.
Newtopian
-1

Actualmente estoy en medio de un proyecto / API bastante grande basado en OOP y he usado este diseño de las excepciones. Pero todo realmente depende de cuán profundo quieras llegar con el manejo de excepciones y similares.

ExpectedException
- AuthorisedException
- EmptySetException
- NoRemainingException
- NoRowsException
- NotFoundException
- ValidationException

UnexpectedException
- ConnectivityException
- EnvironmentException
- ProgrammerException
- SQLException

EJEMPLO

   $valid_types = array('mysql', 'oracle', 'sqlite');
       if (!in_array($type, $valid_types)) {
           throw new ecProgrammerException(
        'The database type specified, %1$s, is invalid. Must be one of: %2$s.',
    $type,
    join(', ', $valid_types)
    );
}
MattyD
fuente
11
Si se espera la excepción, no es realmente una excepción. "NoRowsException"? Suena como control de flujo para mí y, por lo tanto, un mal uso de una excepción.
quentin-starin
1
@qes: tiene sentido generar una excepción cada vez que una función no puede calcular un valor, por ejemplo, doble Math.sqrt (doble v) o User findUser (identificación larga). Esto le da a la persona que llama la libertad de capturar y manejar errores donde sea conveniente, en lugar de verificar después de cada llamada.
Kevin Cline
1
Esperado = flujo de control = antipatrón de excepción. La excepción no debe usarse para controlar el flujo. Si se espera que produzca un error para una entrada específica, simplemente se pasa como parte del valor de retorno. Entonces tenemos NANo NULL.
Eonil
1
@Eonil ... u Opción <T>
Maarten Bodewes