C++ Core Guidelines: Der noexcept-Spezifier und -Operator

Wer die verbleibenden Regeln zur Fehlerbehandlung überfliegt, liest oft das Wort noexcept. Bevor sich dieser Artikel daher mit den Regeln zur Fehlerbehandlung genauer beschäftigt, wird sich zuerst der noexcept-Spezifizierer und der noexcept-Operator angeschaut.

In Pocket speichern vorlesen Druckansicht
Lesezeit: 7 Min.
Von
  • Rainer Grimm
Inhaltsverzeichnis

Wer die verbleibenden Regeln zur Fehlerbehandlung überfliegt, liest oft das Wort noexcept. Bevor sich dieser Artikel daher mit den Regeln zur Fehlerbehandlung genauer beschäftigt, wird sich zuerst der noexcept-Spezifizierer und der noexcept-Operator angeschaut.

noexcept gibt es in zwei Variationen in C++11: als Spezifizierer und als Operator. Die C++ Core Guidelines verwenden den Spezifizierer.

Indem du eine Funktion, eine Methode oder ein Funktions-Template als noexcept deklarierst, drückst du aus, dass diese keine Ausnahme werfen, und falls sie es tun, du dich darum nicht kümmerst und das Programm abstürzen lässt. Es gibt viele Arten, deine Absicht auf den Punkt zu bringen:

void func1() noexcept;        // does not throw
void func2() noexcept(true); // does not throw
void func3() throw(); // does not throw

void func4() noexcept(false); // may throw

Die noexcept-Spezifikation ist äquivalent zu der noexcept(true)-Spezifikation. throw() ist äquivalent zu noexcept(true), wurde aber mit C++11 auf deprecated gesetzt und wird mit C++20 aus dem C++-Standard entfernt werden. noexcept(false) sagt aus, dass die Funktion eine Ausnahme werfen kann. Die noexcept-Spezifikation ist Bestandteil des Funktionstyps, kann aber nicht zum Überladen verwendet werden.

Es gibt zwei gute Gründe, noexcept zu verwenden. Zuerst einmal dokumentiert noexcept das Verhalten der Funktion. Wenn eine Funktion als noexcept deklariert ist, kann sie sicher in Funktionen verwendet werden, die keine Ausnahme werfen. Darüber hinaus ist noexcept eine Optimierungsmöglichkeit für den Compiler. noexcept muss nicht std::unexpected aufrufen und stack unwinding durchführen. Ein Container kann bei seiner Initialisierung seine Elemente billig in den Container verschieben, falls der Move-Konstruktor als noexcept erklärt ist. Falls nicht, werden die Elemente gegebenenfalls teuer in den Container kopiert.

Jede Funktion in C++ kann entweder keine Ausnahme (non-throwing) oder eventuell eine Ausnahme (potentially throwing) werfen. Eventuell bedeutet.

  1. Die Funktion verwendet eine Funktion, die eine Ausnahme werfen kann.
  2. Die Funktion ist ohne eine noexcept-Spezifikation deklariert.
  3. Die Funktion verwendet einen dynamic_cast auf eine Referenz.

Es gibt eine Ausnahme zum Punkt 2. Die Ausnahme umfasst die folgenden speziellen Methoden einer Klasse. Sind sind implizit non-throwing:

  • Default-Konstruktor und Destruktor
  • Move- und Copy-Konstruktor
  • Move- und Copy-Zuweisungsoperator

Diese speziellen sechs Methoden wie der Destruktor können nur non-throwing sein, wenn alle Destruktor der Attribute und der Basisklassen non-throwing sind. Die entsprechenden Aussagen gelten natürlich auch für die verbleibenden fünf Methoden.

Was passiert nun, wenn eine Funktion eine Ausnahme wirft, die als non-throwing deklariert ist? In diesem Fall wird std::terminate aufgerufen. std::terminate ruft den aktuellen std::termination_handler auf, der wiederum std::abort per Default aufruft. Das Ergebnis ist eine abnormale Programmbeendigung.

Der Vollständigkeit halber möchte ich noch genauer den noexcept-Operator vorstellen.

Der noexcept-Operator prüft zur Compilezeit, ob ein Ausdruck keine Ausnahme wirft, wertet diesen Ausdruck aber nicht aus. Er kann in einer noexcept-Spezifikation eines Funktions-Templates verwendet werden und ausdrücken, dass das Funktions-Template abhängig vom konkreten Datentyp eine Ausnahme werfen kann.

Um meine Beschreibung verständlicher zu machen, zeige ich ein einfaches Funktions-Template, dass seinen Rückgabewert kopiert:

// noexceptOperator.cpp

#include <iostream>
#include <array>
#include <vector>

class NoexceptCopy{
public:
std::array<int, 5> arr{1, 2, 3, 4, 5}; // (2)
};

class NonNoexceptCopy{
public:
std::vector<int> v{1, 2, 3, 4 , 5}; // (3)
};

template <typename T>
T copy(T const& src) noexcept(noexcept(T(src))){ // (1)
return src;
}

int main(){

NoexceptCopy noexceptCopy;
NonNoexceptCopy nonNoexceptCopy;

std::cout << std::boolalpha << std::endl;

std::cout << "noexcept(copy(noexceptCopy)): " << // (4)
noexcept(copy(noexceptCopy)) << std::endl;

std::cout << "noexcept(copy(nonNoexceptCopy)): " << // (5)
noexcept(copy(nonNoexceptCopy)) << std::endl;

std::cout << std::endl;

}

Klar, die interessanteste Zeile des Beispielprogramms ist die Zeile (1). Insbesondere der Ausdruck noexcept(noexcept(T(src)). Das innere noexcept ist der noexcept-Operator und das äußere der noexcept-Spezifizierer. Der Ausdruck noexcept(T(src)) prüft in diesem Fall, ob der Copy-Konstruktor keine Ausnahme wirft. Dies gilt für die Klasse Noexcept (2), aber nicht für die Klasse NonNoexcept (3), denn der Copy-Konstruktor des std::vector kann eine Ausnahme werfen. Entsprechend gibt der Ausdruck (4) true und der Ausdruck (5) false zurück.

Vermutlich weißt du es bereits. Mithilfe der Type-Traits-Bibliothek lässt sich zur Compilezeit prüfen, ob ein Datentyp T einen Konstruktor besitzt, der keine Ausnahme werfen kann: std::is_nothrow_copy_constructible::value. Daher kann auch statt des noexcept-Operators das Prädikat aus der Type-Traits-Bibliothek verwendet werden:

template <typename T> 
T copy(T const& src) noexcept(std::is_nothrow_copy_constructible<T>::value){
return src;
}

Ich weiß nicht, welche Version von copy du vorziehst? Ich ziehe die Version vor, bei der die Type-Traits-Bibliothek zum Einsatz kommen, denn diese bringt die Absicht des Codes besser auf den Punkt.

Der Titel der Regel hat mich ein wenig verwirrt. Er besagt, dass du eine Funktion als noexcept deklarieren sollst, wenn

  • diese keine Ausnahme wirft oder
  • du die Ausnahme nicht verarbeiten kannst. Du bist daher bereit, das Programm abstürzen zu lassen, denn eine std::bad_alloc-Ausnahme aufgrund von Speichermangel lässt sich nicht lösen.

Es ist keine gute Idee, eine Ausnahme zu werfen, wenn du der direkte Besitzer eines Objekts bist.

Hier ist das Beispiel zu den direkten Besitzverhältnissen der C++ Core Guidelines:

void leak(int x)   // don't: may leak
{
auto p = new int{7};
if (x < 0) throw Get_me_out_of_here{}; // may leak *p
// ...
delete p; // we may never get here
}

Wenn der throw-Ausdruck zum Einsatz kommt, geht der Speicher verloren und du hast ein Speicherleck. Die einfache Lösung ist es, dass du dein direktes Besitzverhältnis abgibst und stattdessen die C++-Laufzeit zum Besitzer des Objekts machst. Erzeuge dazu eine lokale Variable oder zumindest einen Wächter als lokale Variable. Jetzt kannst du dir sicher sein, dass die C++-Laufzeit auf ihre Objekte aufpasst. Hier sind die drei sicheren Variationen des Programms:

void leak(int x)   // don't: may leak
{
auto p1 = int{7};
auto p2 = std::make_unique<int>(7);
auto p3 = std::vector<int>(7);
if (x < 0) throw Get_me_out_of_here{};
// ...
}

p1 ist eine lokale Variable. Hingegen sind p2 und p3 eine Art Wächter für das Objekt. Der std::vector verwendet unter der Decke den Heap. Zusätzlich wirst du das händische Löschen der Variable p los.

Das ist einfach. Im nächsten Artikel folgt die Fortsetzung zu meiner Geschichte zu Ausnahmen und Fehlerbehandlungen in C++.

Ich freue mich darauf, weitere C++-Schulungen halten zu dürfen.

Die Details zu meinen C++- und Python-Schulungen gibt es auf www.ModernesCpp.de. ()