std::unique_ptr
« | 26 Aug 2023 | »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
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.