Exceptions déterministes et gestion des erreurs dans le «C ++ du futur»


Il est étrange que sur Habrt, il n'y ait encore aucune mention d'une proposition bruyante pour la norme C ++ appelée "Zero-overhead deterministic exceptions". Corriger cette omission ennuyeuse.


Si vous êtes préoccupé par la surcharge des exceptions, ou si vous avez dû compiler du code sans prise en charge des exceptions, ou simplement vous demander ce qui se passera avec la gestion des erreurs en C ++ 2b (une référence à un article récent ), je demande cat. Vous attendez une compression de tout ce qui peut maintenant être trouvé sur le sujet, et quelques sondages.


La discussion ci-dessous portera non seulement sur les exceptions statiques, mais aussi sur les propositions liées à la norme, et sur toutes sortes d'autres façons de gérer les erreurs. Si vous êtes allé ici pour regarder la syntaxe, alors voici:


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 le type d'erreur spécifique est sans importance / inconnu, alors vous pouvez simplement utiliser throws et catch (std::error e) .


Bon à savoir


std::optional et std::expected


Décidons que l'erreur qui pourrait potentiellement se produire dans la fonction n'est pas suffisamment «fatale» pour lui jeter une exception. Traditionnellement, les informations d'erreur sont renvoyées à l'aide d'un paramètre out. Par exemple, Filesystem TS offre un certain nombre de fonctionnalités similaires:


 uintmax_t file_size(const path& p, error_code& ec); 

(Ne lancez pas d'exception car le fichier n'a pas été trouvé?) Néanmoins, le traitement des codes d'erreur est lourd et sujet aux bogues. Le code d'erreur est facile à oublier de vérifier. Les styles de code modernes interdisent l' utilisation des paramètres de sortie; à la place, il est recommandé de renvoyer une structure contenant l'intégralité du résultat.


Depuis un certain temps, Boost propose une solution élégante pour gérer ces erreurs "non fatales" qui peuvent survenir dans certains scénarios dans le programme approprié:


 expected<uintmax_t, error_code> file_size(const path& p); 

Le type expected est similaire à la variant , mais il fournit une interface pratique pour travailler avec le «résultat» et l '«erreur». Par défaut, le résultat expected est stocké dans expected . L'implémentation file_size pourrait ressembler à ceci:


 file_info* info = read_file_info(p); if (info != null) { uintmax_t size = info->size; return size; // <== } else { error_code error = get_error(); return std::unexpected(error); // <== } 

Si la cause de l'erreur ne nous intéresse pas, ou que l'erreur ne peut consister qu'en «absence» du résultat, alors optional peut être utilisé:


 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, facultatif est venu à std (sans prise en charge de optional<T&> ); en C ++ 20, ils peuvent ajouter attendu (ce n'est que la proposition, merci RamzesXI pour la correction).


Contrats


Les contrats (à ne pas confondre avec les concepts) est une nouvelle façon d'imposer des restrictions sur les paramètres de fonction, ajoutée en C ++ 20. 3 annotations ajoutées:


  • attend vérifie les paramètres de la fonction
  • assure la vérification de la valeur de retour de la fonction (la prend comme argument)
  • assert - un remplacement civilisé pour la macro assert

 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]; } 

Vous pouvez configurer en cas de rupture de contrat:


  • Appelé comportement indéfini, ou
  • Il a vérifié et appelé l'exit utilisateur, après quoi std::terminate

Il est impossible de continuer à exécuter le programme après une rupture de contrat, car les compilateurs utilisent les garanties des contrats pour optimiser le code de fonction. S'il y a le moindre doute que le contrat sera exécuté, il convient d'ajouter un contrôle supplémentaire.


std :: error_code


La bibliothèque <system_error> , ajoutée en C ++ 11, vous permet de standardiser la gestion des codes d'erreur dans votre programme. std :: error_code se compose d'un code d'erreur de type int et d'un pointeur vers l'objet d'une classe descendante std :: error_category . Cet objet, en effet, joue le rôle d'une table de fonctions virtuelles et détermine le comportement d'un std::error_code .


Pour créer votre std::error_code , vous devez définir votre std::error_category descendante std::error_category et implémenter des méthodes virtuelles, dont la plus importante est:


 virtual std::string message(int c) const = 0; 

Vous devez également créer une variable globale pour votre std::error_category . La gestion des erreurs en utilisant error_code + attendu ressemble à ceci:


 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}; } } 

Il est important que dans std::error_code valeur de 0 signifie aucune erreur. Si ce n'est pas le cas pour vos codes d'erreur, puis avant de convertir le code d'erreur système en std::error_code , vous devez remplacer le code 0 par SUCCESS, et vice versa.


Tous les codes d'erreur système sont décrits dans errc et system_category . Si à un certain stade le transfert manuel des codes d'erreur devient trop morne, vous pouvez toujours envelopper le code d'erreur dans l' std::system_error et le jeter.


Mouvement destructif / Déplaçable de manière triviale


Vous devez créer une autre classe d'objets possédant certaines ressources. Très probablement, vous voudrez le rendre non copiable, mais mobile, car les objets immobiles ne sont pas pratiques à utiliser (avant C ++ 17, ils ne pouvaient pas être renvoyés par une fonction).


Mais voici le problème: dans tous les cas, l'objet déplacé doit être supprimé. Par conséquent, un état spécial «déplacé» est nécessaire, c'est-à-dire un objet «vide» qui ne supprime rien. Il s'avère que chaque classe C ++ doit avoir un état vide, c'est-à-dire qu'il est impossible de créer une classe avec un invariant (garantie) d'exactitude, du constructeur au destructeur. Par exemple, il n'est pas possible de créer la classe open_file correcte d'un fichier ouvert pendant toute sa durée de vie. Il est étrange d'observer cela dans l'une des rares langues qui utilisent activement RAII.


Un autre problème est la mise à zéro des anciens objets lors du déplacement ajoute une surcharge: le remplissage de std::vector<std::unique_ptr<T>> peut être jusqu'à 2 fois plus lent que std::vector<T*> raison du tas de mise à zéro des anciens pointeurs lors du déplacement , suivi de la suppression des mannequins.


Les développeurs C ++ ont longtemps léché Rust, où les destructeurs ne sont pas appelés sur les objets déplacés. Cette fonctionnalité est appelée mouvement destructif. Malheureusement, la proposition Trivially relocatable ne propose pas de l'ajouter au C ++. Mais le problème des frais généraux sera résolu.


Une classe est considérée comme pouvant être déplacée de manière triviale si deux opérations: déplacer et supprimer l'ancien objet sont équivalentes à memcpy de l'ancien objet vers le nouveau. L'ancien objet n'est pas supprimé, les auteurs l'appellent "déposez-le par terre".


Un type peut être déplacé de manière triviale du point de vue du compilateur si l'une des conditions (récursives) suivantes est vraie:


  1. Il est trivialement mobile + trivialement destructible (par exemple structure int ou POD)
  2. Il s'agit de la classe marquée avec l'attribut [[trivially_relocatable]]
  3. Il s'agit d'une classe dont tous les membres sont délocalisables de manière triviale.

Vous pouvez utiliser ces informations avec std::uninitialized_relocate , qui exécute move init + delete de la manière habituelle, ou accélérée si possible. Il est suggéré de marquer comme [[trivially_relocatable]] plupart des types de la bibliothèque standard, y compris std::string , std::vector , std::unique_ptr . Overhead std::vector<std::unique_ptr<T>> avec ceci à l'esprit La proposition disparaîtra.


Qu'est-ce qui ne va pas avec les exceptions maintenant?


Le mécanisme d'exception C ++ a été développé en 1992. Diverses options de mise en œuvre ont été proposées. Parmi ceux-ci, un mécanisme de table d'exceptions a été sélectionné qui garantit l'absence de surcharge pour le chemin principal de l'exécution du programme. Parce que dès le moment même de leur création, on a supposé que les exceptions devaient être levées très rarement .


Inconvénients des exceptions dynamiques (c'est-à-dire régulières):


  1. Dans le cas de l'exception levée, le temps système est en moyenne d'environ 10 000 à 100 000 cycles CPU, et dans le pire des cas, il peut atteindre l'ordre des millisecondes
  2. La taille des fichiers binaires augmente de 15 à 38%
  3. Incompatibilité avec l'interface de programmation C
  4. Prise en charge des exceptions implicites dans toutes les fonctions sauf noexcept . Une exception peut être levée presque n'importe où dans le programme, même lorsque l'auteur de la fonction ne s'y attend pas

En raison de ces lacunes, la portée des exceptions est considérablement limitée. Lorsque des exceptions ne peuvent pas s'appliquer:


  1. Lorsque le déterminisme est important, c'est-à-dire lorsqu'il est inacceptable que le code fonctionne «parfois» 10, 100, 1000 fois plus lentement que d'habitude
  2. Quand ils ne sont pas pris en charge dans ABI, par exemple, dans les microcontrôleurs
  3. Lorsqu'une grande partie du code est écrit en C
  4. Dans les entreprises avec une grande quantité de code hérité ( Google Style Guide , Qt ). S'il y a au moins une fonction non protégée contre les exceptions dans le code, alors selon la loi de la méchanceté, une exception sera levée tôt ou tard et créera un bogue
  5. Dans les entreprises qui embauchent des programmeurs qui n'ont aucune idée de la sécurité des exceptions

Selon les enquêtes, sur les lieux de travail de 52% (!) Développeurs, les exceptions sont interdites par les règles de l'entreprise.


Mais les exceptions font partie intégrante de C ++! En incluant l'indicateur -fno-exceptions , les développeurs perdent la possibilité d'utiliser une partie importante de la bibliothèque standard. Cela incite en outre les entreprises à planter leurs propres «bibliothèques standard» et, oui, à inventer leur propre classe de chaînes.


Mais ce n'est pas la fin. Les exceptions sont le seul moyen standard d'annuler la création d'un objet dans le constructeur et de générer une erreur. Lorsqu'elles sont désactivées, une abomination telle qu'une initialisation en deux phases apparaît. Les opérateurs ne peuvent pas non plus utiliser de codes d'erreur, ils sont donc remplacés par des fonctions comme assign .


Proposition: exceptions de l'avenir


Nouveau mécanisme de transfert d'exception


Herb Sutter dans P709 a décrit un nouveau mécanisme de transfert d'exception. En principe, la fonction retourne std::expected , cependant, au lieu d'un discriminateur séparé de type bool , qui avec l'alignement occupera jusqu'à 8 octets sur la pile, ce bit d'information est transmis d'une manière plus rapide, par exemple, à Carry Flag.


Les fonctions qui ne touchent pas CF (la plupart d'entre elles) auront la possibilité d'utiliser gratuitement des exceptions statiques - à la fois dans le cas d'un retour normal et dans le cas d'une levée d'exception! Les fonctions qui sont obligées de l'enregistrer et de le restaurer recevront une surcharge minimale, et ce sera toujours plus rapide que std::expected et tout code d'erreur ordinaire.


Les exceptions statiques ressemblent à ceci:


 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; } } 

Dans la version alternative, il est proposé d'obliger le mot clé try dans la même expression que l'appel de la fonction try i + safe_divide(j, k) : try i + safe_divide(j, k) . Cela réduira à presque zéro le nombre de cas d'utilisation de fonctions throws dans du code qui n'est pas sûr pour les exceptions. Dans tous les cas, contrairement aux exceptions dynamiques, l'EDI pourra en quelque sorte mettre en évidence les expressions qui lèvent des exceptions.


Le fait que l'exception levée ne soit pas stockée séparément, mais soit placée directement à la place de la valeur retournée, impose des restrictions sur le type d'exception. Premièrement, il doit être facilement déplaçable. Deuxièmement, sa taille ne doit pas être très grande (mais elle peut être quelque chose comme std::unique_ptr ), sinon toutes les fonctions réserveront plus d'espace sur la pile.


code_état


La bibliothèque <system_error2> , développée par Niall Douglas, contiendra status_code<T> - "new, better" error_code . Les principales différences avec error_code :


  1. status_code - un type de modèle qui peut être utilisé pour stocker presque tous les codes d'erreur imaginables (avec un pointeur sur status_code_category ), sans utiliser d'exceptions statiques
  2. T devrait être relocalisable et copiable (ce dernier, à mon humble avis, ne devrait pas être obligatoire). Lors de la copie et de la suppression, les fonctions virtuelles sont appelées depuis status_code_category
  3. status_code peut stocker non seulement des données d'erreur, mais aussi des informations supplémentaires sur une opération réussie
  4. La fonction "virtuelle" code.message() ne retourne pas std::string , mais string_ref est un type de chaîne assez lourd, qui est un virtuel "possiblement propriétaire" std::string_view . Là, vous pouvez string_view ou string , ou std::shared_ptr<string> , ou une autre façon folle de posséder une chaîne. Niall prétend que #include <string> rendrait l'en-tête <system_error2> inacceptable "lourd"

Ensuite, errored_status_code<T> est entré - un wrapper sur status_code<T> avec le constructeur suivant:


 errored_status_code(status_code<T>&& code) [[expects: code.failure() == true]] : code_(std::move(code)) {} 

erreur


Le type d'exception par défaut ( throws sans type), ainsi que le type de base des exceptions vers lesquelles tous les autres sont convertis (comme std::exception ), est error . Il est défini quelque chose comme ceci:


 using error = errored_status_code<intptr_t>; 

Autrement dit, l' error est un tel code d'état "erreur", dans lequel la valeur ( value ) est placée dans 1 pointeur. Étant donné que le mécanisme status_code_category garantit une suppression, un déplacement et une copie corrects, théoriquement, toute structure de données peut être enregistrée par error . En pratique, ce sera l'une des options suivantes:


  1. Entiers (int)
  2. std::exception_handle , c'est-à-dire un pointeur vers une exception dynamique levée
  3. status_code_ptr , c'est-à-dire unique_ptr à un status_code<T> arbitraire status_code<T> .

Le problème est que le cas 3 n'est pas prévu pour donner la possibilité de ramener l' error au status_code<T> . La seule chose que vous pouvez faire est d'obtenir le message() status_code<T> compressé. Pour pouvoir récupérer la valeur renvoyée en error , lancez-la comme exception dynamique (!), Puis interceptez-la et enveloppez-la par error . En général, Niall pense que seuls les codes d'erreur et les messages de chaîne doivent être stockés en error , ce qui est suffisant pour n'importe quel programme.


Pour distinguer différents types d'erreurs, il est proposé d'utiliser l'opérateur de comparaison «virtuel»:


 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"); } } 

L'utilisation de plusieurs blocs catch ou dynamic_cast pour sélectionner le type d'exception échouera!


Interaction avec les exceptions dynamiques


Une fonction peut avoir l'une des spécifications suivantes:


  • noexcept : ne lance aucune exception
  • throws(E) : lève uniquement les exceptions statiques
  • (rien): ne lance que des exceptions dynamiques

throws n'implique noexcept . Si une exception dynamique est levée à partir d'une fonction "statique", elle est encapsulée par error . Si une exception statique est levée à partir d'une fonction "dynamique", elle est status_error dans une exception status_error . Un exemple:


 void foo() throws(arithmetic_errc) { throw erithmetic_errc::divide_by_zero; } void bar() throws { //  arithmetic_errc   intptr_t //     error foo(); } void baz() { // error    status_error bar(); } void qux() throws { // error    status_error baz(); } 

Exceptions en C?!


La proposition prévoit l'ajout d'exceptions à l'une des futures normes C, et ces exceptions seront compatibles ABI avec les exceptions statiques C ++. Une structure similaire à std::expected<T, U> , l'utilisateur devra déclarer indépendamment, bien que la redondance puisse être supprimée à l'aide de macros. La syntaxe se compose (pour simplifier, nous le supposerons) des mots-clés échoue, échoue, capture.


 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); } 

Dans le même temps, en C ++, il sera également possible d'appeler des fonctions fails partir de C, en les déclarant dans des blocs extern C . Ainsi, en C ++, il y aura toute une galaxie de mots-clés pour travailler avec des exceptions:


  • throw() - supprimé en C ++ 20
  • noexcept - spécificateur de fonction, la fonction ne noexcept pas d'exceptions dynamiques
  • noexcept(expression) - spécificateur de fonction, la fonction ne noexcept(expression) pas d'exceptions dynamiques fournies
  • noexcept(expression) - Une expression noexcept(expression) -t-elle des exceptions dynamiques?
  • throws(E) - spécificateur de fonction, la fonction lève des exceptions statiques
  • throws = throws(std::error)
  • fails(E) - une fonction importée de C lève des exceptions statiques

Ainsi, en C ++, ils ont apporté (ou plutôt livré) un panier de nouveaux outils pour la gestion des erreurs. Ensuite, une question logique se pose:


Quand utiliser quoi?


Direction générale


Les erreurs sont divisées en plusieurs niveaux:


  • Erreurs du programmeur. Traité à l'aide de contrats. Ils conduisent à la collecte des journaux et à la fin du programme conformément au concept de fail-fast . Exemples: pointeur nul (lorsque ce n'est pas valide); division par zéro; erreurs d'allocation de mémoire non prévues par le programmeur.
  • Erreurs fatales fournies par le programmeur. Jeté un million de fois moins souvent qu'un retour normal d'une fonction, ce qui justifie l'utilisation d'exceptions dynamiques. Dans de tels cas, vous devez généralement redémarrer l'ensemble du sous-système du programme ou donner une erreur lors de l'exécution de l'opération. Exemples: connexion soudainement perdue avec la base de données; erreurs d'allocation de mémoire fournies par le programmeur.
  • Erreurs récupérables lorsque quelque chose a empêché la fonction de terminer sa tâche, mais la fonction appelante peut savoir quoi en faire. Géré par des exceptions statiques. Exemples: travailler avec le système de fichiers; autres erreurs d'entrée / sortie (IO); Données utilisateur incorrectes vector::at() .
  • La fonction a terminé sa tâche avec succès, mais avec un résultat inattendu. std::optional , std::expected , std::variant . Exemples: stoi() ; vector::find() ; map::insert .

Dans la bibliothèque standard, il est plus fiable d'abandonner complètement l'utilisation des exceptions dynamiques afin de rendre la compilation "sans exceptions" légale.


errno


Les fonctions qui utilisent errno pour travailler rapidement et facilement avec les codes d'erreur C et C ++ doivent être remplacées respectivement par throws(std::errc) fails(int) et throws(std::errc) . Pendant un certain temps, l'ancienne et la nouvelle version des fonctions de la bibliothèque standard coexisteront, puis l'ancienne sera déclarée obsolète.


Mémoire insuffisante


Les erreurs d'allocation de mémoire sont gérées par le new_handler global new_handler , qui peut:


  1. Éliminez le manque de mémoire et poursuivez l'exécution
  2. Jetez une exception
  3. Programme de crash

Maintenant, std::bad_alloc lancé par défaut. Il est suggéré d'appeler std::terminate() par défaut. Si vous avez besoin de l'ancien comportement, remplacez le gestionnaire par celui dont vous avez besoin au début de main() .


Toutes les fonctions existantes de la bibliothèque standard deviendront noexcept et noexcept le programme lorsque std::bad_alloc . Dans le même temps, de nouvelles API comme vector::try_push_back seront ajoutées, ce qui permettra des erreurs d'allocation de mémoire.


logic_error


Les exceptions std::logic_error , std::domain_error , std::invalid_argument , std::length_error , std::out_of_range , std::future_error signalent une violation d'une condition de fonction. Le nouveau modèle d'erreur devrait utiliser des contrats à la place. Les types d'exceptions répertoriés ne seront pas dépréciés, mais presque tous les cas d'utilisation dans la bibliothèque standard seront remplacés par [[expects: …]] .


Statut actuel de la proposition


La proposition est maintenant dans un état provisoire. Il a déjà beaucoup changé et peut encore changer beaucoup. Certains développements n'ont pas réussi à être publiés, donc l'API proposée <system_error2> pas entièrement pertinente.


La proposition est décrite dans 3 documents:


  1. P709 - document original des armoiries de Sutter
  2. P1095 - Exceptions déterminées dans Niall Douglas Vision, certaines choses ont changé , compatibilité avec le langage C ajoutée
  3. P1028 - API de l' implémentation de test de std::error

Il n'existe actuellement aucun compilateur qui prend en charge les exceptions statiques. En conséquence, il n'est pas encore possible de faire leurs repères.


C++23. , , , C++26, , , .


Conclusion


, , . , . .


, ^^

Source: https://habr.com/ru/post/fr430690/


All Articles