Effets et utilisations de noexcept

Article suivant: SFINAE
Article précédent: std::array, oui, mais pourquoi ?

Fonction noexcept

noexcept est un mot clef apparu en C++11. Dans le prototype d’une fonction, il indique que cette dernière ne jette pas d’exception. Cela ne veut pas dire qu’aucune exception ne sera présente dans la fonction, mais qu’aucune exception ne sortira de la fonction. Dans le cas contraire, le programme s’arrête subitement avec un appel à std::terminate.

noexcept n’impose aucune restriction sur ce que peut faire la fonction. Il est tout à fait possible d’utiliser des fonctions qui ne sont pas marquées noexcept à l’intérieur d’une fonction noexcept, voire, de jeter des exceptions. La seule contrainte se trouve sur le chemin de sortie: il ne doit pas se faire avec une exception.

Voici un code totalement inutile de démonstration avec une exception qui traverse une fonction foo() marquée noexcept:

void foo() noexcept
{
  throw 42; // noexcept ? Pas vu
}

#include <iostream>

int main()
{
  try {
    foo(); // le programme s'arrête ici
  }
  catch (...) {
  }
  std::cout << "ok\n"; // ne s'affiche jamais
}

La compilation puis l’exécution donne:

$ g++ test.cpp && ./a.out
test.cpp: In function void foo():
test.cpp:3:9: warning: throw will always call terminate() [-Wterminate]
    3 |   throw 42;
      |         ^~
terminate called after throwing an instance of 'int'
zsh: abort (core dumped)  ./a.out

G++, Clang et MSVC émettent tous 3 un avertissement et le programme explose comme attendu.

L’avertissement ici est évident, mais ce n’est pas toujours le cas, le compilateur ne pouvant garantir que toutes les fonctions utilisées dans une fonction noexcept ne jettent pas elles-mêmes des exceptions.

Notre fonction foo() est une énorme bombe à retardement. Chouette, une raison supplémentaire pour faire planter un programme ;)

Les bénéfices de noexcept

Une mauvaise utilisation de noexcept peut faire planter un programme. Mais une fonctionnalité à risque ne vient jamais sans avantage. Puisque noexcept garantit qu’aucune exception ne sorte de la fonction, le compilateur peut éjecter le code qui s’en occupe. Si les fonctions ne lancent pas d’exception, il peut déterminer avec certitude quels sont les chemins de sortie, supprimer du code, réordonner les instructions et appliquer autres obscures magies dont il a le secret.

Dit comme ça, on pourrait croire que plein d’optimisations s’offrent à nous, mais non. Premièrement, les compilateurs sont capables de faire des exceptions qui n’ont de coût qu’au moment de l’appel. Bien sûr, l’exécutable est plus gros et peut impacter le cache d’instruction, mais le coût d’une exception non utilisée est nulle. Secundo, ce n’est pas évident de produire un exécutable qui supprime vraiment du code, à moins d’avoir absolument toutes les fonctions en noexcept, la magie du compilateur se verra limité. Surtout que les bonnes options d’optimisation donnent des résultats comparables.

Après plusieurs essais d’exemples de code pseudo-réaliste, en voici 2 basés sur une fonction très simple

// foo.cpp
int foo(int x) NOEXCEPT
{
  return x;
}

NOEXCEPT sera à remplacer par noexcept ou rien. Respectivement les options de compilation -DNOEXCEPT=noexcept et -DNOEXCEPT=.

Ce premier exemple prouve que le compilateur est capable de prendre en compte noexcept pour supprimer du code.

// main.cpp
#include <iostream>

int foo(int) NOEXCEPT;

void bar(std::ostream& out)
{
  char const* err;
  try {
    // trace de l'étape en cours
    err = "foo step";
    auto r = foo(1);
    // fin du "traitement"
    err = nullptr;

    out << r; // ceci peut lancer une exception std::ios_base::failure
  }
  catch(std::exception const& e)
  {
    // err != nullptr uniquement si foo() lance une exception
    if (err)
      std::cerr << "oups: " << err << "\n";
    std::cerr << e.what() << "\n";
  }
}

Ce code permet d’afficher un message sur l’étape en cours avant une éventuelle exception. Dans notre cas, foo() ne lance pas d’exception, mais en l’absence de noexcept, main.cpp ne le sait pas.

Si foo() ne lance pas d’exception, alors err sera toujours égal à nullptr dans le catch. Il est alors possible de supprimer la condition qui serait toujours évaluée à false, ce qui doit donner un exécutable plus petit.

Le petit script suivant va compiler chaque .cpp avec et sans noexcept puis afficher la taille de l’exécutable.

for x in noexcept '' ; do
  d=NOEXCEPT=$x
  g++ -O3 -D$d *.cpp &&
  echo $d &&
  stat ./a.out *.o --format="$(echo "%n\t%s")"
done
  NOEXCEPT= NOEXCEPT=noexcept
stat ./a.out 17632 16816

Pronostic vérifié. On peut même regarder l’assembleur sur godbolt pour s’en convaincre.

Voici un second exemple plus simple qui montre que le compilateur supprime totalement la gestion des exceptions lorsqu’il lui est permis de le faire.

#include <memory>

int foo(int) NOEXCEPT;

int bar(std::unique_ptr<int>&& p)
{
    // some stuff
    // ...
    auto u = std::move(p);
    return foo(*u);
}

Et l’asm de https://godbolt.org/z/-yIB7m

bar(std::unique_ptr<int, std::default_delete<int> >&&):
        push    r12
        push    rbp
        sub     rsp, 8
        mov     rbp, QWORD PTR [rdi]
        mov     QWORD PTR [rdi], 0
        mov     edi, DWORD PTR [rbp+0]
        call    foo(int)
        mov     rdi, rbp
        mov     esi, 4
        mov     r12d, eax
        call    operator delete(void*, unsigned long)
        add     rsp, 8
        mov     eax, r12d
        pop     rbp
        pop     r12
        ret
; ce qui suit ne concerne que l'éventuelle exception de foo()
        mov     r12, rax
        jmp     .L2
bar(std::unique_ptr<int, std::default_delete<int> >&&) [clone .cold]:
.L2:
        mov     rdi, rbp
        mov     esi, 4
        call    operator delete(void*, unsigned long)
        mov     rdi, r12
        call    _Unwind_Resume

La seconde partie du code asm n’est utilisée que pour le traitement d’une exception, elle se situe en dehors du flux normal d’exécution et disparait lorsque foo() ne lance pas d’exception.

Mais attention, si une fonction noexcept appelle une fonction qui n’est pas noexcept, le compilo va ajouter du code pour forcer l’utilisation de std::terminate(). Cela revient presque à un support d’exception, mais en plus léger.

Maintenant, il faut savoir qu’avec l’option -flto la taille des exécutables sont les mêmes que foo() soit noexcept ou pas. Simplement parce que le compilateur déduit que foo() ne lance pas d’exception. Mais dans le cas de bibliothèque partagée, -flto ne pourra rien faire et la différence subsistera.

Puisque le compilateur peut déduire lui-même qu’une fonction ne lance pas d’exception, pourquoi et surtout quand mettre noexcept ?

Quand mettre noexcept

Déjà, on peut dire qu’une fonction qui ne lance absolument pas d’exception peut être noexcept. Cette information est surtout utile dans les APIs ou bibliothèques qui ont cette garantie. Un analyseur statique pourrait aussi vérifier qu’aucune exception ne la traverse. Dans le cas contraire, c’est plutôt risqué.

Néanmoins, noexcept est vraiment important sur les constructeurs déplacement.

Pour être plus précis, conjointement avec les conteneurs de la STL, cela va déterminer si le conteneur utilise ou non le déplacement. Si la fonction est noexcept, le déplacement d’un élément ne peut pas échouer. Dans le cas d’un std::vector<T>, cela veut dire qu’après un agrandissement de la capacité, tous les éléments peuvent être déplacés sans échec. Par contre, si le déplacement lance une exception, une partie des données pourrait se perdre et std::vector utilise alors la copie. On parle de résistance aux exceptions ou d’exception-safety. Dans le cas de std::vector, c’est une garantie forte: l’état du vector est inchangé s’il y a une exception sur l’augmentation de la capacité. Pour en savoir un peu plus sur l’exception-safety, c’est par là.

Pour illustrer l’influence de noexcept avec std::vector, voici une petite classe qui affiche quel constructeur est utilisé. Le constructeur de déplacement est toujours présent, seule la présence de noexcept diffère.

#include <iostream>

struct A
{
  A(int i) : i(i) {}

  A(A const& a) : i(a.i)
  { std::cout << "A&(" << i << ")\n"; }

  A(A&& a) NOEXCEPT : i(a.i)
  { std::cout << "A&&(" << i << ")\n"; }

  int i;
};

#include <vector>

int main()
{
  std::vector<A> v;
  v.emplace_back(1);
  v.emplace_back(2); // agrandissement de la capacité + déplacement/copie de A(1)
}

Et les résultats après compilation:

  NOEXCEPT= NOEXCEPT=noexcept
./a.out A&(1) A&&(1)

Maintenant imaginons que notre classe A contienne des std::string et des std::vector. Si le constructeur de déplacement n’est pas noexcept, alors il y aura de nombreuses copies qui auront un gros impact sur les performances. Ce n’est généralement pas ce qu’on veut.

Personnellement, je déconseille d’écrire le constructeur et operator= de déplacement ou de copie. 99% du temps, la version par défaut fait le job, le compilateur déduisant lui-même noexcept pour les fonctions par défaut – et c’est bien le seul moment. S’il y a une véritable nécessité, il est généralement possible d’avoir une classe qui ne fournit que ce service et l’utiliser en variable membre.

Si on écrit explicitement noexcept sur une fonction par défaut – par exemple X(X&&) noexcept = default; – le compilateur va vérifier que le déplacement de chaque membre ne lance pas d’exception. Et dans le cas contraire, ne compile pas.

Pour finir, les destructeurs sont implicitement noexcept. Si un destructeur balance une exception, le programme va s’arrêter. Les raisons sont assez simples: il est difficile de catcher une exception d’un destructeur et il est impossible d’arriver à un état cohérent sans code spécifique si les objets ne peuvent être détruits. De plus, il faut savoir que jeter une exception pendant le traitement d’une exception appelle automatiquement std::terminate(). Du coup, les exceptions dans un destructeur sont plutôt une mauvaise idée.

Mais si on veut autoriser le destructeur à jeter des exceptions ou pouvoir marquer nos fonctions en noexcept à la seule condition qu’une expression précise soit elle-même noexcept il existe un nouveau mot clef: noexcept. Oui, mais non, il est différent.

Spécificateur d’exception et opérateur noexcept

Il existe 2 mots clef pour noexcept: le spécificateur d’exception et l’opérateur.

  • Le spécificateur noexcept ou noexcept(bool) se met toujours dans le prototype de la fonction. noexcept et noexcept(true) indiquent que la fonction ne jette pas d’exception, noexcept(false) indique que la fonction jette potentiellement une exception.
  • L’opérateur noexcept(expr) prend une expression est retourne un booléen qui indique si oui ou non une expression peut lancer une exception.

Voici quelques exemples:

constexpr bool foo() { return true; }

void f1() noexcept;
void f2() noexcept(false);
void f3() noexcept(foo()); // -> noexcept(true)
void f4() noexcept(noexcept(f1())); // noexcept seulement si f1() est noexcept
void f5() noexcept(noexcept(new int));
void f6() noexcept(noexcept(false));

std::cout << noexcept(f1())  << "\n"; // true
std::cout << noexcept(f2())  << "\n"; // false
std::cout << noexcept(f3())  << "\n"; // true
std::cout << noexcept(f4())  << "\n"; // true
std::cout << noexcept(f5())  << "\n"; // false
std::cout << noexcept(f6())  << "\n"; // true
std::cout << noexcept(true)  << "\n"; // true
std::cout << noexcept(false) << "\n"; // true

Pas très compliqué dans l’ensemble, même si bien que logique, le résultat de f6() et la dernière ligne peuvent surprendre: false est une expression qui ne retourne jamais d’exception, par conséquent, l’opérateur noexcept retourne true.

La STL offre aussi les traits std::is_nothrow_* pour vérifier si certaines expressions sont noexcept ; ainsi qu’une fonction std::move_if_noexcept qui retourne une rvalue si le constructeur de déplacement est noexcept et une lvalue constante dans le cas inverse.

Avant C++11, il existait le spécificateur throw() pour indiquer qu’une fonction ne jette pas d’exception. Son comportement est très différent et pas du tout efficace. En C++17, il devient l’équivalent de noexcept(true) pour finalement être supprimé en C++20.

Signature et type de fonction

2 fonctions qui ne diffèrent que par la spécification d’exception ne peuvent être surchargées car leur signature est identique. Ceci n’est pas autorisé:

void foo();
void foo() noexcept; // erreur

Du fait de la signature identique, noexcept peut très bien apparaître dans le prototype d’une fonction exposée dans une bibliothèque C (extern "C"), mais cette information disparaît dans la bibliothèque elle-même. Mieux que ça, si la bibliothèque est compilée sans noexcept, mais que le .h distribué les met, le compilateur retrouve ses petits, car le name mangling est le même.

Par contre, avant C++17 le type d’une fonction ne prend jamais en compte noexcept. C’est-à-dire que les types void(*)() et void(*)() noexcept sont identiques.

À partir de C++17, noexcept fait partie intégrante du type de la fonction. Un pointeur de fonction noexcept n’est pas compatible avec un pointeur de fonction qui jette potentiellement une exception. Néanmoins, une fonction noexcept est convertible en un pointeur de fonction qui n’est pas noexcept. Les posts conditions sont plus fortes ce qui ne brise pas le LSP.

void foo() noexcept;
void bar();

void(*f)() noexcept = foo;
void(*f)() noexcept = bar; // non depuis C++17

void(*g)() = bar;
void(*g)() = foo; // ok

Cette caractéristique s’applique aussi aux fonctions virtual.

class A
{
  virtual void foo() noexcept = 0;
  virtual void bar() = 0;
};

class B : A
{
  void foo() override; // il manque noexcept
  void bar() noexcept override; // ok, plus de restriction sur les conditions de sortie
};

class C : B
{
  void bar() override; // erreur, même si A::bar() n'est pas noexcept, B::bar() l'est
};

Ce qu’il faut retenir

Finalement il n’y a pas grand chose à retenir. noexcept permet certaines optimisations, mais appliqué n’importe comment il est source de bug. Si vous n’êtes pas sûr, ne l’utilisez pas.

Cependant, comme cela influence les conteneurs de la STL, il faut impérativement penser à le mettre sur le constructeur de déplacement.

Commentaires

Aucun commentaire pour le moment :'(

Le système de commentaire passe par les issues de github et aucun n'est associée au billet. Vous pouvez faire votre commentaire dans une issue qui a comme titre celui du billet. Je me chargerai de les associer.

Revenir en haut