C++: Wann sollten Funktionen noexcept deklariert werden?
C++11 hat mit noexcept
eine Alternative zu den unbeliebten throw
-Deklarationen eingeführt. Die Semantik von noexcept
ist sehr einfach. Unklarheit herrscht dagegen noch über den richtigen Einsatz. Daher ist es höchste Zeit, zumindest ein paar Leitplanken vorzugeben.
Grundsätzlich kann die Verwendung von noexcept
sowohl zur Robustheit als auch zur Performance beitragen. In beiden Fällen geht es primär darum, Informationen auf höheren Abstraktionsebenen transparent und nutzbar zu machen, die sonst nur auf der Detailebene bekannt sind. Und ganz wichtig dabei: Im Gegensatz zu throw()
verursacht noexcept
üblicherweise keinen Laufzeit-Overhead.
Robustheit
Unabhängig davon ob man für die Fehlerbehandlung Ausnahmen einsetzt oder nicht: Wenn ein Fehler auftritt, dann behandelt man ihn. Und wenn bei der Behandlung des Fehlers ein weiterer Fehler auftritt, dann behandelt man auch diesen. Und wenn … Stop! An irgendeiner Stelle muss man diesen Teufelskreis unterbrechen, und dabei helfen Operationen, die überhaupt keine Fehler liefern können.
Dabei könnte man zwischen Operationen unterscheiden, die grundsätzlich nicht fehlschlagen, und solchen, bei denen ein sofortiger Programmabbruch im Fehlerfall üblicherweise sinnvoller als jede Fehlerbehandlung ist. Für die Verwendung von noexcept
ist diese Unterscheidung jedoch irrelevant. Wichtig ist nur, dass eine Grundmenge solcher Operationen existiert. Dazu gehören insbesondere:
- Operationen für die Klassifizierung von Fehlern,
- Operationen für die Freigabe von Speicher, Dateien und sonstiger Ressourcen,
- sowie Operationen für die strukturelle Komposition solcher Operationen.
Was bedeutet das nun konkret für Entwickler? Es gibt zwei wichtige Regeln: Erstens, Destruktoren sollten immer noexcept
sein. Und zweitens, Konstruktoren und Zuweisungsoperatoren mit Move-Semantik sowie swap
-Funktionen sollten noexcept
sein, falls sie deklariert werden. Das folgende Listing zeigt nochmals, wie die Deklarationen aussehen sollten.
class gadget
{
public:
// ...
~gadget() noexcept;
gadget(gadget&& rhs) noexcept;
auto operator=(gadget&& rhs) & noexcept -> gadget&;
void swap(gadget& rhs) noexcept;
};
Der noexcept
-Destruktor ist in den meisten Fällen Voraussetzung für eine korrekte Fehlerbehandlung und wird insbesondere von der Standardbibliothek vorausgesetzt. Die anderen drei noexcept
-Operationen ermöglichen dagegen Implementierungen ohne Komensationslogik. Das folgende Listing zeigt eine typische Implementierung eines Kopierkonstruktors, der zwar Ausnahmen werfen kann, aber dennoch die wichtige Eigenschaft besitzt, dass die Operation entweder ganz oder gar nicht ausgeführt wird. Alternativ hätte man auch return operator=(gadget{rhs})
schreiben können.
auto gadget::operator=(const gadget& rhs) & -> gadget&
{
gadget{rhs}.swap(*this);
return *this;
}
Performance
Bezüglich Performance im Zusammenhang mit noexcept
sollte man sich auf die Fälle fokussieren, die einen signifikanten Geschwindigkeitsunterschied ausmachen können. Dafür gibt es ein klassisches Beispiel. Das folgende Listing zeigt, wie eine Sequenz von Zeichenketten beispielsweise in C aussehen könnte.
typedef char* string_t;
size_t strings_size = ...;
string_t* strings = malloc(strings_size * sizeof(string_t));
Um die Sequenz zu vergrößern, legt man einen neuen Speicherbereich an, kopiert alle Zeiger dorthin und löscht schließlich den alten Speicherbereich. In C++ sah das vor C++11 noch ganz anders aus, wenn man auf std::vector<std::string>
setzte. Dort wurden nicht die Zeiger sondern gleich die kompletten Zeichenketten beim Vergrößern kopiert. C++11 adressierte dieses Problem mit der Move-Semantik. Voraussetzung ist jedoch immer noch, dass der Move-Konstruktor des Elementtyps keine Ausnahme wirft, was sich mit Hilfe von noexcept
in generischem Code unterscheiden lässt.
Die Deklaration noexcept
ermöglicht also in generischem Code statische Fallunterscheidungen, die viele Verwendungen signifikant schneller machen können. Praktisch gesehen sind dafür jedoch nur die Konstruktoren und Zuweisungsoperatoren mit Move-Semantik relevant. Die von std::string
sind entsprechend deklariert, so dass nun die Unterschiede zwischen std::vector<std::string>
und einer Low-Level-Implementierung in C vernachlässigbar geworden sind.
Fazit
Es gibt ein paar Operationen, die man nach Möglichkeit immer noexcept
deklarieren sollten. Konkret betrifft das Destruktoren, Konstruktoren und Zuweisungsoperatoren mit Move-Semantik sowie swap
-Funktionen. Diese einfache Regel adressiert sowohl die Robustheit als auch die Performance. Darüber hinaus gibt es sicherlich noch viele Spezialfälle, für die noexcept
zu empfehlen ist. Doch der wichtigste Teil ist bereits abgedeckt.