Es extraño que en Habrt aún no se mencionara una propuesta clamorosa para el estándar C ++ llamada "Excepciones deterministas de sobrecarga cero". Corregir esta molesta omisión.
Si le preocupa la sobrecarga de excepciones, o tuvo que compilar el código sin soporte de excepción, o simplemente se pregunta qué sucederá con el manejo de errores en C ++ 2b (una referencia a una publicación reciente ), le pido cat. Estás esperando un poco de todo lo que ahora se puede encontrar sobre el tema, y un par de encuestas.
La discusión a continuación se llevará a cabo no solo sobre excepciones estáticas, sino también sobre propuestas relacionadas con el estándar, y sobre todo tipo de otras formas de manejar errores. Si fuiste aquí para ver la sintaxis, aquí está:
double safe_divide(int x, int y) throws(arithmetic_error) { if (y == 0) { throw arithmetic_error::divide_by_zero; } else { return as_double(x) / y; } } void caller() noexcept { try { cout << safe_divide(5, 2); } catch (arithmetic_error e) { cout << e; } }
Si el tipo específico de error no es importante / desconocido, simplemente puede usar throws
and catch (std::error e)
.
Bueno saber
std::optional
y std::expected
Decidamos que el error que podría surgir en la función no es lo suficientemente "fatal" como para lanzarle una excepción. Tradicionalmente, la información de error se devuelve utilizando un parámetro de salida. Por ejemplo, el Sistema de archivos TS ofrece una serie de características similares:
uintmax_t file_size(const path& p, error_code& ec);
(¿No arroja una excepción debido al hecho de que no se encontró el archivo?) Sin embargo, el procesamiento del código de error es engorroso y propenso a errores. El código de error es fácil de olvidar. Los estilos de código modernos prohíben el uso de parámetros de salida; en cambio, se recomienda devolver una estructura que contenga el resultado completo.
Por algún tiempo, Boost ha estado ofreciendo una solución elegante para manejar tales errores "no fatales", que en ciertos escenarios pueden ocurrir en el programa correcto por cientos:
expected<uintmax_t, error_code> file_size(const path& p);
El tipo expected
es similar a la variant
, pero proporciona una interfaz conveniente para trabajar con el "resultado" y el "error". Por defecto, el resultado expected
se almacena en expected
. La implementación de file_size
podría verse así:
file_info* info = read_file_info(p); if (info != null) { uintmax_t size = info->size; return size;
Si la causa del error no nos interesa, o el error puede consistir solo en la "ausencia" del resultado, entonces se puede usar optional
:
optional<int> parse_int(const std::string& s); optional<U> get_or_null(map<T, U> m, const T& key);
En C ++ 17 de Boost, opcional llegó a std (sin soporte para optional<T&>
); en C ++ 20, pueden agregar lo esperado (esto es solo una Propuesta, gracias RamzesXI por la corrección).
Contratos
Los contratos (que no deben confundirse con los conceptos) es una nueva forma de imponer restricciones a los parámetros de la función, que se agrega en C ++ 20. Se agregaron 3 anotaciones:
- espera verifica los parámetros de la función
- asegura comprueba el valor de retorno de la función (lo toma como argumento)
- afirmar - un reemplazo civilizado para la macro afirmación
double unsafe_at(vector<T> v, size_t i) [[expects: i < v.size()]]; double sqrt(double x) [[expects: x >= 0]] [[ensures ret: ret >= 0]]; value fetch_single(key e) { vector<value> result = fetch(vector<key>{e}); [[assert result.size() == 1]]; return v[0]; }
Puede configurar por incumplimiento de contrato:
- Llamado comportamiento indefinido, o
- Verificó y llamó a la salida del usuario, después de lo cual
std::terminate
Es imposible continuar ejecutando el programa después del incumplimiento del contrato, porque los compiladores usan las garantías de los contratos para optimizar el código de función. Si existe la más mínima duda de que el contrato se cumplirá, vale la pena agregar un cheque adicional.
std :: error_code
La biblioteca <system_error>
, agregada en C ++ 11, le permite estandarizar el manejo de códigos de error en su programa. std :: error_code consiste en un código de error de tipo int
y un puntero al objeto de alguna clase descendiente std :: error_category . Este objeto, de hecho, desempeña el papel de una tabla de funciones virtuales y determina el comportamiento de un determinado std::error_code
.
Para crear su std::error_code
, debe definir su std::error_category
descendiente std::error_category
e implementar métodos virtuales, el más importante de los cuales es:
virtual std::string message(int c) const = 0;
También debe crear una variable global para su std::error_category
. El manejo de errores usando error_code + esperado se ve así:
template <typename T> using result = expected<T, std::error_code>; my::file_handle open_internal(const std::string& name, int& error); auto open_file(const std::string& name) -> result<my::file> { int raw_error = 0; my::file_handle maybe_result = open_internal(name, &raw_error); std::error_code error{raw_error, my::filesystem_error}; if (error) { return unexpected{error}; } else { return my::file{maybe_result}; } }
Es importante que en std::error_code
valor de 0 significa que no hay error. Si este no es el caso de sus códigos de error, antes de convertir el código de error del sistema a std::error_code
, debe reemplazar el código 0 con SUCCESS, y viceversa.
Todos los códigos de error del sistema se describen en errc y system_category . Si en cierto momento el reenvío manual de los códigos de error se vuelve demasiado triste, entonces siempre puede ajustar el código de error en la std::system_error
y tirarlo a la basura.
Movimiento destructivo / Trivialmente reubicable
Deje que necesite crear otra clase de objetos que posean algunos recursos. Lo más probable es que desee que no se pueda copiar, pero que se pueda mover, porque los objetos inmóviles son inconvenientes para trabajar (antes de C ++ 17 no podían ser devueltos desde una función).
Pero aquí está el problema: en cualquier caso, el objeto movido necesita ser eliminado. Por lo tanto, es necesario un estado especial de "movido desde", es decir, un objeto "vacío" que no elimina nada. Resulta que cada clase de C ++ debe tener un estado vacío, es decir, es imposible crear una clase con una invariante (garantía) de corrección, desde el constructor hasta el destructor. Por ejemplo, no es posible crear la clase correcta de archivo abierto de un archivo que está abierto durante toda su vida útil. Es extraño observar esto en uno de los pocos idiomas que usan activamente RAII.
Otro problema es la puesta a cero de objetos antiguos cuando se mueve agrega una sobrecarga: el relleno std::vector<std::unique_ptr<T>>
puede ser hasta 2 veces más lento que std::vector<T*>
debido al montón de puesta a cero de punteros antiguos al mover , seguido de la eliminación de maniquíes.
Los desarrolladores de C ++ han lamido durante mucho tiempo a Rust, donde los destructores no se invocan en objetos reubicados. Esta característica se llama movimiento destructivo. Desafortunadamente, la Propuesta Trivialmente reubicable no ofrece agregarlo a C ++. Pero el problema general se resolverá.
Una clase se considera trivialmente reubicable si dos operaciones: mover y eliminar el objeto antiguo son equivalentes a memcpy del objeto antiguo al nuevo. El antiguo objeto no se elimina, los autores lo llaman "dejarlo caer al suelo".
Un tipo es reubicable trivialmente desde el punto de vista del compilador si se cumple una de las siguientes condiciones (recursivas):
- Es trivialmente móvil + trivialmente destructible (por ejemplo, estructura
int
o POD) - Esta es la clase marcada con el atributo
[[trivially_relocatable]]
- Esta es una clase cuyos miembros son trivialmente reubicables.
Puede usar esta información con std::uninitialized_relocate
, que ejecuta move init + delete de la manera habitual, o si es posible, se acelera. Se sugiere marcar como [[trivially_relocatable]]
mayoría de los tipos de la biblioteca estándar, incluidos std::string
, std::vector
, std::unique_ptr
. Gastos generales std::vector<std::unique_ptr<T>>
con esto en mente La propuesta desaparecerá.
¿Qué hay de malo con las excepciones ahora?
El mecanismo de excepción C ++ se desarrolló en 1992. Se han propuesto varias opciones de implementación. De estos, se seleccionó un mecanismo de tabla de excepción que garantiza la ausencia de una sobrecarga para la ruta principal de ejecución del programa. Porque desde el momento mismo de su creación, se asumió que las excepciones deberían lanzarse muy raramente .
Desventajas de las excepciones dinámicas (es decir, regulares):
- En el caso de la excepción lanzada, la sobrecarga es en promedio de aproximadamente 10,000-100,000 ciclos de CPU, y en el peor de los casos, puede alcanzar el orden de milisegundos.
- El tamaño del archivo binario aumenta en un 15-38%
- Incompatibilidad con la interfaz de programación C
- Excepción implícita en el soporte de lanzamiento en todas las funciones, excepto en
noexcept
. Se puede lanzar una excepción en casi cualquier parte del programa, incluso cuando el autor de la función no lo espera.
Debido a estas deficiencias, el alcance de las excepciones es significativamente limitado. Cuando no se pueden aplicar excepciones:
- Donde el determinismo es importante, es decir, donde es inaceptable que el código "a veces" funcione 10, 100, 1000 veces más lento de lo habitual
- Cuando no son compatibles con ABI, por ejemplo, en microcontroladores
- Cuando una gran parte del código está escrito en C
- En empresas con una gran carga de código heredado ( Guía de estilo de Google , Qt ). Si hay al menos una función que no es segura para excepciones en el código, entonces, de acuerdo con la ley de maldad, tarde o temprano se generará una excepción y se creará un error
- En empresas que contratan programadores que no tienen idea de la seguridad de excepción
Según las encuestas, en los lugares de trabajo del 52% (!) Desarrolladores, las excepciones están prohibidas por las normas corporativas.
¡Pero las excepciones son una parte integral de C ++! Al incluir el -fno-exceptions
, los desarrolladores pierden la capacidad de utilizar una parte importante de la biblioteca estándar. Esto incita a las empresas a plantar sus propias "bibliotecas estándar" y, sí, inventar su propia clase de cadena.
Pero este no es el final. Las excepciones son la única forma estándar de cancelar la creación de un objeto en el constructor y generar un error. Cuando se apagan, aparece una abominación como la inicialización de dos fases. Los operadores tampoco pueden usar códigos de error, por lo que se reemplazan con funciones como assign
.
Propuesta: excepciones del futuro
Nuevo mecanismo de transferencia de excepciones
Herb Sutter en P709 describió un nuevo mecanismo de transferencia de excepciones. En principio, la función devuelve std::expected
, sin embargo, en lugar de un discriminador separado del tipo bool
, que junto con la alineación ocupará hasta 8 bytes en la pila, este bit de información se transmite de manera más rápida, por ejemplo, a Carry Flag.
Las funciones que no tocan CF (la mayoría de ellas) tendrán la oportunidad de usar excepciones estáticas de forma gratuita, ¡tanto en el caso de un retorno normal como en el caso de lanzar una excepción! Las funciones que se ven obligadas a guardarlo y restaurarlo recibirán una sobrecarga mínima, y seguirá siendo más rápido que std::expected
y cualquier código de error ordinario.
Las excepciones estáticas se ven así:
int safe_divide(int i, int j) throws(arithmetic_errc) { if (j == 0) throw arithmetic_errc::divide_by_zero; if (i == INT_MIN && j == -1) throw arithmetic_errc::integer_divide_overflows; return i / j; } double foo(double i, double j, double k) throws(arithmetic_errc) { return i + safe_divide(j, k); } double bar(int i, double j, double k) { try { cout << foo(i, j, k); } catch (erithmetic_errc e) { cout << e; } }
En la versión alternativa, se propone obligar a la palabra clave try
en la misma expresión que la llamada de función throws
: try i + safe_divide(j, k)
. Esto reducirá el número de casos de uso de funciones throws
en código que no es seguro para excepciones a casi cero. En cualquier caso, a diferencia de las excepciones dinámicas, el IDE podrá resaltar de alguna manera las expresiones que arrojan excepciones.
El hecho de que la excepción lanzada no se almacene por separado, sino que se coloca directamente en el lugar del valor devuelto, impone restricciones sobre el tipo de excepción. Primero, debe ser trivialmente reubicable. En segundo lugar, su tamaño no debe ser muy grande (pero puede ser algo como std::unique_ptr
), de lo contrario, todas las funciones reservarán más espacio en la pila.
status_code
La biblioteca <system_error2>
, desarrollada por Niall Douglas, contendrá status_code<T>
- "nuevo, mejor" error_code
. Las principales diferencias de error_code
:
status_code
: un tipo de plantilla que se puede usar para almacenar casi cualquier código de error concebible (junto con un puntero a status_code_category
), sin usar excepciones estáticasT
debería ser trivialmente reubicable y copiable (este último, en mi humilde opinión, no debería ser obligatorio). Al copiar y eliminar, las funciones virtuales se llaman desde status_code_category
status_code
puede almacenar no solo datos de error, sino también información adicional sobre una operación completada con éxito- La función "virtual"
code.message()
no devuelve std::string
, pero string_ref
es un tipo de cadena bastante pesado, que es un std::string_view
virtual "posiblemente propietario". Allí puede string_view
o string
, o std::shared_ptr<string>
, o alguna otra forma loca de poseer una cadena. Niall afirma que #include <string>
haría que el encabezado <system_error2>
inaceptablemente "pesado"
A continuación, se errored_status_code<T>
: un contenedor sobre status_code<T>
con el siguiente constructor:
errored_status_code(status_code<T>&& code) [[expects: code.failure() == true]] : code_(std::move(code)) {}
error
El tipo de excepción predeterminado ( throws
sin tipo), así como el tipo básico de excepciones a las que se emiten todos los demás (como std::exception
), es error
. Se define algo como esto:
using error = errored_status_code<intptr_t>;
Es decir, el error
es un status_code
"error", en el que el valor ( value
) se coloca en 1 puntero. Dado que el mecanismo status_code_category
garantiza la eliminación, el movimiento y la copia correctos, teóricamente, cualquier estructura de datos se puede guardar por error
. En la práctica, esta será una de las siguientes opciones:
- Enteros (int)
std::exception_handle
, es decir, un puntero a una excepción dinámica lanzadastatus_code_ptr
, es decir, unique_ptr
a un status_code<T>
arbitrario status_code<T>
.
El problema es que el caso 3 no está planeado para dar la oportunidad de devolver el error
a status_code<T>
. Lo único que puede hacer es obtener el message()
status_code<T>
empaquetado status_code<T>
. Para poder recuperar el valor devuelto por error
, tíralo como una excepción dinámica (!), Luego tómalo y envuélvelo por error
. En general, Niall cree que solo los códigos de error y los mensajes de cadena deben almacenarse con error
, lo cual es suficiente para cualquier programa.
Para distinguir entre diferentes tipos de errores, se propone utilizar el operador de comparación "virtual":
try { open_file(name); } catch (std::error e) { if (e == filesystem_error::already_exists) { return; } else { throw my_exception("Unknown filesystem error, unable to continue"); } }
¡Usar múltiples bloques catch o dynamic_cast
para seleccionar el tipo de excepción fallará!
Interacción con excepciones dinámicas.
Una función puede tener una de las siguientes especificaciones:
noexcept
: no arroja excepcionesthrows(E)
: lanza solo excepciones estáticas- (nada): arroja solo excepciones dinámicas
throws
implican no noexcept
. Si se lanza una excepción dinámica desde una función "estática", entonces se envuelve por error
. Si se lanza una excepción estática desde una función "dinámica", entonces se envuelve en una excepción status_error
. Un ejemplo:
void foo() throws(arithmetic_errc) { throw erithmetic_errc::divide_by_zero; } void bar() throws {
Excepciones en C?!
La propuesta prevé la adición de excepciones a uno de los futuros estándares de C, y estas excepciones serán compatibles con ABI con excepciones estáticas de C ++. Una estructura similar a std::expected<T, U>
, el usuario tendrá que declarar de forma independiente, aunque la redundancia se puede eliminar mediante macros. La sintaxis consiste en (por simplicidad, supondremos esto) las palabras clave falla, falla, captura.
int invert(int x) fails(float) { if (x != 0) return 1 / x; else return failure(2.0f); } struct expected_int_float { union { int value; float error; }; _Bool failed; }; void caller() { expected_int_float result = catch(invert(5)); if (result.failed) { print_error(result.error); return; } print_success(result.value); }
Al mismo tiempo, en C ++ también será posible llamar a funciones fails
desde C, declarándolas en bloques extern C
. Por lo tanto, en C ++ habrá una galaxia completa de palabras clave para trabajar con excepciones:
throw()
- eliminado en C ++ 20noexcept
- especificador de función, la función no arroja excepciones dinámicasnoexcept(expression)
- especificador de función, la función no arroja excepciones dinámicas proporcionadasnoexcept(expression)
: ¿una expresión arroja excepciones dinámicas?throws(E)
- especificador de función, la función arroja excepciones estáticasthrows
= throws(std::error)
fails(E)
: una función importada de C genera excepciones estáticas
Entonces, en C ++ trajeron (o mejor dicho, entregaron) un carrito de nuevas herramientas para el manejo de errores. A continuación, surge una pregunta lógica:
¿Cuándo usar qué?
Dirección general
Los errores se dividen en varios niveles:
- Errores de programador. Procesado mediante contratos. Conducen a la recopilación de registros y la terminación del programa de acuerdo con el concepto de falla rápida . Ejemplos: puntero nulo (cuando esto no es válido); división por cero; errores de asignación de memoria no previstos por el programador.
- Errores fatales proporcionados por el programador. Se descarta un millón de veces con menos frecuencia que un retorno normal de una función, lo que justifica el uso de excepciones dinámicas. Por lo general, en tales casos, debe reiniciar todo el subsistema del programa o dar un error al realizar la operación. Ejemplos: pérdida repentina de conexión con la base de datos; errores de asignación de memoria proporcionados por el programador.
- Errores recuperables cuando algo impide que la función complete su tarea, pero la función de llamada puede saber qué hacer con ella. Manejado por excepciones estáticas. Ejemplos: trabajar con el sistema de archivos; otros errores de entrada / salida (IO); Datos de usuario incorrectos
vector::at()
. - La función completó con éxito su tarea, aunque con un resultado inesperado.
std::variant
std::optional
, std::expected
, std::variant
. Ejemplos: stoi()
; vector::find()
; map::insert
.
En la biblioteca estándar, es más confiable abandonar por completo el uso de excepciones dinámicas para hacer legal la compilación "sin excepciones".
errno
Las funciones que usan errno
para trabajar rápida y fácilmente con códigos de error C y C ++ deben reemplazarse con fails(int)
y throws(std::errc)
, respectivamente. Durante algún tiempo, las versiones antigua y nueva de las funciones de la biblioteca estándar coexistirán, luego la antigua se declarará obsoleta.
Sin memoria
Los errores de asignación de memoria son manejados por el new_handler
global new_handler
, que puede:
- Elimine la falta de memoria y continúe la ejecución.
- Lanzar una excepción
- Programa de bloqueo
Ahora std::bad_alloc
lanza por defecto. Se sugiere llamar a std::terminate()
por defecto. Si necesita el comportamiento anterior, reemplace el controlador con el que necesita al principio de main()
.
Todas las funciones existentes de la biblioteca estándar pasarán a ser noexcept
y bloquearán el programa cuando std::bad_alloc
. Al mismo tiempo, se agregarán nuevas API como vector::try_push_back
, que permiten errores de asignación de memoria.
logic_error
Excepciones std::logic_error
, std::domain_error
, std::invalid_argument
, std::length_error
, std::out_of_range
, std::future_error
informan una violación de una condición previa de la función. El nuevo modelo de error debería usar contratos en su lugar. Los tipos de excepciones enumerados no serán obsoletos, pero casi todos los casos de su uso en la biblioteca estándar serán reemplazados por [[expects: …]]
.
Estado actual de la propuesta
La propuesta se encuentra ahora en un estado borrador. Ya ha cambiado bastante y todavía puede cambiar mucho. Algunos desarrollos no lograron publicarse, por lo que la API propuesta <system_error2>
no <system_error2>
todo relevante.
La propuesta se describe en 3 documentos:
- P709 - documento original del escudo de armas de Sutter
- P1095 - Excepciones determinadas en Niall Douglas Vision, algunos momentos cambiados, compatibilidad con lenguaje C agregado
- P1028 - API de la implementación de prueba de
std::error
Actualmente no hay un compilador que admita excepciones estáticas. En consecuencia, todavía no es posible hacer sus puntos de referencia.
C++23. , , , C++26, , , .
Conclusión
, , . , . .
, ^^