Quelltext und eine englische Version gibt es auf GitHub.
Fangen wir mit einem einfachen C++ Programm an:
#include <stdio.h>
#include <stdlib.h>
struct Owner {
void* owned;
() {
Owner= malloc(69);
owned }
~Owner() {
(owned);
free}
};
int main() {
{
;
Owner a= a;
Owner b ("b ownes %p\n", b.owned);
printf}
("Hello, World!\n");
printf
return 0;
}
Es wird lediglich etwas Speicher von einem Struct verwaltet, sagen
wir, die Instanzen besitzen den Speicher (deswegen Owner
).
Dann benennen wir die Instanz um und geben die Adresse aus. Führen wir
es mal aus…
$ g++ owner.cpp && ./a.out
b ownes 0x564d1b514eb0
free(): double free detected in tcache 2
zsh: abort (core dumped) ./a.out
Was ist das? Double Free? Wir haben doch nur eine Instanz?
Versuchen wir mal uns mit ein paar Prints zu helfen:
#include <stdio.h>
#include <stdlib.h>
struct Owner {
void* owned;
() {
Owner= malloc(69);
owned ("Owner constructed: %p\n", owned);
printf}
~Owner() {
("Owner destructed: %p\n", owned);
printf(owned);
free}
};
int main() {
{
;
Owner a= a;
Owner b ("b ownes %p\n", b.owned);
printf}
("Hello, World!\n");
printf
return 0;
}
Wir erhalten:
$ g++ owner-print.cpp && ./a.out
Owner constructed: 0x561d5cde4eb0
b ownes 0x561d5cde4eb0
Owner destructed: 0x561d5cde4eb0
Owner destructed: 0x561d5cde4eb0
free(): double free detected in tcache 2
zsh: abort (core dumped) ./a.out
Aha, tatsächlich wird der Destructor zwei mal aufgerufen. Um zu verdeutlichen, was C++ aus dem Programm macht, übersetzte ich das mal in C:
#include <stdio.h>
#include <stdlib.h>
typedef struct {
void* owned;
} Owner;
void Owner_constructor(Owner* owner) { // Owner::Owner();
->owned = malloc(69);
owner("Owner constructed: %p\n", owner->owned);
printf}
void Owner_destructor(Owner* owner) { // Owner::~Owner();
("Owner destructed: %p\n", owner->owned);
printf(owner->owned);
free}
int main() {
{
;
Owner a(&a);
Owner_constructor
= a;
Owner b ("b ownes %p\n", b.owned);
printf
(&a); // a leaves scope
Owner_destructor(&b); // b leaves scope
Owner_destructor}
("Hello, World!\n");
printf
return 0;
}
Gibt uns Gleichermaßen:
$ gcc owner-print.c && ./a.out
Owner constructed: 0x55ecdf2152a0
b ownes 0x55ecdf2152a0
Owner destructed: 0x55ecdf2152a0
Owner destructed: 0x55ecdf2152a0
free(): double free detected in tcache 2
zsh: abort (core dumped) ./a.out
Nun sehen wir aber das Problem: Auch wenn man als Programmierer von nur einer Instanz ausgeht, macht der C++ trotzdem zwei daraus, die dann beide zerstört werden. Ganz genau wäre zu sagen, es gibt zwei Instanzen, die aber den selben Speicher besitzt.
Ist da schon der Designfehler, dass C++ daraus zwei Instanzen macht? Nicht wirklich, denn das mit dem Kopieren in C++ ist nicht so offensichtlich, sodass es dann doch ganz gut ist, das der Compiler uns da Abhilfe schaffen soll. Mehr dazu auf SO.
Um das klar zu stellen: Wenn ich vom Kopieren spreche, dann meine ich das anfordern von zusätzlichen Speicher auf dem Heap und kopieren von Bytes dort. Das Kopieren von Adressen auf dem Stack oder in Registern ist kein Problem.
Für mich, wurzelt der Designfehler dort, wo C++ einen zwingt, sich einem kompliziertem Speicherbesitzritual zu unterwerfen, indem es alles aufräumt, was aus dem Scope geht.
Plötzlich muss man mehre Constructor implementieren, nur um dem Compiler zu sagen, wann er speicher kopieren muss. Aber kopieren ist teuer also soll man noch mehr Constructoren implementieren, um dem Compiler anzudeuten, wo das Kopieren vielleicht vermieden werden könnte. Diese Constructoren werden in dem oben genannten SO-Link erklärt.
Lasst mich einen einfacheren Ansatz vorstellen: Wir kopieren nur,
wenn es der Nutzer explizit anfordert. Das ist das Standardverhalten in
so vielen Programmiersprachen, Java hat einen eventuell vermasselten
aber manuellen Mechanismus um Objekte zu klonen, Python hat das
copy
Module, das genau dafür gedacht ist und JavaScript hat
Object.assign
für manuelles Kopieren.
Aber wie wir in unserem ersten Codebeispiel gesehen haben, funktioniert so ein Ansatz auch nicht. Das automatische Aufräumen von Instanzen macht uns das leben schwer. Wir müssen das auch los werden: Lasst mich einfach den Destructor umbenennen:
#include <stdio.h>
#include <stdlib.h>
struct Owner {
void* owned;
() {
Owner= malloc(69);
owned ("Owner constructed: %p\n", owned);
printf}
void destruct() {
("Owner destructed: %p\n", owned);
printf(owned);
free}
};
int main() {
{
;
Owner a= a;
Owner b ("b ownes %p\n", b.owned);
printf
.destruct();
b}
("Hello, World!\n");
printf
return 0;
}
Uns es geht!
$ g++ owner-print-manuall.cpp && ./a.out
Owner constructed: 0x562180a57eb0
b ownes 0x562180a57eb0
Owner destructed: 0x562180a57eb0
Hello, World!
Das wäre dann der Ansatz eines C-Programmierers. Der ist aber auch
keine Lösung, da wir alle Wissen, dass es auch recht unübersichtlich
werden kann mit dem Ressourcenmanagement (Such
mal nach glfwTerminate()
).
Meine Lösung wäre damit automatischer Destructor Aufruf als Option.
Dafür das magische Struct AutoDestruct
dienen.
AutoDestruct<T>
ist T
mit
C++-Destructor.
#include <stdio.h>
#include <stdlib.h>
struct Owner {
void* owned;
() {
Owner= malloc(69);
owned ("Owner constructed: %p\n", owned);
printf}
void destruct() {
("Owner destructed: %p\n", owned);
printf(owned);
free}
};
template<typename T>
struct AutoDestruct : public T {
~AutoDestruct() {
this->destruct();
}
};
int main() {
{
<Owner> a;
AutoDestruct= a;
Owner b ("b ownes %p\n", b.owned);
printf}
("Hello, World!\n");
printf
return 0;
}
Eleganter wäre es als Schlüsselwort, aber das ist das beste, was man ohne Compiler ändern machen kann. Immerhin, das funktioniert:
Owner constructed: 0x55ef4c242eb0
b ownes 0x55ef4c242eb0
Owner destructed: 0x55ef4c242eb0
Hello, World!
Als einen Bonus kriegen wir ein effizientes “Rückgabe einer Instanz von Prozeduren” um sonst:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
struct Owner {
void* owned;
() {
Owner= malloc(69);
owned ("Owner constructed: %p\n", owned);
printf}
void destruct() {
("Owner destructed: %p\n", owned);
printf(owned);
free}
};
() {
Owner makeMessageOwner;
Owner o((char*) o.owned, "Some message");
strcpyreturn o;
}
template<typename T>
struct AutoDestruct : public T {
template<typename... Args>
(Args&&... args) : T(args...) {}
AutoDestruct
~AutoDestruct() {
this->destruct();
}
};
int main() {
{
<Owner> a = (AutoDestruct<Owner>) makeMessageOwner();
AutoDestruct= a;
Owner b ("b ownes `%s`\n", b.owned);
printf}
("Hello, World!\n");
printf
return 0;
}
Und ohne zu kopieren:
$ g++ owner-print-returned.cpp && ./a.out
Owner constructed: 0x55ba40246eb0
b ownes `Some message`
Owner destructed: 0x55ba40246eb0
Hello, World!