Programmiersprache C++: Was reinterpret_cast nicht tut
Der Blog beschäftigt sich mit einem der größten Fallstricke von C++: reinterpret_cast kann zweckentfremdet undefiniertes Verhalten auslösen.
(Bild: Fransizka Panter)
- Andreas Fertig
Im heutigen Beitrag werde ich eine der größten Fallstricke von C++ erläutern: reinterpret_cast. Ein anderer Titel für diesen Beitrag könnte lauten: „Das ist nicht der Cast, den du suchst!“
Meine Motivation für diesen Blogbeitrag stammt aus mehreren Schulungen und einigen Vorträgen, die ich gehalten habe. Seit C++23 gibt es in der Standardbibliothek eine neue Funktion: std::start_lifetime_as. Wenn ich Kurse mit Schwerpunkt auf eingebetteten Umgebungen unterrichte oder Vorträge mit diesem Schwerpunkt halte, habe ich begonnen, std::start_lifetime_as in das Material aufzunehmen. Mit einem interessanten Ergebnis.
Das Feedback, das ich bekomme, lautet in etwa:
- Warum benötige ich
std::start_lifetime_as, ich habe doch schonreinterpret_cast? - Warum kann ich
reinterpret_castnicht verwenden?
Wer noch nie von start_lifetime_as gehört hat, findet weitere Informationen in meinem englischen Artikel „The correct way to do type punning in C++ - The second act“. Ich verwende folgendes Beispiel daraus:
struct ConfigValues {
uint32_t chksum;
std::array<uint32_t, 128> values;
};
bool ProcessData(std::span<unsigned char> bytes)
{
if(bytes.size() < sizeof(ConfigValues)) { return false; }
// #A
ConfigValues* cfgValues = reinterpret_cast<ConfigValues*>(bytes.data());
return HandleConfigValues(cfgValues);
}
Die Idee hier ist, eine Reihe von rohen Bytes in eine bekannte Struktur umzuwandeln – hier mit dem Namen ConfigValues. Zusammen mit start_lifetime_as gerate ich immer öfter in Gespräche, in denen mir Leute sagen, dass der Name reinterpret impliziert, dass solcher Code wie erwartet funktionieren sollte. Die Erwartung ist, dass solcher Code frei von undefiniertem Verhalten ist und tatsächlich einen Zeiger auf ein ConfigValues-Objekt zurückgibt.
Videos by heise
Zwar kann ich einer solchen Erwartung aufgrund des Wortlauts des Standards und des C++-Objektmodells nicht widersprechen, doch eine solche Erwartung fĂĽhrt zu undefiniertem Verhalten. In einer typsicheren Sprache kann ein Objekt nicht in ein anderes, nicht verwandtes Objekt konvertiert werden.
Der wichtigste Wortlaut ist [expr.reinterpret.cast § 7], der besagt:
An object pointer can be explicitly converted to an object pointer of a different type. When a prvalue v of object pointer type is converted to the object pointer type “pointer to cv T”, the result is static_cast<cv T*>(static_cast<cv void*>(v)). [Note 5: Converting a pointer of type “pointer to T1” that points to an object of type T1 to the type “pointer to T2” (where T2 is an object type and the alignment requirements of T2 are no stricter than those of T1) and back to its original type yields the original pointer value. — end note]
Zunächst einmal handelt dieser ganze Absatz von Zeigern auf Objekte und nicht von Objekten selbst. Es heißt, dass du ein ConfigValues in einen void* oder einen beliebigen anderen Datentyp konvertieren kannst, der ein Objekttyp ist. Ein Objekttyp ist alles außer einem Funktionstyp, einem Referenztyp und void.
Weiter unten in der Anmerkung bestätigt der Standard ausdrücklich, dass du einen Rundlauf durchführen kannst. Zum Beispiel:
ConfigValues cfg{};
ConfigValues* val{&cfg};
void* typeErased = reinterpret_cast<void*>(val);
ConfigValues* roundTripBackToVal = reinterpret_cast<ConfigValues*>(typeErased);
Dies ermöglicht Konstrukte, die sogenannte Typlöschung (Type Erasure) nutzen, wie std::any. Du kannst einen Alias eines anderen Zeigertyps erhalten.
Konvertierung nur fĂĽr den Zeiger
In diesem Absatz ist von einer Konvertierung des Objekts selbst keine Rede. Nur den Zeiger kann man konvertieren.
Im Sinne des C++-Objektmodells muss eine Anwendung ein gültiges Objekt erstellen (und später zerstören). Aber alles, was jemals erstellt wurde, ist ein ConfigValues-Objekt. reinterpret_cast ist ein Werkzeug, das es ermöglicht, einen Zeiger eines anderen Typs zu speichern. Sobald du ihn verwenden möchtest, musst du den Zeiger wieder in seinen ursprünglichen Typ zurückkonvertieren.
Nehmen wir einmal an, reinterpret_cast wĂĽrde so funktionieren, wie manche Leute es erwarten:
struct Apple {
int x;
};
struct Orange {
int y;
};
Apple* grannySmith{new Apple{4}};
Orange* bali{reinterpret_cast<Orange*>(grannySmith)}; // #A
int y = bali->y;
int x = grannySmith->x;
Nach den Regeln von C++ muss ein Objekt erstellt und zerstört werden. Wenn #A ein Objekt erstellen würde, müsste es das grannySmith-Objekt zerstören. Was überraschend wäre. Dann hättest du keine Möglichkeit, eine Typlöschung wie std::any zu implementieren, da das Speichern eines gelöschten Typs void* das ursprüngliche Objekt aus Sicht der abstrakten Maschinerie von C++ zerstören würde. Das würde dem Compiler ermöglichen, verschiedene andere Optimierungen vorzunehmen, die das Programm zum Absturz bringen würden.
Mit reinterpret_cast hast du eine Möglichkeit, ein Objekt A als einen anderen Typ B zu aliasieren, jedoch ohne das Recht, jemals über diesen Zeiger auf ein B-Objekt zuzugreifen. Andererseits erstellt start_lifetime_as implizit ein B-Objekt am Zielort des Zeigers A, während gleichzeitig die Lebensdauer von A beendet wird.
Ein Objekt ins Leben rufen
Meist will man in diesen Situationen ein Objekt eines anderen Typs zum Leben erwecken. Und genau dafĂĽr ist std::start_lifetime_as gedacht.
Wenn du std::start_lifetime_as auf einen Zeiger anwendest, versteht die abstrakte Maschine, dass du ein neues Objekt dieses Typs erstellst. Im Gegensatz zu einem Aufruf von new oder einem Stack-Objekt wird kein Konstruktor ausgefĂĽhrt. Alles geschieht nur innerhalb des C++-Objektmodells.
Es gibt noch eine weitere Funktion von std::start_lifetime_as: Wenn es die Lebensdauer eines neuen Objekts startet, wird die Lebensdauer der Quelle automatisch beendet, wiederum ohne einen tatsächlichen Destruktor aufzurufen. Das ist hier entscheidend.
Wenn ich std::start_lifetime_as auf mein vorheriges Beispiel anwende, sieht die korrekte Implementierung wie folgt aus:
struct Apple {
int x;
};
struct Orange {
int y;
};
Apple* grannySmith{new Apple{4}};
Orange* bali{std::start_lifetime_as<Orange>(grannySmith)}; // #A
int y = bali->y;
grannySmith = std::start_lifetime_as<Apple>(bali); // #B
int x = grannySmith->x;
Der Code in #B startet die Lebensdauer des Zeigers erneut als Apple-Objekt.
Wichtigste Erkenntnisse
Mit reinterpret_cast erhältst du nur eine Zeigerkonvertierung. Du darfst diesen neuen Zeiger nicht verwenden, um auf ein Objekt des Typs zuzugreifen.
Wenn du die Lebensdauer eines Objekts starten möchtest, um auf Daten als neuen Typ zuzugreifen, benötigst du std::start_lifetime_as.
(rme)