Ein Designfehler in C++ (2022-01-04)

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() {
        owned = malloc(69);
    }

    ~Owner() {
        free(owned);
    }
};

int main() {
    {
        Owner a;
        Owner b = a;
        printf("b ownes %p\n", b.owned);
    }
    printf("Hello, World!\n");

    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() {
        owned = malloc(69);
        printf("Owner constructed: %p\n", owned);
    }

    ~Owner() {
        printf("Owner destructed: %p\n", owned);
        free(owned);
    }
};

int main() {
    {
        Owner a;
        Owner b = a;
        printf("b ownes %p\n", b.owned);
    }
    printf("Hello, World!\n");

    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();
    owner->owned = malloc(69);
    printf("Owner constructed: %p\n", owner->owned);
}

void Owner_destructor(Owner* owner) { // Owner::~Owner();
    printf("Owner destructed: %p\n", owner->owned);
    free(owner->owned);
}

int main() {
    {
        Owner a;
        Owner_constructor(&a);

        Owner b = a;
        printf("b ownes %p\n", b.owned);

        Owner_destructor(&a); // a leaves scope
        Owner_destructor(&b); // b leaves scope
    }
    printf("Hello, World!\n");

    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() {
        owned = malloc(69);
        printf("Owner constructed: %p\n", owned);
    }

    void destruct() {
        printf("Owner destructed: %p\n", owned);
        free(owned);
    }
};

int main() {
    {
        Owner a;
        Owner b = a;
        printf("b ownes %p\n", b.owned);

        b.destruct();
    }
    printf("Hello, World!\n");

    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() {
        owned = malloc(69);
        printf("Owner constructed: %p\n", owned);
    }

    void destruct() {
        printf("Owner destructed: %p\n", owned);
        free(owned);
    }
};

template<typename T>
struct AutoDestruct : public T {
    ~AutoDestruct() {
        this->destruct();
    }
};

int main() {
    {
        AutoDestruct<Owner> a;
        Owner b = a;
        printf("b ownes %p\n", b.owned);
    }
    printf("Hello, World!\n");

    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() {
        owned = malloc(69);
        printf("Owner constructed: %p\n", owned);
    }

    void destruct() {
        printf("Owner destructed: %p\n", owned);
        free(owned);
    }
};

Owner makeMessageOwner() {
    Owner o;
    strcpy((char*) o.owned, "Some message");
    return o;
}

template<typename T>
struct AutoDestruct : public T {
    template<typename... Args>
    AutoDestruct(Args&&... args) : T(args...) {}

    ~AutoDestruct() {
        this->destruct();
    }
};

int main() {
    {
        AutoDestruct<Owner> a = (AutoDestruct<Owner>) makeMessageOwner();
        Owner b = a;
        printf("b ownes `%s`\n", b.owned);
    }
    printf("Hello, World!\n");

    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!