El problema:
Desde hace mucho tiempo, estoy preocupado por el exceptions
mecanismo, porque siento que realmente no resuelve lo que debería.
RECLAMACIÓN: Hay largos debates afuera sobre este tema, y la mayoría de ellos tienen dificultades para comparar exceptions
vs devolver un código de error. Este definitivamente no es el tema aquí.
Intentando definir un error, estaría de acuerdo con CppCoreGuidelines, de Bjarne Stroustrup & Herb Sutter
Un error significa que la función no puede lograr su propósito anunciado
RECLAMACIÓN: El exception
mecanismo es un lenguaje semántico para manejar errores.
RECLAMACIÓN: Para mí, no hay "excusa" para que una función no logre una tarea: o definimos erróneamente las condiciones previas / posteriores para que la función no pueda garantizar resultados, o algún caso excepcional específico no se considera lo suficientemente importante como para pasar tiempo en el desarrollo una solución. Teniendo en cuenta que, en mi opinión, la diferencia entre el código normal y el manejo del código de error es (antes de la implementación) una línea muy subjetiva.
RECLAMACIÓN: El uso de excepciones para indicar cuándo no se mantiene una condición previa o posterior es otro propósito del exception
mecanismo, principalmente para fines de depuración. No apunto este uso de exceptions
aquí.
En muchos libros, tutoriales y otras fuentes, tienden a mostrar el manejo de errores como una ciencia bastante objetiva, que se resuelve exceptions
y solo se necesita catch
para tener un software robusto, capaz de recuperarse de cualquier situación. Pero mis varios años como desarrollador me hacen ver el problema desde un enfoque diferente:
- Los programadores tienden a simplificar su tarea lanzando excepciones cuando el caso específico parece demasiado raro para implementarlo con cuidado. Los casos típicos de esto son: problemas de falta de memoria, problemas de disco lleno, problemas de archivos corruptos, etc. Esto puede ser suficiente, pero no siempre se decide desde un nivel arquitectónico.
- Los programadores tienden a no leer cuidadosamente la documentación sobre las excepciones en las bibliotecas, y generalmente no saben qué y cuándo se lanza una función. Además, incluso cuando lo saben, realmente no los manejan.
- Los programadores tienden a no detectar excepciones lo suficientemente temprano, y cuando lo hacen, es principalmente para iniciar sesión y lanzar más. (consulte el primer punto).
Esto tiene dos consecuencias:
- Los errores que ocurren con frecuencia se detectan temprano en el desarrollo y se depuran (lo cual es bueno).
- Las excepciones raras no se gestionan y hacen que el sistema se bloquee (con un buen mensaje de registro) en la página de inicio del usuario. Algunas veces se informa el error, o ni siquiera.
Teniendo en cuenta que, el objetivo principal de un mecanismo de error de la OMI debería ser:
- Hacer visible en el código donde no se gestiona un caso específico.
- Comunique el tiempo de ejecución del problema al código relacionado (al menos la persona que llama) cuando ocurra esta situación.
- Proporciona mecanismos de recuperación.
El principal defecto de la exception
semántica como mecanismo de manejo de errores es la OMI: es fácil ver dónde throw
está a en el código fuente, pero no es absolutamente evidente saber si una función específica podría arrojarse al mirar la declaración. Esto trae todo el problema que presenté anteriormente.
El lenguaje no aplica y verifica el código de error tan estrictamente como lo hace para otros aspectos del lenguaje (por ejemplo, tipos fuertes de variables)
Un intento de solución
Con la intención de mejorar esto, desarrollé un sistema de manejo de errores muy simple, que intenta poner el manejo de errores en el mismo nivel de importancia que el código normal.
La idea es:
- Cada función (relevante) recibe una referencia a un
success
objeto muy ligero, y puede establecerlo en un estado de error en el caso. El objeto es muy ligero hasta que se guarda un error con el texto. - Se alienta a una función a omitir su tarea si el objeto proporcionado ya contiene un error.
- Un error nunca debe anularse.
El diseño completo obviamente considera a fondo cada aspecto (aproximadamente 10 páginas), también cómo aplicarlo a OOP.
Ejemplo de la Success
clase:
class Success
{
public:
enum SuccessStatus
{
ok = 0, // All is fine
error = 1, // Any error has been reached
uninitialized = 2, // Initialization is required
finished = 3, // This object already performed its task and is not useful anymore
unimplemented = 4, // This feature is not implemented already
};
Success(){}
Success( const Success& v);
virtual ~Success() = default;
virtual Success& operator= (const Success& v);
// Comparators
virtual bool operator==( const Success& s)const { return (this->status==s.status && this->stateStr==s.stateStr);}
virtual bool operator!=( const Success& s)const { return (this->status!=s.status || this->stateStr==s.stateStr);}
// Retrieve if the status is not "ok"
virtual bool operator!() const { return status!=ok;}
// Retrieve if the status is "ok"
operator bool() const { return status==ok;}
// Set a new status
virtual Success& set( SuccessStatus status, std::string msg="");
virtual void reset();
virtual std::string toString() const{ return stateStr;}
virtual SuccessStatus getStatus() const { return status; }
virtual operator SuccessStatus() const { return status; }
private:
std::string stateStr;
SuccessStatus status = Success::ok;
};
Uso:
double mySqrt( Success& s, double v)
{
double result = 0.0;
if (!s) ; // do nothing
else if (v<0.0) s.set(Error, "Square root require non-negative input.");
else result = std::sqrt(v);
return result;
}
Success s;
mySqrt(s, 144.0);
otherStuff(s);
saveStuff(s);
if (s) /*All is good*/;
else cout << s << endl;
Lo utilicé en muchos de mis (propios) códigos y obligó al programador (a mí) a pensar más sobre posibles casos excepcionales y cómo resolverlos (bien). Sin embargo, tiene una curva de aprendizaje y no se integra bien con el código que ahora lo usa.
La pregunta
Me gustaría entender mejor las implicaciones de usar tal paradigma en un proyecto:
- ¿Es correcta la premisa del problema? o ¿Me perdí algo relevante?
- ¿Es la solución una buena idea arquitectónica? o el precio es demasiado alto?
EDITAR:
Comparación entre métodos:
//Exceptions:
// Incorrect
File f = open("text.txt"); // Could throw but nothing tell it! Will crash
save(f);
// Correct
File f;
try
{
f = open("text.txt");
save(f);
}
catch( ... )
{
// do something
}
//Error code (mixed):
// Incorrect
File f = open("text.txt"); //Nothing tell you it may fail! Will crash
save(f);
// Correct
File f = open("text.txt");
if (f) save(f);
//Error code (pure);
// Incorrect
File f;
open(f, "text.txt"); //Easy to forget the return value! will crash
save(f);
//Correct
File f;
Error er = open(f, "text.txt");
if (!er) save(f);
//Success mechanism:
Success s;
File f;
open(s, "text.txt");
save(s, f); //s cannot be avoided, will never crash.
if (s) ... //optional. If you created s, you probably don't forget it.
fuente
Respuestas:
El manejo de errores es quizás la parte más difícil de un programa.
En general, darse cuenta de que hay una condición de error es fácil; sin embargo, señalarlo de una manera que no se pueda eludir y manejarlo adecuadamente (ver los niveles de seguridad de excepción de Abrahams ) es realmente difícil.
En C, los errores de señalización se realizan mediante un código de retorno, que es isomorfo a su solución.
C ++ introdujo excepciones debido a la deficiencia de tal enfoque; a saber, solo funciona si las personas que llaman recuerdan verificar si se produjo un error o no, y de lo contrario, falla de manera horrible. Cada vez que te encuentras diciendo "Está bien siempre y cuando ..." tienes un problema; los humanos no son tan meticulosos, incluso cuando les importa.
El problema, sin embargo, es que las excepciones tienen sus propios problemas. A saber, flujo de control invisible / oculto. Esto estaba destinado: ocultar el caso de error para que la lógica del código no se ofusque por la placa de control de errores. Hace que el "camino feliz" sea mucho más claro (¡y rápido!), A costa de hacer que los caminos de error sean casi inescrutables.
Me resulta interesante observar cómo otros idiomas abordan el problema:
C ++ solía tener alguna forma de excepciones comprobadas, es posible que haya notado que se ha desaprobado y simplificado hacia un básico en su
noexcept(<bool>)
lugar: o se declara que una función posiblemente se lanza, o se declara que nunca se debe. Las excepciones marcadas son algo problemáticas ya que carecen de extensibilidad, lo que puede causar asignaciones / anidaciones incómodas. Y jerarquías de excepciones enrevesadas (uno de los principales casos de uso de herencia virtual son las excepciones ...).En contraste, Go y Rust adoptan el enfoque de que:
Esto último es bastante evidente en que (1) nombran sus excepciones en pánico y (2) no hay una jerarquía de tipos / cláusula complicada aquí. El lenguaje no ofrece facilidades para inspeccionar el contenido de un "pánico": sin jerarquía de tipos, sin contenido definido por el usuario, solo "oops, las cosas salieron tan mal que no hay recuperación posible".
Esto efectivamente alienta a los usuarios a usar el manejo adecuado de errores, al tiempo que deja una forma fácil de rescatar en situaciones excepcionales (como: "¡espera, aún no lo he implementado!").
Por supuesto, el enfoque Ir desafortunadamente es muy similar al suyo, ya que puede olvidarse fácilmente de verificar el error ...
... sin embargo, el enfoque de Rust se centra principalmente en dos tipos:
Option
, que es similar astd::optional
,Result
, que es una variante de dos posibilidades: Ok y Err.esto es mucho más ordenado porque no hay oportunidad de usar accidentalmente un resultado sin haber verificado el éxito: si lo hace, el programa entra en pánico.
Los lenguajes FP forman su manejo de errores en construcciones que se pueden dividir en tres capas: - Functor - Aplicativo / Alternativo - Mónadas / Alternativa
Echemos un vistazo a la
Functor
clase de tipo de Haskell :En primer lugar, las clases de tipos son algo similares pero no iguales a las interfaces. Las firmas de funciones de Haskell dan un poco de miedo en un primer vistazo. Pero descifrémoslos. La función
fmap
toma una función como primer parámetro que es algo similar astd::function<a,b>
. Lo siguiente es unm a
. Puedes imaginartem
como algo asístd::vector
ym a
como algo asístd::vector<a>
. Pero la diferencia es que esom a
no dice que tiene que ser explícitamentestd:vector
. Por lo tanto, también podría ser unstd::option
. Al decirle al idioma que tenemos una instancia para la claseFunctor
de tipos para un tipo específico comostd::vector
ostd::option
, podemos usar la funciónfmap
para ese tipo. Lo mismo debe hacerse para las clases de tiposApplicative
,Alternative
yMonad
que le permite hacer cálculos con estado y posibles fallas. LaAlternative
clase de tipos implementa abstracciones de recuperación de errores. Por eso puedes decir algo así comoa <|> b
que es términoa
o términob
. Si ninguno de los dos cálculos tiene éxito, sigue siendo un error.Echemos un vistazo al
Maybe
tipo de Haskell .Esto significa que donde esperas un
Maybe a
, obtienes unoNothing
oJust a
. Al mirarfmap
desde arriba, una implementación podría verse comoLa
case ... of
expresión se llama coincidencia de patrones y se asemeja a lo que se conoce en el mundo OOPvisitor pattern
. Imagine la líneacase m of
comom.apply(...)
y los puntos es la instanciación de una clase que implementa las funciones de envío. Las líneas debajo de lacase ... of
expresión son las funciones de despacho respectivas que llevan los campos de la clase directamente al alcance por nombre. En laNothing
rama que creamosNothing
y en laJust a
rama nombramos nuestro único valora
y creamos otroJust ...
con la función de transformaciónf
aplicadaa
. Leerlo como:new Just(f(a))
.Esto ahora puede manejar cálculos erróneos mientras abstrae las comprobaciones de error reales. Existen implementaciones para las otras interfaces que hacen que este tipo de cálculos sea muy poderoso. En realidad,
Maybe
es la inspiración para Rust'sOption
-Type.En ese caso, te animo a que vuelvas a trabajar en tu
Success
clase hacia unaResult
. Alexandrescu en realidad propuso algo realmente cercano, llamadoexpected<T>
, para lo cual se hicieron propuestas estándar .Me atendré a los nombres y API de Rust simplemente porque ... está documentado y funciona. Por supuesto, Rust tiene un ingenioso
?
operador de sufijo que haría el código mucho más dulce; en C ++, usaremos la expresión deTRY
macro y declaraciones de GCC para emularlo.Nota: este
Result
es un marcador de posición. Una implementación adecuada usaría encapsulación y aunion
. Sin embargo, es suficiente para transmitir el punto.Lo que me permite escribir ( verlo en acción ):
lo cual me parece realmente genial:
Success
clase), olvidarse de buscar errores dará como resultado un error de tiempo de ejecución 1 en lugar de un comportamiento aleatorio,concepts
en el estándar. Esto haría que este tipo de programación sea mucho más placentera, ya que podríamos elegir el tipo de error. Por ejemplo, con una implementación destd::vector
como resultado, podríamos calcular todas las soluciones posibles a la vez. O podríamos elegir mejorar el manejo de errores, como usted propuso.1 Con una
Result
implementación adecuadamente encapsulada ;)Nota: a diferencia de la excepción, este peso ligero
Result
no tiene retrocesos, lo que hace que el registro sea menos eficiente; puede resultarle útil registrar al menos el número de archivo / línea en el que se genera el mensaje de error y, en general, escribir un mensaje de error enriquecido. Esto se puede complicar capturando el archivo / línea cada vez queTRY
se usa la macro, esencialmente creandolibbacktrace
la traza inversa manualmente, o usando código específico de la plataforma y bibliotecas como para enumerar los símbolos en la pila de llamadas.Sin embargo, hay una gran advertencia: las bibliotecas C ++ existentes, e incluso
std
, se basan en excepciones. Usar este estilo será una batalla cuesta arriba, ya que la API de cualquier biblioteca de terceros debe estar envuelta en un adaptador ...fuente
({...})
es una extensión de gcc, pero aun así, ¿no debería ser asíif (!result.ok) return result;
? Su condición aparece al revés y hace una copia innecesaria del error.({...})
es la expresión de las declaraciones de gcc .std::variant
para implementar elResult
si está usando C ++ 17. Además, para recibir una advertencia si ignora un error, use[[nodiscard]]
std::variant
o no es algo de gusto, dadas las compensaciones en torno al manejo de excepciones.[[nodiscard]]
es de hecho una victoria pura.Las excepciones son un mecanismo de control de flujo. La motivación para este mecanismo de control de flujo fue separar específicamente el manejo de errores del código de manejo sin errores, en el caso común de que el manejo de errores es muy repetitivo y tiene poca relevancia para la parte principal de la lógica.
Considere: trato de crear un archivo. El dispositivo de almacenamiento está lleno.
Ahora, esto no es un fracaso para definir mis condiciones previas: no se puede usar "debe haber suficiente almacenamiento" como condición previa en general, porque el almacenamiento compartido está sujeto a condiciones de carrera que hacen que esto sea imposible de satisfacer.
Entonces, ¿debería mi programa liberar algo de espacio y luego continuar con éxito, de lo contrario, soy demasiado vago para "desarrollar una solución"? Esto parece francamente sin sentido. La "solución" para la gestión del almacenamiento compartido es fuera del alcance de mi programa , y permitiendo que mi programa falle con gracia, y volver a efectuar una vez que el usuario ha lanzado ya sea un poco de espacio, o añadido algo más capacidad de almacenamiento, es fina .
Lo que hace su clase de éxito es intercalar el manejo de errores de manera muy explícita con la lógica de su programa. Cada función debe verificar, antes de ejecutarse, si ya se produjo algún error, lo que significa que no debería hacer nada. Cada función de la biblioteca debe estar envuelta en otra función, con un argumento más (y con suerte un reenvío perfecto), que hace exactamente lo mismo.
Tenga en cuenta también que su
mySqrt
función necesita devolver un valor incluso si falló (o si una función anterior había fallado). Por lo tanto, está devolviendo un valor mágico (comoNaN
), o inyectando un valor indeterminado en su programa y esperando que nada lo use sin verificar el estado de éxito que ha introducido durante su ejecución.Para la corrección, y el rendimiento, es mucho mejor volver a pasar el control fuera del alcance una vez que no pueda avanzar. Las excepciones y la comprobación explícita de errores de estilo C con retorno temprano logran esto.
A modo de comparación, un ejemplo de su idea que realmente funciona es la mónada Error en Haskell. La ventaja sobre su sistema es que normalmente escribe la mayor parte de su lógica y luego la envuelve en la mónada, que se encarga de detener la evaluación cuando falla un paso. De esta forma, el único código que toca directamente el sistema de manejo de errores es el código que podría fallar (arrojar un error) y el código que necesita para hacer frente a la falla (detectar una excepción).
Sin embargo, no estoy seguro de que el estilo de mónada y la evaluación perezosa se traduzcan bien a C ++.
fuente
and allowing my program to fail gracefully, and be re-run
cuando acaba de perder 2 horas de trabajo:std::exception
en el nivel superior de la operación lógica, le dice al usuario "X falló debido a ex.what ()" y le ofrece volver a intentar toda la operación cuando esté listo.showing the Save dialog again along with an error message and allowing the user to specify an alternative location to try
. Es un manejo elegante de un problema que normalmente no se puede hacer desde el código que detecta que la primera ubicación de almacenamiento está llena.Su enfoque trae algunos problemas importantes a su código fuente:
se basa en el código del cliente siempre recordando verificar el valor de
s
. Esto es común con el uso de códigos de retorno para el enfoque de manejo de errores , y una de las razones por las que se introdujeron excepciones en el lenguaje: con excepciones, si falla, no falla en silencio.cuanto más código escriba con este enfoque, más código de error tendrá que agregar también, para el manejo de errores (su código ya no es minimalista) y su esfuerzo de mantenimiento aumentará.
Las soluciones para estos problemas deben abordarse a nivel técnico o de equipo:
Si te encuentras manejando todo tipo de excepción que pueda ser lanzada, todo el tiempo, entonces el diseño no es bueno; Los errores que se manejan deben decidirse de acuerdo con las especificaciones del proyecto, no de acuerdo con lo que los desarrolladores desean implementar.
Aborde mediante la configuración de pruebas automatizadas, separando la especificación de las pruebas unitarias y la implementación (haga que dos personas diferentes hagan esto).
No abordará esto escribiendo más código. Creo que su mejor opción son las revisiones de código aplicadas meticulosamente.
El manejo adecuado de errores es difícil, pero menos tedioso con excepciones que con valores de retorno (ya sea que se devuelvan o se pasen como argumentos de E / S).
La parte más complicada del manejo de errores no es cómo recibe el error, sino cómo asegurarse de que su aplicación mantenga un estado consistente en presencia de errores.
Para abordar esto, se debe asignar más atención a la identificación y ejecución en condiciones de error (más pruebas, más pruebas de unidad / integración, etc.).
fuente