Uso idiomático de excepciones en C ++

16

La excepción de isocpp.org afirma que las preguntas frecuentes

No utilice throw para indicar un error de codificación en el uso de una función. Utilice el aserción u otro mecanismo para enviar el proceso a un depurador o para bloquear el proceso y recopilar el volcado de bloqueo para que el desarrollador lo depure.

Por otro lado, la biblioteca estándar define std :: logic_error y todos sus derivados, que me parecen que deben manejar, además de otras cosas, los errores de programación. ¿Pasar una cadena vacía a std :: stof (arrojará inválido_argumento) no es un error de programación? ¿Pasar una cadena que contiene caracteres diferentes de '1' / '0' a std :: bitset (arrojará inválido_argumento) no es un error de programación? ¿Llamar a std :: bitset :: set con un índice no válido (arrojará out_of_range) no es un error de programación? Si no lo son, ¿cuál es un error de programación que uno probaría? El constructor basado en cadenas std :: bitset solo existe desde C ++ 11, por lo que debería haber sido diseñado con el uso idiomático de excepciones en mente. Por otro lado, he tenido gente que me dice que logic_error básicamente no debería usarse en absoluto.

Otra regla que aparece con frecuencia con excepciones es "usar solo excepciones en circunstancias excepcionales". Pero, ¿cómo se supone que una función de biblioteca sabe qué circunstancias son excepcionales? Para algunos programas, no poder abrir un archivo puede ser excepcional. Para otros, no poder asignar memoria puede no ser excepcional. Y hay cientos de casos intermedios. ¿Ser incapaz de crear un socket? ¿No puede conectarse o escribir datos en un socket o un archivo? ¿No se puede analizar la entrada? Podría ser excepcional, podría no serlo. La función en sí misma definitivamente no puede saber en general, no tiene idea de en qué tipo de contexto se llama.

Entonces, ¿cómo se supone que debo decidir si debo usar excepciones o no para una función en particular? Me parece que la única forma realmente consistente es usarlos para el manejo de todos y cada uno de los errores, o para nada. Y si estoy usando la biblioteca estándar, esa elección fue hecha por mí.

cooky451
fuente
66
Tienes que leer esa entrada de preguntas frecuentes con mucho cuidado. Solo se aplica a errores de codificación, no a datos no válidos, a la desreferenciación de un objeto nulo o a cualquier cosa que tenga que ver con problemas de tiempo de ejecución generales. En general, las afirmaciones son sobre identificar cosas que nunca deberían suceder. Para todo lo demás, hay excepciones, códigos de error, etc.
Robert Harvey
1
@RobertHarvey esa definición todavía tiene el mismo problema: si algo se puede resolver sin intervención humana o no, solo las capas superiores de un programa lo saben.
cooky451
1
Te estás obsesionando con la legalización. Evalúa los pros y los contras, y toma tu propia decisión. Además, el último párrafo de su pregunta ... No considero que sea evidente en absoluto. Tu pensamiento es muy blanco y negro, cuando la verdad probablemente esté más cerca de algunos tonos de gris.
Robert Harvey
44
¿Has intentado hacer alguna investigación antes de hacer esta pregunta? Los modismos de manejo de errores de C ++ se discuten casi con toda seguridad en detalles nauseabundos en la web. Una referencia a una entrada de Preguntas Frecuentes no hace una buena investigación. Después de investigar, aún tendrá que decidirse. No empiece a explicar cómo nuestras escuelas de programación aparentemente están creando robots de codificación de patrones de software sin sentido que no saben pensar por sí mismos.
Robert Harvey
2
Lo que da credibilidad a mi teoría de que tal regla puede no existir realmente. He invitado a algunas personas de The C ++ Lounge para ver si pueden responder a su pregunta, aunque cada vez que entro allí, su consejo es "Deje de usar C ++, le fregará el cerebro". Así que tome sus consejos bajo su propio riesgo.
Robert Harvey

Respuestas:

15

Primero, me siento obligado a señalar que std::exceptiony sus hijos fueron diseñados hace mucho tiempo. Hay una serie de partes que probablemente (casi con certeza) serían diferentes si se estuvieran diseñando hoy.

No me malinterpreten: hay partes del diseño que han funcionado bastante bien, y son muy buenos ejemplos de cómo diseñar una jerarquía de excepción para C ++ (por ejemplo, el hecho de que, a diferencia de la mayoría de las otras clases, todas comparten un raíz común).

Mirando específicamente logic_error, tenemos un poco de enigma. Por un lado, si tiene alguna opción razonable en el asunto, el consejo que citó es correcto: generalmente es mejor fallar tan rápido y ruidosamente como sea posible para que pueda ser depurado y corregido.

Sin embargo, para bien o para mal, es difícil definir la biblioteca estándar en torno a lo que generalmente debe hacer. Si definió estos para salir del programa (por ejemplo, llamar abort()) cuando se le da una entrada incorrecta, eso sería lo que siempre sucedió para esa circunstancia, y en realidad hay bastantes circunstancias bajo las cuales esto probablemente no sea realmente lo correcto. , al menos en código desplegado.

Eso se aplicaría en código con requisitos (al menos suaves) en tiempo real y una penalización mínima por una salida incorrecta. Por ejemplo, considere un programa de chat. Si está decodificando algunos datos de voz y recibe alguna entrada incorrecta, es probable que un usuario esté mucho más feliz de vivir con un milisegundo de estática en la salida que un programa que simplemente se apaga por completo. Del mismo modo, cuando se realiza la reproducción de video, puede ser más aceptable vivir produciendo valores incorrectos para algunos píxeles para un cuadro o dos que hacer que el programa salga sumariamente porque la secuencia de entrada se corrompió.

En cuanto a si se deben usar excepciones para informar ciertos tipos de errores: tiene razón: la misma operación podría calificar como una excepción o no, dependiendo de cómo se esté utilizando.

Por otro lado, también está equivocado: el uso de la biblioteca estándar no (necesariamente) obliga a tomar esa decisión. En el caso de abrir un archivo, normalmente estaría usando un iostream. Iostreams tampoco es exactamente el último y mejor diseño, pero en este caso hacen las cosas bien: le permiten configurar un modo de error, por lo que puede controlar si no se abre un archivo con el resultado de una excepción o no. Por lo tanto, si tiene un archivo que es realmente necesario para su aplicación, y no abrirlo significa que debe tomar algunas medidas correctivas serias, entonces puede hacer que arroje una excepción si no puede abrir ese archivo. Para la mayoría de los archivos, que intentará abrir, si no existen o no son accesibles, simplemente fallarán (este es el valor predeterminado).

En cuanto a cómo decides: no creo que haya una respuesta fácil. Para bien o para mal, las "circunstancias excepcionales" no siempre son fáciles de medir. Si bien hay casos que son fáciles de decidir, deben ser [un] excepcionales, hay (y probablemente siempre lo serán) casos en los que está abierto a dudas o requiere un conocimiento del contexto que está fuera del dominio de la función en cuestión. Para casos como ese, al menos puede valer la pena considerar un diseño más o menos similar a esta parte de iostreams, donde el usuario puede decidir si la falla resulta en una excepción o no. Alternativamente, es completamente posible tener dos conjuntos separados de funciones (o clases, etc.), una de las cuales arrojará excepciones para indicar una falla, y la otra utiliza otros medios. Si vas por esa ruta,

Jerry Coffin
fuente
9

El constructor basado en cadenas std :: bitset solo existe desde C ++ 11, por lo que debería haber sido diseñado con el uso idiomático de excepciones en mente. Por otro lado, he tenido gente que me dice que logic_error básicamente no debería usarse en absoluto.

Puede que no lo creas, pero, bueno, diferentes codificadores de C ++ no están de acuerdo. Es por eso que las preguntas frecuentes dicen una cosa, pero la biblioteca estándar no está de acuerdo.

Las preguntas frecuentes recomiendan fallar porque será más fácil de depurar. Si se bloquea y obtiene un volcado de núcleo, tendrá el estado exacto de su aplicación. Si lanzas una excepción, perderás mucho de ese estado.

La biblioteca estándar toma la teoría de que dar al codificador la capacidad de detectar y manejar el error es más importante que la depuración.

Podría ser excepcional, podría no serlo. La función en sí misma definitivamente no puede saber en general, no tiene idea de en qué tipo de contexto se llama.

La idea aquí es que si su función no sabe si la situación es excepcional o no, no debería arrojar una excepción. Debería devolver un estado de error a través de algún otro mecanismo. Una vez que llega a un punto en el programa en el que sabe que el estado es excepcional, debe lanzar la excepción.

Pero esto tiene su propio problema. Si una función devuelve un estado de error, es posible que no recuerde verificarlo y el error pasará silenciosamente. Esto lleva a algunas personas a abandonar las excepciones, son una regla excepcional a favor de lanzar excepciones para cualquier tipo de estado de error.

En general, el punto clave es que diferentes personas tienen diferentes ideas sobre cuándo lanzar excepciones. No vas a encontrar una sola idea coherente. A pesar de que algunas personas afirmarán dogmáticamente que esta o esa es la forma correcta de manejar las excepciones, no existe una única teoría acordada.

Puedes lanzar excepciones:

  1. Nunca
  2. En todas partes
  3. Solo en errores de programador
  4. Nunca en errores del programador
  5. Solo durante fallas no rutinarias (excepcionales)

y encuentre a alguien en Internet que esté de acuerdo con usted. Tendrás que adoptar el estilo que funcione para ti.

Winston Ewert
fuente
Posiblemente valga la pena señalar que la sugerencia de usar solo excepciones cuando las circunstancias son realmente excepcionales ha sido ampliamente promovida por personas que enseñan sobre idiomas en los que las excepciones tienen un bajo rendimiento. C ++ no es uno de esos lenguajes.
Jules
1
@Jules: ahora ese (rendimiento) ciertamente merece una respuesta propia en la que respaldes tu reclamo. El rendimiento de excepción de C ++ es ciertamente un problema, puede ser más, tal vez menos que en otros lugares, pero decir que "C ++ no es uno de esos lenguajes [donde las excepciones tienen un rendimiento deficiente]" es ciertamente discutible.
Martin Ba
1
@MartinBa: en comparación con, por ejemplo, Java, el rendimiento de la excepción C ++ es mucho más rápido. Los puntos de referencia sugieren que el rendimiento de lanzar una excepción en 1 nivel es aproximadamente 50 veces más lento que manejar un valor de retorno en C ++, en comparación con más de 1000 veces más lento en Java. Los consejos escritos para Java en este caso no deberían aplicarse a C ++ sin pensarlo más porque hay una diferencia de rendimiento de más de un orden de magnitud entre los dos. Quizás debería haber escrito "rendimiento extremadamente bajo" en lugar de "bajo rendimiento".
Julio
1
@Jules: gracias por estos números. (ninguna fuente?) me puedo creer que ellos, porque Java (y C #) necesidad de capturar el seguimiento de la pila, lo que sin duda parece como que podría ser muy caro. Todavía creo que su respuesta inicial es un poco engañosa, porque incluso una desaceleración de 50x es bastante fuerte, creo, especialmente. en un lenguaje orientado al rendimiento como C ++.
Martin Ba
2

Se han escrito muchas otras buenas respuestas, solo quiero agregar un breve punto.

La respuesta tradicional, especialmente cuando se escribieron las preguntas frecuentes de ISO C ++, compara principalmente "excepción C ++" versus "código de retorno de estilo C". Una tercera opción, "devolver algún tipo de valor compuesto, por ejemplo, a structo union, o hoy en día, boost::varianto (propuesto) std::expected, no se considera.

Antes de C ++ 11, la opción "devolver un tipo compuesto" solía ser muy débil. Debido a que no había semántica de movimiento, copiar cosas dentro y fuera de una estructura era potencialmente muy costoso. Era extremadamente importante en ese punto del lenguaje diseñar su código hacia RVO para obtener el mejor rendimiento. Las excepciones fueron como una manera fácil de devolver de manera efectiva un tipo compuesto, cuando de lo contrario eso sería bastante difícil.

En mi opinión, después de C ++ 11, esta opción "devolver una unión discriminada", similar al idioma Result<T, E>utilizado en Rust hoy en día, debería ser más frecuente en el código C ++. A veces es realmente un estilo más simple y más conveniente de indicar errores. Con excepciones, siempre existe la posibilidad de que las funciones que no se lanzaron antes de repente puedan comenzar a lanzarse después de un refactorizador, y los programadores no siempre documentan esas cosas tan bien. Cuando el error se indica como parte del valor de retorno en una unión discriminada, reduce en gran medida la posibilidad de que el programador simplemente ignore el código de error, que es la crítica habitual al manejo de errores de estilo C.

Generalmente Result<T, E> funciona como un impulso opcional. Puede probar, utilizando operator bool, si es un valor o un error. Y luego use say operator *para acceder al valor, o alguna otra función "get". Por lo general, ese acceso no está marcado, por velocidad. Pero puede hacerlo de modo que en una compilación de depuración, el acceso se compruebe y una aserción se asegure de que realmente haya un valor y no un error. De esta manera, cualquiera que no verifique los errores correctamente obtendrá una afirmación difícil en lugar de un problema más insidioso.

Una ventaja adicional es que, a diferencia de las excepciones en las que, si no se detecta, simplemente vuela la pila a cierta distancia arbitraria, con este estilo, cuando una función comienza a indicar un error donde no lo había hecho antes, no puede compilar a menos que el el código se cambia para manejarlo. Esto aumenta los problemas: el problema tradicional de la "excepción no detectada" se parece más a un error en tiempo de compilación que a un error en tiempo de ejecución.

Me he convertido en un gran admirador de este estilo. Por lo general, hoy en día uso esto o excepciones. Pero trato de limitar las excepciones a problemas mayores. Para algo así como un error de análisis, intento volver, expected<T>por ejemplo. Cosas como std::stoiy boost::lexical_castque arrojan una excepción de C ++ en caso de algún problema relativamente menor "la cadena no se puede convertir en número" me parecen de muy mal gusto hoy en día.

Chris Beck
fuente
1
std::expectedsigue siendo una propuesta no aceptada, ¿verdad?
Martin Ba
Tienes razón, supongo que aún no se acepta. Pero hay varias implementaciones de código abierto flotando, y creo que he implementado la mía un par de veces. Es menos complicado que hacer un tipo de variante ya que solo hay dos estados posibles. Las principales consideraciones de diseño son: ¿qué interfaz exacta desea y desea que sea como la esperada <T> de Andrescu donde se supone que el objeto de error es exception_ptr, o simplemente desea usar algún tipo de estructura o algo así? como eso.
Chris Beck
La charla de Andrei Alexandrescu está aquí: channel9.msdn.com/Shows/Going+Deep/… Muestra en detalle cómo construir una clase como esta y qué consideraciones puede tener.
Chris Beck
La propuesta [[nodiscard]] attributeserá útil para este enfoque de manejo de errores, ya que garantiza que no ignore simplemente el resultado del error por accidente.
CodesInChaos
- Sí, sabía la conversación de AA. Encontré el diseño bastante extraño ya que para descomprimirlo ( except_ptr) había que lanzar una excepción internamente. Personalmente, creo que dicha herramienta debería funcionar completamente independiente de las ejecuciones. Solo un comentario.
Martin Ba
1

Este es un tema muy subjetivo, ya que es parte del diseño. Y debido a que el diseño es básicamente arte, prefiero discutir estas cosas en lugar de debatir (no estoy diciendo que estés debatiendo).

Para mí, los casos excepcionales son de dos tipos: los que tratan con recursos y los que tratan con operaciones críticas. Lo que puede considerarse crítico depende del problema en cuestión y, en muchos casos, del punto de vista del programador.

La falta de adquisición de recursos es un candidato principal para lanzar excepciones. El recurso puede ser memoria, archivo, conexión de red o cualquier otra cosa según su problema y plataforma. Ahora, ¿el hecho de no liberar un recurso garantiza una excepción? Bueno, eso nuevamente depende. No he hecho nada en lo que falló la liberación de memoria, así que no estoy seguro de ese escenario. Sin embargo, la eliminación de archivos como parte de la liberación de recursos puede fallar, y ha fallado para mí, y esa falla generalmente está vinculada a otro proceso que la ha mantenido abierta en una aplicación multiproceso. Supongo que otros recursos podrían fallar durante el lanzamiento como podría hacerlo un archivo, y generalmente es la falla de diseño lo que provoca este problema, por lo que solucionarlo sería mejor que lanzar una excepción.

Luego viene la actualización de recursos. Este punto, al menos para mí, está estrechamente relacionado con el aspecto de operaciones críticas de la aplicación. Imagine una Employeeclase con una función UpdateDetails(std::string&)que modifica los detalles en función de una cadena separada por comas dada. Similar a la falla de la liberación de memoria, me resulta difícil imaginar que la asignación de valores de las variables miembro falla debido a mi falta de experiencia en los dominios en los que esto podría suceder. Sin embargo, UpdateDetailsAndUpdateFile(std::string&)se espera que falle una función como la que hace como su nombre indica. Esto es lo que yo llamo operación crítica.

Ahora, debe ver si la llamada operación crítica justifica una excepción. Quiero decir, ¿se está actualizando el archivo al final, como en el destructor, o es simplemente una llamada paranoica realizada después de cada actualización? ¿Existe un mecanismo alternativo que escriba objetos no escritos regularmente? Lo que digo es que hay que evaluar la importancia de la operación.

Obviamente, hay muchas operaciones críticas que no están vinculadas a los recursos. Si UpdateDetails()se le dan datos incorrectos, no actualizará los detalles y la falla debe darse a conocer, por lo que arrojaría una excepción aquí. Pero imagina una función como GiveRaise(). Ahora, si dicho empleado tiene la suerte de tener un jefe de pelo puntiagudo y no obtendrá un aumento (en términos de programación, el valor de alguna variable evita que esto suceda), la función esencialmente ha fallado. ¿Lanzarías una excepción aquí? Lo que digo es que tienes que evaluar la necesidad de una excepción.

Para mí, la coherencia es en términos de mi enfoque de diseño que la usabilidad de mis clases. Lo que quiero decir es que no pienso en términos de "todas las funciones de Get deben hacer esto y todas las funciones de Actualización deben hacerlo", sino ver si una función en particular apela a cierta idea dentro de mi enfoque. En su superficie, las clases podrían parecer un poco "al azar", pero cada vez que los usuarios (en su mayoría colegas de otros equipos) despotrican o preguntan al respecto, se lo explicaré y parecerán satisfechos.

Veo a muchas personas que básicamente reemplazan los valores de retorno con excepciones porque están usando C ++ y no C, y eso me da una 'separación agradable del manejo de errores', etc., y me insta a dejar de 'mezclar' idiomas, etc. ese tipo de personas.

vin
fuente
1

En primer lugar, como otros han dicho, las cosas no son que claro corte en C ++, en mi humilde opinión sobre todo debido a los requisitos y las restricciones son algo más variado en C ++ que otros lenguajes, esp. C # y Java, que tienen problemas de excepción "similares".

Expondré en el ejemplo std :: stof:

pasar una cadena vacía a std :: stof (arrojará inválido_argumento) no es un error de programación

El contrato básico , tal como lo veo, de esta función es que intenta convertir su argumento en flotante, y cualquier falla al hacerlo se informa por una excepción. Ambas posibles excepciones se derivan, logic_errorpero no en el sentido de error del programador, sino en el sentido de que "la entrada no se puede convertir en flotante".

Aquí, uno puede decir que logic_errorse usa a para indicar que, dada esa entrada (tiempo de ejecución), siempre es un error intentar convertirlo, pero es el trabajo de la función determinar eso y decírselo (a través de una excepción).

Nota al margen: en esa vista, a runtime_error podría verse como algo que, dada la misma entrada a una función, teóricamente podría tener éxito para diferentes ejecuciones. (por ejemplo, una operación de archivo, acceso a base de datos, etc.)

Nota adicional: La biblioteca de expresiones regulares de C ++ eligió derivar su error, runtime_erroraunque hay casos en los que podría clasificarse de la misma manera que aquí (patrón de expresiones regulares no válido).

Esto solo muestra, en mi humilde opinión, que la agrupación logic_o el runtime_error es bastante confuso en C ++ y realmente no ayuda mucho en el caso general (*): si necesita manejar errores específicos, probablemente necesite atrapar más bajo que los dos.

(*): Eso no quiere decir que una sola pieza de código no debe ser consistente, pero si usted lanza runtime_o logic_o custom_tantos no es realmente tan importante, creo.


Para comentar sobre ambos stofy bitset:

Ambas funciones toman cadenas como argumento, y en ambos casos es:

  • no es trivial verificar si la persona que llama es válida (por ejemplo, en el peor de los casos, tendría que replicar la lógica de la función; en el caso de bitset, no está claro de inmediato si la cadena vacía es válida, así que deje que el ctor decida)
  • Ya es responsabilidad de la función "analizar" la cadena, por lo que ya tiene que validar la cadena, por lo que tiene sentido que informe un error para "usar" la cadena de manera uniforme (y en ambos casos esto es una excepción) .

La regla que aparece con frecuencia con excepciones es "usar solo excepciones en circunstancias excepcionales". Pero, ¿cómo se supone que una función de biblioteca sabe qué circunstancias son excepcionales?

Esta declaración tiene, en mi humilde opinión, dos raíces:

Rendimiento : si se llama a una función en una ruta crítica, y el caso "excepcional" no es excepcional, es decir, una cantidad significativa de pases implicará lanzar una excepción, entonces pagar cada vez por la maquinaria de desenrollado de excepción no tiene sentido , y puede ser demasiado lento.

Localidad del manejo de errores : si se invoca una función y la excepción se captura y procesa de inmediato, entonces tiene poco sentido lanzar una excepción, ya que el manejo de errores será más detallado con el catchque con un if.

Ejemplo:

float readOrDefault;
try {
  readOrDefault = stof(...);
} catch(std::exception&) {
  // discard execption, just use default value
  readOrDefault = 3.14f; // 3.14 is the default value if cannot be read
}

Aquí es donde entran en juego funciones como TryParsevs Parse.: una versión para cuando el código local espera que la cadena analizada sea válida, una versión cuando el código local supone que realmente se espera (es decir, no excepcional) que el análisis falle.

De hecho, stofes solo (definido como) un contenedor strtof, así que si no quieres excepciones, usa esa.


Entonces, ¿cómo se supone que debo decidir si debo usar excepciones o no para una función en particular?

En mi humilde opinión, tienes dos casos:

  • Función similar a "Biblioteca" (reutilizada a menudo en diferentes contextos): Básicamente no puede decidir. Posiblemente proporcione ambas versiones, tal vez una que informe un error y una envoltura que convierta el error devuelto en una excepción.

  • La función "Aplicación" (específica para un blob de código de aplicación, puede reutilizarse en parte, pero está restringida por el estilo de manejo de errores de las aplicaciones, etc.): Aquí, a menudo debería ser bastante claro. Si la (s) ruta (s) de código que llama a las funciones manejan las excepciones de una manera sensata y útil, use las excepciones para informar cualquier error (pero vea a continuación) . Si el código de la aplicación se lee y escribe más fácilmente para un estilo de retorno de error, utilícelo por todos los medios.

Por supuesto, habrá lugares intermedios, solo use lo que necesita y recuerde YAGNI.


Por último, creo que debería volver a la declaración de preguntas frecuentes,

No utilice throw para indicar un error de codificación en el uso de una función. Utilice el aserción u otro mecanismo para enviar el proceso a un depurador o para bloquear el proceso ...

Me suscribo a esto para todos los errores que son una clara indicación de que algo está muy mal o que el código de llamada claramente no sabía lo que estaba haciendo.

Pero cuando esto es apropiado, a menudo es altamente específico de la aplicación, por lo tanto, consulte el dominio de biblioteca anterior vs.

Esto recae en la pregunta sobre si y cómo validar las condiciones previas de llamada , pero no voy a entrar en eso, responder ya demasiado tiempo :-)

Martin Ba
fuente