Type Punning in C++: Der saubere Ansatz mit C++20

Verbotene Type-Punning-Taktiken in C++: Warum sie undefiniertes Verhalten erzeugen, und wie C++20 das Problem elegant löst.

vorlesen Druckansicht 2 Kommentare lesen
(C) Franziska Panter

(Bild: Franziska Panter)

Lesezeit: 4 Min.
Von
  • Andreas Fertig
Die C++ Werkbank – Andreas Fertig
Portrait von Andreas Fertig

Andreas Fertig ist erfahrener C++-Trainer und Berater, der weltweit Präsenz- sowie Remote-Kurse anbietet. Er engagiert sich im C++-Standardisierungskomitee und spricht regelmäßig auf internationalen Konferenzen. Mit C++ Insights (https://cppinsights.io) hat er ein international anerkanntes Tool entwickelt, das C++-Programmierenden hilft, C++ noch besser zu verstehen.

Der heutige Beitrag widmet sich der Typumwandlung via Type Punning in C++. Das habe ich jahrelang gemacht, als ich im Bereich Embedded-Software gearbeitet habe, und andere haben das schon lange vor mir gemacht. Laut Standard ist das zu 100 Prozent verboten. Trotzdem weiß ich, dass viele Embedded-Geräte mit Type Punning gebaut werden, obwohl es nicht nur verboten ist, sondern auch zu unbestimmtem Verhalten (Undefined Behavior, UB) führt.

Anhand des folgenden Codes möchte ich klären, was ich unter Type Punning verstehe und warum es sich dabei um undefiniertes Verhalten in C++ handelt.

float pi = 3.14f;  // #A

// #B
uint32_t first = static_cast<uint32_t>(pi);

// #C
uint32_t second = *reinterpret_cast<uint32_t*>(&pi);

Der Code soll die Bitdarstellung des float in #A in eine Ganzzahl umwandeln. Es geht nicht darum, den Wert 3,14 in eine Ganzzahl umzuwandeln, die 3 wäre. Das Ziel ist es, die Bitdarstellung (0x4048f5c3) zu erhalten.

Ich stelle mir zwei Versuche vor, die ich oft gesehen habe. Der erste in #B verwendet ein static_cast. Dieser Versuch lässt sich zwar kompilieren und ist zu 100 Prozent gültiges C++, aber das Ergebnis ist nicht das, was du suchst, da du die konvertierten Werte erhältst und am Ende die Zahl 3 in first hast.

Der zweite Versuch muss natürlich cleverer sein, und ich würde sagen, dass #C tatsächlich clever aussieht. Dieser Code holt sich zuerst die Adresse des float und nutzt dann reinterpret_cast, um den float-Zeiger in einen int-Zeiger umzuwandeln, und schließlich den frisch erhaltenen Zeiger auf einen int zu dereferenzieren. Du beugst einfach die Regeln von C++ (und übrigens auch von C) so weit, dass der Compiler die Umwandlung zulässt. Erfolg!

Nun ja – dass der Code kompiliert wird, ist nur der erste Schritt! Als Nächstes muss er verknüpft (linked) werden, was ebenfalls erfolgreich ist. Dann muss der Code das tun, was du geplant hast. Hier wird es knifflig. Du hast gerade Code geschrieben, der undefiniertes Verhalten enthält.

Indem du bei der Konvertierungssequenz zu clever warst, hast du dem Compiler die Möglichkeit gegeben, die Zuweisung zu second zu optimieren. Das hängt im Wesentlichen mit der Lebensdauer von Objekten und den entsprechenden Regeln zusammen. Grob gesagt wurde der int, den du in #C zuweist, aus Sicht des Compilers nie aktiv. Es gibt keinen Konstruktor und keine zulässige Konvertierungssequenz, die den Compiler auf den Beginn der Lebensdauer aufmerksam machen würde. Eine perfekte Gelegenheit für den Compiler, uns einige Anweisungen zu ersparen, indem er diese gesamte Zuweisung optimiert.

Videos by heise

Ich habe Kunden, die aus diesem Grund die Optimierungsstufe nicht höher als -O1 einstellen.

Als ich über den Teil „zur Rettung“ nachdachte, kam mir die Fernsehserie Baywatch in den Sinn, in der die Rettungsschwimmer mit roten Rettungsbojen (Safety Buoy) ins Wasser rannten.

Okay, zurĂĽck vom Strand ins BĂĽro. C++20 hat eine Rettungsboje namens std::bit_cast fĂĽr den oben beschriebenen Fall. Anstatt eine Menge Code zu schreiben, um den Compiler zur gewĂĽnschten Konvertierungssequenz zu verleiten, wende std::bit_cast auf die gleiche Weise an wie first static_cast. Dein Code sieht dann folgendermaĂźen aus:

const float    pi  = 3.14f;
const uint32_t pii = std::bit_cast<uint32_t>(pi);

Wie du siehst, kommt std::bit_cast wirklich wie ein static_cast daher. Du gibst den Zieldatentyp in den spitzen Klammern und die Quellvariable oder den Quellwert als Argument an. Als Ergebnis erhältst du ein Objekt vom Zieldatentyp.

Intern nutzt std::bit_cast memcpy, um die Quellbits in den Zielpuffer zu kopieren, bevor dieser zurĂĽckgegeben wird. Das funktioniert, weil memcpy seit C++20 vom Standard als Element anerkannt ist, das die Lebensdauer eines Objekts startet.

Wenn du mit einer Situation wie der am Anfang des Beitrags konfrontiert bist, solltest du sicherheitshalber immer std::bit_cast bevorzugen, wenn du kannst.

(rme)