Entwicklung & Code

Von undefiniert zu definiert: Die Verwendung von std::launder in C++


Im heutigen Beitrag knüpfe ich an die übergreifenden Themen der letzten beiden Monate an. Heute geht es darum, wann und wo du std::launder aus C++17 einsetzen musst und worin der Unterschied zu reinterpret_cast oder std::start_lifetime_as besteht.

Weiterlesen nach der Anzeige




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 ( hat er ein international anerkanntes Tool entwickelt, das C++-Programmierenden hilft, C++ noch besser zu verstehen.

Die Bereiche, in denen man das heute Gelernte anwenden kann, sind vielfältig. Im Embedded-Bereich wird std::launder in der Regel verwendet, aber auch beim Schreiben von Bibliothekscode kommt „Laundering“ (zu Deutsch waschen und bügeln, gerne im Zusammenhang mit „Money“ als Geldwäsche) vor.

Ich verwende das Beispiel aus dem Paper P0532R0:


struct X {
  const int n;  // #A
  double    d;
};

X* p = new X{7, 8.8};  // #B

new(p) X{42, 9.9};  // #C

int  i = p->n;  // #D
auto d = p->d;  // #E


Hier stehen mehrere Teile, die zusammenpassen müssen. Beachte, dass das struct X das Datenfeld n als const deklariert.

Als Nächstes wird mithilfe von new in #B ein Objekt erstellt und der resultierende Zeiger in p gespeichert. So weit, so gut.

Weiterlesen nach der Anzeige

Der interessante Teil beginnt als Nächstes in #C mit dem Platzierungs-new. Wer das noch nie gemacht hat, muss den eigenen C++-Code vielleicht nicht waschen und glatt bügeln.

Das Platzierungs-new selbst ist auch in Ordnung. Das Problem tritt später in #D und #E auf. Hier wird auf die Werte des Zeigers p zugegriffen. Aber der Compiler darf davon ausgehen, dass nach #B der Inhalt von p unverändert ist, da p selbst nie dazu verwendet wurde, den Inhalt des Objekts zu ändern. Noch schlimmer: Eines der Datenelemente von X ist const. Das ist eine Freikarte für den Optimierer, zu Recht anzunehmen, dass sich der Wert von n nach der Konstruktion nie ändert.

Aber genau das habe ich in #C getan. Ich ändere einen const-Wert! Die Werte von i und d sind unbekannt. Das ist undefiniertes Verhalten.

Dieses lässt sich leicht vermeiden, sogar ohne std::launder, indem man den Zeiger aktualisiert und dem Compiler mitteilt, dass sich die Werte hinter p geändert haben:


X* p = new X{7, 8.8};  // #B

p = new(p) X{42, 9.9};  // #C

int  i = p->n;  // #D
auto d = p->d;  // #E


Die Frage ist also: Warum macht man das nicht einfach und vergisst std::launder? Nun, wann immer möglich, vergiss std::launder.

Leider gibt es Fälle, in denen das Aktualisieren des Zeigers wertvolle Ressourcen opfern würde.

Angenommen, du implementierst einen benutzerdefinierten Allokator:


template
class Buffer {
  alignas(ALIGNMENT) std::byte mBuffer[SIZE];

public:
  template
  T* Construct(Ts... vals)
  {
    new(mBuffer) T{std::forward(vals)...};
    return reinterpret_cast(mBuffer);
  }

  template
  [[nodiscard]] T* Get()
  {
    return reinterpret_cast(mBuffer);
  }
};


Du siehst hier zwei Funktionen, die der Allokator bereitstellt: Construct und Get. Das Konzept von Buffer besteht darin, dass die gespeicherten Daten typunabhängig sind. Eine mögliche vereinfachte Verwendung könnte folgendermaßen aussehen:


struct Point {  // #A
  int x;
  int y;
};

struct Point3D {
  int x;
  int y;
  int z;
};

std::array, 2> storage{};  // #B

// #C
storage.at(0).Construct(2, 3);
storage.at(1).Construct(4, 5, 6);

// #D
storage.at(0).Get()->x = 7;


Die zwei Datentypen Point und Point3D in #A stehen als Beispiel für beliebige Typen. storage steht für den Stack-Speicher, der beliebige Daten speichern kann. Solange die Nutzer wissen, welcher Datentyp hinter einem Index steckt (und dieser Typ nicht zu groß ist), kann alles gespeichert werden.

Am ersten Element des Arrays #C konstruiere ich ein Point-Objekt, während ich am zweiten Element ein Point3D erstelle. Erst später im Programm #D werden die Objekte tatsächlich verwendet. Dennoch hat niemand aus gutem Grund einen Zeiger auf das frisch konstruierte Objekt gespeichert. Ein solcher Zeiger würde Speicherkapazität erfordern, und du weißt bereits, wohin dieser Zeiger zeigt.

Aus der Perspektive der abstrakten Maschine habe ich Löcher in das Typsystem gestochen. Der Compiler kann zu Recht davon ausgehen, dass sich die Daten hinter Get nicht ändern, es sei denn, er sieht etwa einen direkten Schreibzugriff #D. Ein solcher Zugriff bleibt vom Compiler unbemerkt (oder könnte es bleiben, da es sich um undefiniertes Verhalten handelt) im folgenden Code in #E, wenn ich an einer bestehenden Stelle ein neues Point-Objekt erstelle:


std::array, 2> storage{};  // #B

// #C
storage.at(0).Construct(2, 3);

// #D
storage.at(0).Get()->x = 7;

// #E
storage.at(0).Construct(8, 9);


Offensichtlich lässt sich das Problem hier am einfachsten vermeiden, indem man den von jedem Construct-Aufruf zurückgegebenen Zeiger speichert. Wenn das, wie hier, nicht machbar ist, besteht die richtige Vorgehensweise darin, den Zeiger mit std::launder zu „waschen“. Dieses spezielle Hilfsmittel fungiert als Devirtualisierungsbarriere, die Compiler-Optimierungen und -Annahmen verhindert.

So lässt sich die Buffer-Implementierung auf sichere Weise aktualisieren. Zunächst zu Construct: Meine ursprüngliche Implementierung sah so aus:


template
T* Construct(Ts... vals)
{
  new(mBuffer) T{std::forward(vals)...};
  return reinterpret_cast(mBuffer);
}


Du kannst diesen Code auch ohne std::launder sicher machen, indem du den von new zurückgegebenen Zeiger zurückgibst:


template
T* Construct(Ts... vals)
{
  return new(mBuffer) T{std::forward(vals)...};
}


Die zweite Funktion, die du korrigieren musst, ist Get, dieses Mal durch Hinzufügen von std::launder:


template
[[nodiscard]] T* Get()
{
  return std::launder(reinterpret_cast(mBuffer));
}


Der Code geht nun davon aus, dass du Construct aufrufst, bevor du Get aufrufst. Dieser mBuffer enthält ein gültiges Objekt, das mit dem übereinstimmt, das mit Construct erstellt wurde.

Mit std::launder kannst du einen Zeiger aktualisieren, der auf ein bereits vorhandenes Objekt an dieser Speicheradresse verweist. Die Lebensdauer des Objekts an dieser Stelle hat also bereits begonnen.


(rme)



Source link

Beliebt

Die mobile Version verlassen