std::unique_ptr

Als ich neulich freudig folgendes vor Kollegen verkündete:

Wir können problemlos native Pointer mit unique_ptr ersetzen, da das Layout der Objekte gleich ist.

wurde dagegen argumentiert.
Grund genug, das nochmal aufzurollen.


Memory-Leaks fühlen sich besonders dort wohl, wo RAII nicht angewendet wird. Genau das geschieht auch in Projekten, die ich mitbetreuen darf.

Jetzt herrscht dort leider die Meinung, dass alle erkannten Memory-Leaks nur einmalige Allokierungen sind, weil vieles aus globalen Singletons hervorgeht.

Das stimmt streng genommen auch, und man kann globale Variablen gerne formal ausnehmen … doch Leak-Detektoren tun das nicht von selbst.
Und wenn man dann zahlreiche Unit-Tests ausführt, die alle unzählige Leaks aus solchen Singletons melden, bringt die Erkennung genau gar nichts, weil sie keiner mehr ernst nimmt.

Die Devise sollte also lauten:

Wir beheben diese Leaks so gut wie möglich, damit wir im Bericht am Ende die relevanten Leaks angezeigt bekommen, die wir weiterverfolgen sollen.

Tja und tatsächlich hilft einem da ein Smart-Pointer, der am Prozessende automatisch zerstört wird. std::unique_ptr ist ein perfekter Kandidat solche nativen Pointer zu ersetzen und das Cleanup so dem Compiler zu überlassen.

Spätestens hier meldet sich dann das Deployment Team und fragt, ob eine solche Codeänderung die Binärkompatibilität bricht und ob Patches dann noch möglich sind.

auto_ptr vs unique_ptr

Ein std::auto_ptr besteht genau aus einem Member, nämlich aus dem Pointer zum verwalteten Objekt. Und seine reset() und Destruktorfunktionen führen ein delete auf eben diesen Pointer aus.
Das bedeutet eine int* Variable und ein auto_ptr<int> haben in der Regel das gleiche Speicherlayout.

Diese Behauptung wird zwar nicht durch den Standard gedeckt, aber auf allen unseren üblichen Plattformen ist das durch die Compiler de facto garantiert.

Man kann also durchaus eine .dll oder .so patchen, die original ein Objekt mit einem T* definiert, welches durch einen auto_ptr<T> ersetzt wird. Die Datengröße und das Layout bleiben gleich, nur Codes führen jetzt ein automatisches Cleanup durch.

Bei std::unique_ptr hatte ich anfangs meine Zweifel, ob man hier auch so vorgehen kann. Schließlich bietet unique_ptr an, eine Freigabefunktion dem Objekt beizulegen, die das referenzierte Objekt zerstören können soll. Das ist im Normalfall dann auch wieder ein delete ptr, kann aber durch etwas anderes abgewandelt werden.

Meine Befürchtung war, dass weitere Funktionszeiger in unique_ptr integriert werden und womit das unique_ptr Objekt dann mehr als nur den zu verwaltenden Pointer mitführt.

Compressed Pair

Wie so oft hat Raymond Chen bereits die Lösung publiziert und beschreibt die MSVC Lösung, die auch in anderen STL-Implementierungen angewendet wird:

Der Deleter-Funktor (dessen Default Typ einfach std::default_delete heißt) wird nicht als Member dem unique_ptr angehängt, sonder unique_ptr erbt von diesem Typ. Und so lange dieser Typ selbst keine Datenmember besitzt (was bei std::default_delete der Fall ist), wächst unique_ptr nicht, sondern definiert seine Größe nur nach dem bekannten Pointer zum Objekt.

Beim MSVC gibt es dafür den Typ compressed_pair<K, V> welcher von K erbt und nur V als echten Value-member anhängt.

Konstruktion von Objekten

Die Größen von Klassen und ihren Unterobjekten sind essentiell für das Erzeugen und den Zugriff auf instanziierte Objekte. Wenn also ein Typ in einem Modul (DLL/SO) eingesetzt wird und ich dessen Code neu kompilieren und ausrollen möchte, darf sich das Objektlayout um kein einziges Bit ändern, denn sonst stimmt es mit früheren Kompilaten nicht mehr überein. Code hingegen wird über Namenstabellen beim dynamischen Einbinden neu aufgelöst.

Um also eine Plugin-SO/DLL gefahrlos patchen zu können, muss sichergestellt sein, dass alle Datenstrukturen exakt gleich groß sind und mit identischen Offsets herauskommen.

Und eben genau das ist bei std::unique_ptr dann der Fall, wenn man die Default-Implementierung nutzt.

Beispiel

 1class my_worker_impl;
 2
 3class my_worker
 4{
 5public:
 6  my_worker()
 7  : ptr(new my_worker_impl())
 8  {
 9  }
10
11private:
12  //my_worker_impl* ptr;
13  // PATCH: replace raw ptr with unique_ptr
14  std::unique_ptr<my_worker_impl> ptr;
15};
16
17my_worker singleton_instance;

Durch die Änderung bleiben die Daten exakt an der gleichen Stelle, doch unique_ptr generiert automatisch einen Destruktor-Aufruf, der am Ende des Prozesses das allokierte Objekt wieder freigibt.

Und wenn my_worker jetzt ein Member in einem anderen Objekt sein sollte, das in einem Modul liegt, welches mit dem alten Header kompiliert wurde, so ist ein binärkompatibler Patch ebenso möglich.

Fazit

Man muss natürlich klar darauf hinweisen, dass Binärkompatibilität nicht durch Standards gedeckt wird. Eigentlich handelt es sich hierbei um “undefinied behavior”. Das bedeutet aber nicht, dass auf bestimmten Plattformen das Verhalten dann trotzdem vom Hersteller garantiert ist.

Der Wechsel von raw-Pointern zu unique_ptr wäre also so ein Fall, der unter Windows und Linux durchführbar ist und die Codequalität erhöht, ohne dass man manuell neue Destruktoren oder Ähnliches schreiben müsste.

📧 📋 🐘 | 🔔
 

Meine Dokus über:
 
Weitere externe Links zu:
Alle extern verlinkten Webseiten stehen nicht in Zusammenhang mit opengate.at.
Für deren Inhalt wird keine Haftung übernommen.



Wenn sich eine triviale Erkenntnis mit Dummheit in der Interpretation paart, dann gibt es in der Regel Kollateralschäden in der Anwendung.
frei zitiert nach A. Van der Bellen
... also dann paaren wir mal eine komplexe Erkenntnis mit Klugheit in der Interpretation!