C++11 Coding Style und Move-Operationen
Für diesen Artikel habe ich mir einiges vorgenommen. Ich versuche anhand eines einzigen Beispiels gleich drei unterschiedliche Themen zu anzuschneiden: Erstens möchte ich ein wenig über Coding Styles bezüglich C++11 sprechen. Zweitens möchte ich zeigen, wie Move-Operationen für komplexe Klassen implementiert werden. Und drittens möchte ich auch die Entwickler adressieren, die mit Move-Semantik noch nicht so viel anzufangen wissen, und ihnen das Thema näher bringen.
Dieser Spagat ist nicht einfach und hat ein paar Auswirkungen. Insbesondere verzichte ich aufgrund dieser Vorgaben fast vollständig auf die Verwendung der Standardbibliothek. Das macht ein paar Dinge unnötig kompliziert, sollte aber zumindest Entwicklern anderer Programmiersprachen den Einstieg erleichtern.
Als Beispiel verwende ich die Implementierung einer Container-Klasse. Um den Code übersichtlich zu halten, verzichte ich dabei vollständig auf Templates. Außerdem beschränke ich mich auf eine einzige Operation, die für Container repräsentativ ist. Der eigentliche Fokus liegt hingegen auf den Copy- und Move-Operationen, die in diesem Beispiel entsprechend einen großen Anteil ausmachen.
Definition
Ich betrachte zunächst die Definition der Klasse. Diese baue ich im Folgenden schrittweise auf, damit ich zu den einzelnen Teilen ein paar Worte schreiben kann.
class vector final
{
};
Bereits an dieser Rumpf-Definition sind zwei Dinge wichtig: Erstens gibt es keine Basisklasse, und zweitens ist die Klasse final deklariert, da sie nicht für die Vererbung vorgesehen ist. Letzteres ist neu in C++11 und meine Empfehlung für alle Klassen, die nicht explizit als Basisklassen konzipiert sind.
Member-Variablen
Zu Instanzvariablen gibt es ganz viele Dinge zu sagen. Das Wichtigste ist aus meiner Sicht, dass Instanzvariablen immer zusammen stehen sollten – unabhängig von den Zugriffsrechten – und stets getrennt von eventuell existierenden Klassenvariablen, denn mit Letzteren haben sie nicht zu tun. Ich verwende außerdem den führenden Unterstrich zur Unterscheidung der Instanzvariablen von anderen Variablen. Des weiteren sollten im gesamten Projekt die Instanzvariablen immer an der gleichen Stelle innerhalb einer Klasse stehen, damit man sie leichter findet. Ich bevorzuge dafür den Anfang der Klasse, weil der potentielle Zustandsraum aus meiner Sicht hilft eine Klasse zu verstehen. Das steht übrigens nicht im Widerspruch zur Datenkapselung, da es dabei in erster Linie um die Kontrolle des Zugriffs und weniger um das Verbergen des Zustandsraums geht.
private: // --- fields ---
std::size_t _capacity{0};
std::size_t _size{0};
int* _data{nullptr};
Neu in C++11 ist die Möglichkeit, bei der Deklaration eine Default-Initialisierung anzugeben. Wenn die Variable nicht im Konstruktor initialisiert wird, dann wird diese Initialisierung verwendet. Ich empfehle diese Initialisierung bei allen nativen Datentypen vorzunehmen. Dadurch verhindert man einige dumme Fehler aufgrund fehlender Initialisierung, und andererseits habe ich noch nie einen Fall mit sichtbarer Auswirkung auf die Performance gesehen.
Konstruktion und Destruktion
Als nächstes kommen die Konstruktoren und der Destruktor. Neu in C++11 ist der sogenannte Move-Konstruktor, der in meinem Beispiel direkt auf den Copy-Konstruktor folgt.
public: // --- con-/destruction ---
vector() noexcept;
vector(const vector& rhs);
vector(vector&& rhs) noexcept;
~vector() noexcept;
private:
vector(std::size_t capacity, std::size_t size, int* data);
Auffallend ist die Verwendung von noexcept
. Dadurch wird zur Laufzeit sichergestellt, dass die entsprechend ausgezeichneten Operationen keine Ausnahmen werfen. Das Verhalten ist vergleichbar mit der leeren Exception-Spezifikation throw()
, beseitigt aber die größten Probleme und bietet zusätzlich die wichtige Möglichkeit zur Übersetzungszeit die Information auszuwerten.
Die Spezifikation mit noexcept
hat in den drei Fällen einen leicht unterschiedlichen Hintergrund auf den ich kurz eingehen möchte. Bei Destruktoren war es schon immer so, dass diese keine Ausnahmen werfen sollten. Daran hat sich auch mit C++11 nichts geändert. Zum Default-Konstruktor werde ich weiter unten noch etwas sagen. Und beim Move-Konstruktor sollte man beachten, dass viele Optimierungen nur möglich sind, wenn er keine Ausnahmen wirft – und auch entsprechend deklariert ist. Ich werde weiter unten nochmals darauf zurückkommen.
Spezielle Member-Funktionen
Als nächstes kommen der Copy- und der Move-Assignment-Operator sowie die swap-Funktion. Die beiden Operatoren sind eng mit den beiden entsprechenden Konstruktoren verwandt. Ich trenne die beiden Gruppen dennoch ganz bewusst. Denn ein essentieller Unterschied ist, dass Konstruktoren auf uninitialisiertem Speicher und Operatoren auf vollständig konstruierten Objekten arbeiten.
public: // --- special functions ---
auto operator=(const vector& rhs) & -> vector&;
auto operator=(vector&& rhs) & noexcept -> vector&;
void swap(vector& rhs) noexcept;
Mit der swap-Funktion sollten die meisten C++‑Entwickler bereits vertraut sein. An dieser Stelle beschränke ich mich auf die Information, dass es üblich ist, eine solche Funktion in nicht-trivialen Datenstrukturen anzubieten. Der Nutzen dieser Funktion wird bei der Implementierung deutlich werden.
Member-Funktionen
Abschließend kommt noch die Deklaration der Member-Funktionen. Insbesondere findet sich hier die einzige, echte Container-Operation, die repräsentativ für alle anderen Operationen steht. Die beiden privaten Hilfsfunktionen werden hingegen nur intern benutzt, um die Implementierung lesbarer und verständlicher zu gestalten.
public: // --- member functions ---
void push_back(int value);
private:
auto move_assign(vector&& rhs) noexcept -> vector&;
void ensure_capacity(std::size_t required_capacity);
};
Implementierung
Wenn ich die Deklaration von der Definition trenne, dann definiere ich üblicherweise die Operationen in der gleichen Reihenfolge, in der sie auch deklariert sind. In diesen Artikel weiche ich von diesem Vorgehen allerdings bewusst ab und wähle stattdessen eine Reihenfolge, die die Abhängigkeiten der Operationen untereinander berücksichtigt.
swap
Die swap-Funktion gehört zu den grundlegendsten Funktionen und wird fast immer nach dem gleichen Muster implementiert: Die swap-Operation wird rekursiv
auf alle Member-Variablen angewendet. Dadurch wird der Zustand der beiden Objekte vollständig ausgetauscht. In diesem Fall sind alle Member-Variablen von einem einfachen Typ. Die Implementierung sieht bei komplexeren Typen aber identisch aus.
template <typename Type>
void swap_noexcept(Type& lhs, Type& rhs) noexcept
{
using std::swap;
static_assert(noexcept(swap(lhs, rhs)), "swap has to be noexcept");
swap(lhs, rhs);
}
Ich habe an dieser Stelle ein zusätzliches Funktionstemplate genutzt, das man am Besten in einer zentralen Bibliothek platziert. Dieses Funktionstemplate delegiert den Aufruf an die eigentliche swap-Funktion, prüft aber zusätzlich mit den Möglichkeiten von C++11, ob die aufgerufene Funktion mit noexcept
ausgezeichnet ist. Wenn das nicht der Fall ist, dann scheitert bereits die Übersetzung.
void vector::swap(vector& rhs) noexcept
{
swap_noexcept(_capacity, rhs._capacity);
swap_noexcept(_size, rhs._size);
swap_noexcept(_data, rhs._data);
}
Auf eine Eigenschaft möchte ich besonders hinweisen: Wenn die swap-Implementierung auf diese Weise rekursiv bis auf die primitiven Datentypen herunter gebrochen wird, dann kann die swap-Funktion keine Ausnahme werfen.
Damit das Ganze aber auch funktioniert, wenn diese Klasse in einer anderen Klasse verwendet wird, ist noch eine zusätzliche Funktion im umgebenen Namensraum notwendig, die den Aufruf einfach an die Member-Funktion delegiert.
void swap(vector& lhs, vector& rhs) noexcept
{
lhs.swap(rhs);
}
Konstruktion und Destruktion
Nach dieser Vorarbeit kann ich mich nun den Konstruktoren und dem Destruktor zuwenden. Für den Default-Konstruktor ist nicht zu tun, da die Default-Initialisierung der Member-Variablen bereits ausreicht. Ich habe mich ganz bewusst dagegen entschieden, im Default-Konstruktor Speicher für die ersten Elemente zu allokieren. Es ist allgemein eine Best-Practice, den Default-Konstruktor einfach zu halten. Und durch noexcept
mache ich diese Entwurfsentscheidung auch an der Schnittstelle explizit.
vector::vector() noexcept = default;
Den Copy-Konstruktor delegiere ich an den privaten Konstruktor. Diese Möglichkeit ist neu in C++11 und vereinfacht es, redundanten Code zu vermeiden.
vector::vector(const vector& rhs)
: vector{rhs._size, rhs._size, rhs._data}
{ }
Interessant ist der Move-Konstruktor: Zunächst wird das Objekt leer konstruiert und anschließend der Zustand mit dem übergebenen Objekt getauscht. Da beide Operationen noexcept
sind, kann auch der Move-Konstruktor keine Ausnahme werfen. Diese Art der Implementierung mag auf den ersten Blick merkwürdig erscheinen. Tatsächlich ist es aber die übliche Vorgehensweise. Denn dadurch wird minimalem Aufwand genau das gewünschte Ergebnis erreicht: Das neu konstruierte Objekt übernimmt den Zustand des übergebenen Objekts, und Letzteres wird in einen Default-Zustand versetzt.
vector::vector(vector&& rhs) noexcept
: vector{}
{
swap(rhs);
}
Der Destruktor ist dagegen sehr gewöhnlich. Erwähnenswert ist lediglich, dass keine Fallunterscheidung für nullptr
benötigt wird, denn der delete
-Aufruf macht in diesem Fall einfach gar nichts.
vector::~vector() noexcept
{
delete [] _data;
}
Der private und intern genutzte Konstruktor gehört in meiner Implementierung zu den kompliziertesten Operationen. Dabei ist er in diesem Fall noch relativ einfach, da nur primitive Typen gespeichert werden. Wichtig ist in diesem Fall nämlich, dass nach der Speicherallokation keine Ausnahme auftreten kann. Ansonsten hätte ich innerhalb dieses Konstruktors ein Memory-Leak.
vector::vector(std::size_t capacity, std::size_t size, int* data)
: _capacity{capacity}, _size{size}
{
_data = new int[_capacity];
for (std::size_t i = 0; i != _size; ++i) {
_data[i] = data[i];
}
}
Zuweisung
Für die Zuweisung verwende ich eine Hilfsfunktion, die genau dem Move-Assignment-Operator entspricht. Das ist zwar redundant, aber es verbessert die Lesbarkeit des Codes.
auto vector::move_assign(vector&& rhs) noexcept -> vector&
{
swap(rhs);
return *this;
}
Die Implementierung des Copy-Assignment-Operators erfolgt ganz klassisch über den Copy-Konstruktor und ein temporäres Objekt – genauso wie das seit über zehn Jahren in jedem guten Buch zu C++ gelehrt wird. Und daran ändert sich auch mit C++11 nichts – außer dass statt Swap nun auch ein Move verwendet werden kann, um den eigenen Zustand zu ersetzen.
auto vector::operator=(const vector& rhs) & -> vector&
{
return move_assign(vector{rhs});
}
auto vector::operator=(vector&& rhs) & noexcept -> vector&
{
return move_assign(std::move(rhs));
}
Der Move-Assignment-Operator besteht analog zum Move-Konstrutor praktisch nur aus einem swap-Aufruf. Wichtig ist noch für beide Operatoren, dass die Selbstzuweisung korrekt funktioniert. Dieser Fall tritt in der Praxis viel häufiger auf als manche glauben – beispielsweise wenn beim Sortieren ein Element bereits an der richtigen Stelle steht. Die verwendete Implementierung benötigt dafür keine Fallunterscheidung.
Member-Funktionen
Zu den beiden verbleibenden Member-Funktionen gibt es nicht mehr viel zu sagen. Bei der zweiten Funktion habe ich ein wenig an der Implementierung gespart. In die Strategie für die Berechnung der neuen Größe würde man üblicherweise ein wenig mehr Arbeit investieren. Insbesondere sollte man an dieser Stelle auch auf Überlauf prüfen und im Fehlerfall eine Ausnahme werfen.
void vector::push_back(int value)
{
ensure_capacity(_size + 1);
_data[_size++] = value;
}
void vector::ensure_capacity(std::size_t required_capacity)
{
if (_capacity < required_capacity) {
move_assign(vector{_capacity + required_capacity, _size, _data});
}
}
Fazit
Ich möchte abschließend nochmals auf zwei besonders Dinge hinweisen, die im Rahmen dieses Artikels deutlich geworden sein sollten. Erstens verändert C++11 den Programmierstil an vielen Stellen sichtbar. Die bisherigen Richtlinien sind zwar weiterhin korrekt, müssen aber an vielen Stellen an die neuen Möglichkeiten der Sprache angepasst werden. Und zweitens gilt für die Move-Operationen das Gleiche wie für alle anderen Standard-Operationen: Es gibt bewährte Vorgehensweisen für deren Implementierung, die sowohl robust als auch effizient sind. Und man sollte nur von diesem Vorgehen abweichen, wenn man einen guten Grund dafür hat.