Table of Contents
Motivation
“C makes it easy to shoot yourself in the foot; C++ makes it harder, but when you do it blows your whole leg off.”
Bjarne Stroustrup
Damals
Bevor Smart Pointer existierten, musste der Entwickler den Speicher vollständig selbst verwalten. Jede Allokation mit new erforderte ein manuelles delete – und wer das vergaß oder an der falschen Stelle aufräumte, öffnete die Tür für schwer zu findende Fehler. Schauen wir uns an, wie das damals aussah:
struct Vec3D
{
int x{0};
int y{0};
int z{0};
Vec3D() = default;
Vec3D(int x, int y, int z) : x(x), y(y), z(z) {}
};
TEST(MyUniquePointer, OldSchoolPointer)
{
Vec3D* my_pointer = new Vec3D;
my_pointer->x = 42;
EXPECT_EQ(my_pointer->x, 42);
delete my_pointer;
my_pointer = nullptr;
EXPECT_EQ(my_pointer, nullptr);
}
Klassische Fehler mit rohen Zeigern
Die manuelle Speicherverwaltung mit rohen Zeigern ist fehleranfällig. Es gibt einige typische Fehlerkategorien, die immer wieder auftauchen – und die in großen Codebasen besonders schwer zu debuggen sind.
Memory Leaks
Memory leaks entstehen immer dann, wenn Speicher auf dem heap mit new alloziert wurde, aber nicht wieder mit delete freigegeben wird. Das ist problematisch, weil dadurch der Speicher (RAM) immer weiter zugemüllt wird und irgendwann nicht mehr genügend Platz zur Verfügung steht. Programme stürzen dann unvermittelt ab
Wie kommt es dazu?
Ein häufiger Fall: Eine Funktion alloziert Speicher, aber ein früher return-Pfad oder eine geworfene Exception sorgt dafür, dass das delete nie erreicht wird. Selbst bei sorgfältigem Code schleichen sich solche Fehler schnell ein – besonders wenn der Code im Laufe der Zeit erweitert wird.
Double delete
Ein Double delete passiert, wenn delete auf denselben Zeiger zweimal aufgerufen wird. Das Ergebnis ist undefined behavior – in der Praxis häufig ein Absturz oder eine Speicherkorruption, die sich erst viel später bemerkbar macht. Das folgende Beispiel demonstriert das Problem:
void double_delete()
{
Vec3D* my_pointer = new Vec3D;
my_pointer->x = 42;
delete my_pointer;
delete my_pointer;
}
TEST(MyUniquePointer, OldSchoolPointerDoubleDelete)
{
EXPECT_DEATH(double_delete(), ".*");
}
Use after free
Use after free bedeutet, dass auf einen Zeiger zugegriffen wird, nachdem der dahinterliegende Speicher bereits mit delete freigegeben wurde. Der Zeiger zeigt dann auf eine Speicherstelle, die vom System bereits neu vergeben worden sein kann. Das Verhalten ist undefined – der Code kann scheinbar funktionieren, falsche Werte liefern oder abstürzen. Besonders tückisch: Der Fehler tritt oft nicht sofort auf, sondern erst unter bestimmten Laufzeitbedingungen.
Unklare Besitzverhältnisse
Bei rohen Zeigern ist oft nicht klar, wer für das Freigeben des Speichers zuständig ist. Gibt eine Funktion einen Zeiger zurück – wem gehört er dann? Dem Aufrufer? Der aufgerufenen Funktion? Muss der Aufrufer delete aufrufen oder nicht? Diese Unklarheit führt entweder zu Memory Leaks (niemand macht delete) oder zu Double deletes (beide machendelete). In größeren Teams und Codebasen wird dieses Problem schnell zur ernsthaften Quelle von Bugs.
Smart Pointer zur Rettung
Ein Smart Pointer ist ein Wrapper um einen rohen Zeiger und kommt in den Geschmacksrichtungen unique, shared und weak. Wenn man einen der drei verwenden will, muss #include <memory> eingebunden werden.
std::unique_ptr
Der Highlander unter den Smart-Pointern. Getreu dem Motto „es kann nur einen geben” hat nur ein einziger Pointer Zugriff auf einen allozierten Speicherbereich.
Ein unique_ptr kann anstatt eines normalen Pointers verwendet werden:
TEST(MyUniquePointer, UniquePointer)
{
std::unique_ptr my_pointer(new Vec3D);
my_pointer->x = 42;
EXPECT_EQ(my_pointer->x, 42);
}
Besser ist jedoch die Verwendung von make_unique, und zwar aus drei Gründen:
- Exception-Sicherheit – Bei
std::unique_ptr<Vec3D> p(new Vec3D)sind zwei separate Operationen im Spiel: die Allokation mitnewund die Konstruktion desunique_ptr. Wenn dazwischen eine Exception geworfen wird, kann der Speicher verloren gehen.make_uniquefasst beides atomar zusammen. - Kein
new, kein Typ-Duplikat – Der Typ muss nur einmal angegeben werden, was den Code kürzer und weniger fehleranfällig beim Refactoring macht. - Kein roher Zeiger sichtbar – Weil
newgar nicht erst auftaucht, gibt es keine Chance, den rohen Zeiger versehentlich an eine zweite Stelle weiterzugeben.
TEST(MyUniquePointer, MakeUniquePointer)
{
auto my_pointer = std::make_unique();
my_pointer->x = 42;
EXPECT_EQ(my_pointer->x, 42);
}
Es dürfen nie zwei unique_ptr-Instanzen auf dasselbe Objekt zeigen – das würde zu einem Double-Delete führen.
Unique Pointer mit Konstruktor
TEST(MyUniquePointer, MakeUniquePointerWithCTor)
{
auto my_pointer = std::make_unique(42, 8, 15);
EXPECT_EQ(my_pointer->x, 42);
}
std::shared_ptr
Während unique_ptr exklusiven Besitz modelliert, erlaubt shared_ptr geteilten Besitz: Mehrere shared_ptr-Instanzen können gleichzeitig auf dasselbe Objekt zeigen. Erst wenn der letzte shared_ptr auf ein
Objekt seinen Scope verlässt oder zurückgesetzt wird, wird der Speicher freigegeben.
Der Referenzzähler
Das Herzstück des shared_ptr ist ein Referenzzähler (Reference Count). Jedes Mal, wenn ein neuer shared_ptrauf dasselbe Objekt zeigt, wird der Zähler um eins erhöht. Verlässt ein shared_ptr seinen Scope oder wird zurückgesetzt, sinkt der Zähler um eins. Erreicht er null, wird das Objekt zerstört und der Speicher freigegeben.
Mit use_count() lässt sich der aktuelle Zählerstand jederzeit abfragen:
TEST(MySharedPointer, ReferenceCount)
{
auto ptr1 = std::make_shared();
EXPECT_EQ(ptr1.use_count(), 1); // nur ptr1 besitzt das Objekt
{
auto ptr2 = ptr1; // ptr2 teilt den Besitz
EXPECT_EQ(ptr1.use_count(), 2); // Zähler steigt auf 2
} // ptr2 verlässt den Scope → Zähler sinkt
EXPECT_EQ(ptr1.use_count(), 1); // wieder nur ptr1
}
make_shared statt new
Genau wie bei unique_ptr sollte man make_shared statt shared_ptr<T>(new T) bevorzugen. Der Grund ist hier besonders gewichtig: std::shared_ptr<Vec3D>(new Vec3D) führt zu zwei separaten Heap-Allokationen – eine für das Objekt selbst und eine für den internen Kontrollblock, der den Referenzzähler enthält. make_shared fasst beides in einer einzigen Allokation zusammen – das ist schneller und cache-freundlicher.
TEST(MySharedPointer, MakeShared)
{
// bevorzugt: eine Allokation für Objekt + Kontrollblock
const auto my_pointer = std::make_shared();
my_pointer->x = 42;
EXPECT_EQ(my_pointer->x, 42);
}
Das Zyklus-Problem
shared_ptr hat eine Achillesferse: Zeigen zwei Objekte per shared_ptr gegenseitig aufeinander, entsteht ein Zyklus. Der Referenzzähler beider Objekte erreicht dann nie null – der Speicher wird nie freigegeben, obwohl kein Code von außen mehr auf die Objekte zugreift.
struct Node
{
std::shared_ptr next; // Zyklus: A zeigt auf B, B zeigt auf A
};
TEST(MySharedPointer, CyclicReferenceLeak)
{
auto nodeA = std::make_shared();
auto nodeB = std::make_shared();
nodeA->next = nodeB; // A hält B am Leben
nodeB->next = nodeA; // B hält A am Leben → Zyklus!
// Hier verlassen nodeA und nodeB den Scope –
// aber der Referenzzähler beider Objekte ist noch 1,
// weil sie sich gegenseitig halten. Memory Leak!
}
Die Lösung für dieses Problem ist der weak_ptr, der den Referenzzähler nicht erhöht und damit Zyklen durchbricht.
std::weak_ptr
Der Dritte im Bunde ist der weak_ptr. Wie der Name schon sagt, ist es ein schwacher Zeiger – er verändert nicht den Referenzzähler. Deshalb muss der Zeiger vor der Benutzung mit .lock() gesperrt werden, um sicherzustellen, dass das Objekt noch existiert.
TEST(MyWeakPointer, WeakPointerLock)
{
const auto my_pointer = std::make_shared();
const std::weak_ptr gw = my_pointer;
my_pointer->x = 42;
if (const auto obj = gw.lock())
{
EXPECT_EQ(obj->x, 42);
}
}
Mit der expired-Methode kann überprüft werden, ob ein weak Pointer noch gültig ist.
TEST(MyWeakPointer, WeakPointerExpired)
{
auto my_pointer = std::make_shared();
const std::weak_ptr gw = my_pointer;
auto expired = gw.expired();
EXPECT_EQ(expired, false);
my_pointer.reset();
expired = gw.expired();
EXPECT_EQ(expired, true);
}
Fazit
Rohe Zeiger sind in modernem C++ in den allermeisten Fällen nicht mehr nötig. Die klassischen Fehler – Memory Leaks, Double Deletes, Use-after-free und unklare Besitzverhältnisse – lassen sich mit den drei Smart-Pointer-Typen aus <memory> zuverlässig vermeiden:
std::unique_ptrist die erste Wahl, wenn ein Objekt genau einen Besitzer hat. Er ist leichtgewichtig, hat keinen
Laufzeit-Overhead und macht Besitzverhältnisse explizit sichtbar. Im Zweifel: immer zuerstunique_ptrin Betracht ziehen.std::shared_ptrkommt zum Einsatz, wenn sich mehrere Stellen im Code denselben Besitz teilen müssen. Der
eingebaute Referenzzähler sorgt dafür, dass der Speicher erst dann freigegeben wird, wenn der letzte Besitzer
verschwindet. Der Preis dafür ist ein geringer Overhead durch die Referenzzählung.std::weak_ptrergänzt denshared_ptr: Er erlaubt den Zugriff auf ein Objekt, ohne den Referenzzähler zu
erhöhen. Das macht ihn ideal, um Zyklen im Besitzgraphen zu durchbrechen und „beobachtende” Referenzen abzubilden,
die das Objekt nicht am Leben halten sollen.
Die Faustregel für die Praxis lautet: unique_ptr by default, shared_ptr wenn nötig, weak_ptr um Zyklen zu
vermeiden – und rohe Zeiger nur noch dann, wenn es einen guten Grund gibt, der explizit dokumentiert werden sollte.






