Aus Linux-Magazin 04/2020

C++ Core Guidelines – Folge 51

© Volodymyr Tverdokhlib, 123RF

Die Besitzverhältnisse stehen im Vordergrund, wenn es um die Wahl eines Smart Pointers geht. Rezepte für den richtigen Smart-Pointer-Einsatz liefern einmal mehr die C++ Core Guidelines.

Die C++ Core Guidelines kennen rund zehn Regeln zu Smart Pointern. Vor ihrem Einsatz sollte ein Entwickler zentrale Fragen klären: Wann empfiehlt es sich, Smart Pointer einzusetzen, und welchen Smart Pointer verwendet er konkret? Wie erzeugt er ihn und reicht ihn an eine Funktion weiter?

Einmaleins der Smart Pointer

Die Aufgabe der Smart Pointer besteht darin, Besitzverhältnisse zu repräsentieren. Dies macht die Regel R.20 [1] unmittelbar klar. Sie lautet: “Verwende »unique_ptr« oder »shared_ptr«, um Besitzverhältnisse anzuzeigen.” Den Grund nennt die Regel auch: Das verhindert Ressourcen-Lecks.

C++20 unterstützt drei Varianten von Smart Pointern, die konsequenterweise drei verschiedene Besitzverhältnisse repräsentieren. Ein »std::unique_ptr« bürgt für exklusiven Besitz, ein »std::shared_ptr« lediglich für geteilten Besitz. Ein »std::weak_ptr« schließlich beschränkt sich auf zeitweiligen Besitz.

Ein »std::unique_ptr« ist exklusiver Besitzer einer Ressource. Er lässt sich zwar nicht kopieren, wohl aber verschieben, wie Listing 1 demonstriert.

Listing 1

std::unique_ptr

std::unique_ptr<int> uniquePtr1{new int(1998)};
std::unique_ptr<int> uniquePtr2(std::move(uniquePtr1));

Im Gegensatz dazu teilt ein »std::shared_ptr« seine Ressource. Kopiert der Entwickler ihn oder weist ihn zu, erhöht das seinen Referenzzähler (Listing 2). Löscht er ihn oder setzt er ihn zurück, senkt das den Referenzzähler. Erreicht der Referenzzähler den Wert »0«, löscht C++ die Ressource.

Listing 2

std::shared_ptr

std::shared_ptr<int> sharedPtr1{new int(1998)}; // Referenzzähler 1
std::shared_ptr<int> sharedPtr2(sharedPtr1); // Referenzzähler 2

Der Dritte im Bunde, der »std::weak_ptr«, agiert als zeitweiliger Besitzer. Genau genommen ist ein »std::weak_ptr« nicht smart, da er keinen Zugriff auf die Ressource erlaubt. Vielmehr leiht er sie sich von einem »std::shared_ptr« aus. Im Gegensatz zum »std::shared_ptr« verändert der »std::weak_ptr« jedoch den Referenzzähler nicht. Hat er eine Ressource von einem »std::shared_ptr« geliehen, erzeugt der Aufruf der Methode »lock()« auf dem »std::weak_ptr« einen »std::shared_ptr«. So entsteht ein temporärer Besitzer der Ressource (Listing 3).

Listing 3

std::weak_ptr

std::shared_ptr<int> sharedPtr1{new int(1998)}; // Referenzzähler 1
std::weak_ptr<int> weakPtr1(sharedPtr1); // Referenzzähler 1
std::shared_ptr<int> sharedPtr2 = weakPtr1.lock(); // Referenzzähler 2

Der »std::shared_ptr« kommt dabei in Anbetracht der Regel R.21 [2] deutlich zu häufig zum Einsatz: “Bevorzuge den »unique_ptr« gegenüber dem »shared_ptr«, es sei denn, Du möchtest das Besitzverhältnis teilen.”

Der »std::unique_ptr« sollte immer die erste Wahl für einen Smart Pointer sein, denn er arbeitet qua Design so schnell und ressourcenschonend wie ein nackter Zeiger. @L:Für einen »std::shared_ptr« gilt das nicht: Er muss einen Referenzzähler in seinem Kontrollblock verwalten und dafür Speicher zuweisen. Er ist die erste Wahl, wenn der Entwickler geteilte Besitzverhältnisse anstrebt. In diesem Fall lässt sich das mehrmalige Allozieren der Ressource vermeiden.

Auf keinen Fall sollte der Programmierer einen »std::shared_ptr« nur deshalb verwenden, weil er sich kopieren lässt. Zwar darf er einen »std::unique_ptr« nicht kopieren, dafür aber verschieben. Genau dieses Verhalten zeigt Listing 4.

Listing 4

Verschieben eines std::unique_ptr

#include <algorithm>
#include <iostream>
#include <memory>
#include <utility>
#include <vector>
void takeUniquePtr(std::unique_ptr<int> uniqPtr) {
std::cout << "*uniqPtr: " << *uniqPtr << std::endl;
}
int main() {
std::cout << std::endl;
auto uniqPtr1 = std::make_unique<int>(2011);
takeUniquePtr(std::move(uniqPtr1));
auto uniqPtr2 = std::make_unique<int>(2014);
auto uniqPtr3 = std::make_unique<int>(2017);
std::vector<std::unique_ptr<int>> vecUniqPtr {};
vecUniqPtr.push_back(std::move(uniqPtr2));
vecUniqPtr.push_back(std::move(uniqPtr3));
vecUniqPtr.push_back(std::make_unique<int>(2020));
std::cout << std::endl;
std::for_each(vecUniqPtr.begin(), vecUniqPtr.end(),
[](std::unique_ptr<int>& uniqPtr) {
std::cout << *uniqPtr << std::endl;
});
std::cout << std::endl;
}

Die Funktion »takeUniquePtr()« (Zeile 8) nimmt einen »std::unique_ptr« über einen Wert an. Dazu muss der Aufrufer den »std::unique_ptr« in die Funktion verschieben (Zeile 17). Das gilt auch für Zeile 22.

Der »std::vector« besitzt als ein Container der Standard Template Library Copy-Semantik: Er möchte seine Elemente besitzen und daher kopieren. Deshalb muss der Code in den Zeilen 23 und 24 zwei »std::unique_ptr« verschieben. Ein direktes Erzeugen des Unique Pointers im »std::vector« wie in Zeile 25 ist ebenfalls erlaubt. Selbst dem Einsatz eines Algorithmus der Standard Template Library wie »std::for_each« auf einen exklusiven Smart Pointer (»std::vector<std::unique_ptr>«) wie in Zeile 27 steht nichts im Wege, sofern der Algorithmus intern keine Copy-Semantik anwendet. Abbildung 1 zeigt die Ausgabe des Programms.

<a href="#artRef-f1">Abbildung 1</a>: Das Verschieben eines &raquo;std::unique_ptr&laquo; l&auml;sst sich an einem Programm demonstrieren.

Abbildung 1: Das Verschieben eines »std::unique_ptr« lässt sich an einem Programm demonstrieren.

Es gibt zwei gewichtige Gründe, einen »std::unique_ptr« oder einen »std::shared_ptr« nicht direkt zu erzeugen, sondern über die Fabrikfunktionen »std::make_unique()« respektive »std::make_shared()«. Diesen beiden Gründen widmen sich die Regeln R.22 [3] und R.23 [4].

Der erste Grund ist die sogenannte Exception Safety. Listing 5 bringt das Problem auf den Punkt. Im ersten Aufruf der Funktion »fun()« lauert ein Speicherleck: Beim Generieren der zwei »std::shared_ptr« kann es passieren, dass der Code zuerst den Speicher für die Objekte zuweist und dann erst die beiden Konstruktoren aufruft. Schlägt in diesem Fall die Allokation des Speichers fehl, gibt das Programm den Speicher des anderen Objekts nicht mehr frei. Beim Aufruf von »std::make_shared« kann ein solches Speicherleck nicht auftreten.

Listing 5

Ein mögliches Speicherleck

void fun(std::shared_ptr<Widget> sp1, std::shared_ptr<Widget> sp2);
fun(std::shared_ptr<Widget>(new Widget(a), std::shared_ptr<Widget>(new Widget(b))); // Potenzielles Speicherleck
fun(std::make_shared<Widget>(a), std::make_shared<Widget>(b)); // Kein Speicherleck möglich

Der zweite Grund betrifft nur »std::shared_ptr«. Der Aufruf der ersten Zeile aus Listing 6 erzwingt zwei Speicherallokationen, zuerst für »new int(1998)« und anschließend für den Kontrollblock des »std::shared_ptr«. Speicherallokationen sind jedoch sehr teure Operationen. Der Aufruf »std::make_shared<int>(1998)« in der zweiten Zeile verwendet nur eine Speicherallokation für die Ressource und den Kontrollblock und arbeitet daher deutlich schneller.

Listing 6

Zwei std::shared_ptr erzeugen

auto shar1 = std::shared_ptr<int> sharedPtr1{new int(1998)};
auto shar2 = std::make_shared<int>(1998);

Smart Pointer als Funktionsparameter

Nun aber zur spannenden Frage, wie Entwickler Smart Pointer an eine Funktion übergeben. Darüber diskutieren selbst erfahrene C++-Entwickler intensiv. Dabei reduziert sich die Antwort auf einen einfachen Aspekt: Besitzverhältnisse.

Unique Pointer (»std::unique_ptr«) sollte der Entwickler per Copy und Referenz übergeben. Für geteilte Pointer (»std::shared_ptr«) greift er zusätzlich zu einer konstanten Referenz.

Die Regeln R.32 [5] und R.33 [6] widmen sich dem »std::unique_ptr«. Eine Übergabe als Kopie verwandelt die Funktion in den Besitzer des »std::unique_ptr« und somit auch in den Besitzer der Ressource. Die Konsequenz: Der Aufrufer der Funktion muss »std::unique_ptr<Widget>« verschieben, um den Code übersetzen zu können (Listing 7). Der Aufruf »sink(std::move(uniqPtr))« ist hier zulässig; der Aufruf »sink(uniqPtr)« schlägt hingegen fehl, da C++ einen »std::unique_ptr« nicht kopieren kann.

Listing 7

std::unique_ptr per Copy annehmen

#include <memory>
#include <utility>
struct Widget{
Widget(int){}
};
void sink(std::unique_ptr<Widget> uniqPtr){
// Tu etwas mit uniqPtr
}
int main(){
auto uniqPtr = std::make_unique<Widget>(1998);
sink(std::move(uniqPtr)); // OK
sink(uniqPtr); // Fehler
}

Benötigt die Funktion also die Möglichkeit, die Ressource zu modifizieren, muss der Entwickler »std::unique_ptr« über eine nicht konstante Referenz übergeben. In Beispiel aus Listing 8 schlägt der Aufruf »reseat(std::move(uniqPtr))« fehl, da sich ein Rvalue nicht an eine nicht konstante Lvalue-Referenz binden lässt. Das gilt aber nicht für den Kopiervorgang im folgenden Aufruf »reseat(uniqPtr)«: Ein Lvalue lässt sich an eine nicht konstante Lvalue-Referenz binden.

Besonders interessant ist der Aufruf »uniquePtr.reseat(new Widget(2000))« in der Funktion »reseat()«. Er erzeugt ein neues »Widget(2003)« und räumt dabei auch das alte »Widget(1998)« auf. Die Details zu Lvalues und Rvalues schildert der Artikel “Rasch verschoben” [7].

Listing 8

Nicht konstante Referenz

#include
 <memory>
#include <utility>
struct Widget{
Widget(int){}
};
void reseat(std::unique_ptr<Widget>& uniqPtr){
uniqPtr.reset(new Widget(2003)); // (0)
// Tu etwas mit uniqPtr
}
int main(){
auto uniqPtr = std::make_unique<Widget>(1998);
reseat(std::move(uniqPtr)); // Fehler
reseat(uniqPtr); // OK
}

Für »std::shared_ptr« stehen drei Funktionssignaturen zur Debatte (Listing 9). Es erstaunt wenig, dass es sich bei den entsprechenden Regeln um fast buchstäbliche Wiederholungen der Vorgaben für die »std::unique_ptr« handelt.

Listing 9

Funktionssignaturen für std::shared_ptr

void share(std::shared_ptr<Widget> shaWid);
void reseat(std::shard_ptr<Widget>& shadWid);
void mayShare(const std::shared_ptr<Widget>& shaWid);

Um den Leser nicht über Gebühr zu langweilen, lassen wir an dieser Stelle ausnahmsweise einmal die Funktionen selbst zu Wort kommen:

  • »void share(std::shared_ptr<Widget> shaWid)«: Ich bin für die Lebenszeit des Funktionskörpers ein Miteigentümer des »Widget«. Am Anfang des Funktionskörpers inkrementiere ich den Referenzzähler, und am Ende dekrementiere ich den Referenzzähler. Daher bleibt das »Widget« so lange am Leben, wie ich es benötige [8].
  • »void reseat(std::shard_ptr<Widget>& shadWid)«: Ich bin kein Miteigentümer des »Widget«, da ich seinen Referenzzähler nicht erhöhe. Ich kann keine Garantie liefern, dass das »Widget« gültig ist, während ich es verwende. Ich kann das »Widget« aber neu setzen. Eine nicht konstante Referenz ist eine Form des Ausleihens, die es erlaubt, die Ressource neu zu setzen [9].
  • »void mayShare(const std::shared_ptr<Widget>& shaWid)«: Ich leihe mir das »Widget« nur aus. Weder kann ich seine Lebenszeit verlängern noch es zurücksetzen [10].

Im Fall eines »const std::shared_ptr<Widget>&« als Funktionsparameter bietet es sich an, einen Zeiger (»Widget*«) oder eine Referenz (»Widget&«) zu verwenden, denn ein »std::shared_ptr« fügt keinen Mehrwert hinzu.

Wie geht es weiter?

Den Regeln zur Ressourcenverwaltung folgen in den C++ Core Guidelines die zu Expressions und Statements. Unter anderem geht es im nächsten Artikel um das Deklarieren von Namen. (kki/jlu)

Der Autor

Der C++- und Python-Trainer Rainer Grimm hat bei O’Reilly und Leanpub zahlreiche Bücher zu C++ veröffentlicht, zuletzt “The C++ Standard Library” und “Concurrency with modern C++”.

DIESEN ARTIKEL ALS PDF KAUFEN
EXPRESS-KAUF ALS PDFUmfang: 4 HeftseitenPreis €0,99
(inkl. 19% MwSt.)
LINUX-MAGAZIN KAUFEN
EINZELNE AUSGABE Print-Ausgaben Digitale Ausgaben
ABONNEMENTS Print-Abos Digitales Abo
TABLET & SMARTPHONE APPS Readly Logo
E-Mail Benachrichtigung
Benachrichtige mich zu:
1 Kommentar
Älteste
Neuste Beste Bewertung
Inline Feedbacks
Alle Kommentare anzeigen
Frank Mertens
5 Jahre her

Im Listing 5, Zeile 2 fehlt eine “)” hinter

new Widget(a)

(Ist da was beim Konvertierung von latex kaputt gegangen?-)

Insgesamt finde ich es eingehender überall die neue Konstruktor-Syntax ala {} zu verwenden.

Nach oben