Objektlebensdauer-Invarianten vs. Bewegungssemantik


13

Als ich vor langer Zeit C ++ lernte, wurde mir stark betont, dass ein Teil des Punktes von C ++ darin besteht, dass genau wie Schleifen "Schleifeninvarianten" haben, Klassen auch Invarianten, die mit der Lebensdauer des Objekts verbunden sind - Dinge, die wahr sein sollten solange das Objekt lebt. Dinge, die von den Konstruktoren festgelegt und von den Methoden erhalten werden sollten. Die Kapselung / Zugriffskontrolle soll Ihnen dabei helfen, die Invarianten durchzusetzen. RAII ist eine Sache, die Sie mit dieser Idee machen können.

Seit C ++ 11 haben wir jetzt die Bewegungssemantik. Für eine Klasse, die das Bewegen unterstützt, endet das Bewegen von einem Objekt formell nicht seine Lebenszeit - die Bewegung soll es in einem "gültigen" Zustand belassen.

Ist es beim Entwerfen einer Klasse eine schlechte Praxis, wenn Sie sie so entwerfen, dass die Invarianten der Klasse nur bis zu dem Punkt erhalten bleiben, von dem aus sie verschoben werden? Oder ist das in Ordnung, wenn Sie es schneller machen können?

Nehmen wir an, ich habe einen nicht kopierbaren, aber verschiebbaren Ressourcentyp:

class opaque {
  opaque(const opaque &) = delete;

public:
  opaque(opaque &&);

  ...

  void mysterious();
  void mysterious(int);
  void mysterious(std::vector<std::string>);
};

Und aus irgendeinem Grund muss ich einen kopierbaren Wrapper für dieses Objekt erstellen, damit es möglicherweise in einem vorhandenen Versandsystem verwendet werden kann.

class copyable_opaque {
  std::shared_ptr<opaque> o_;

  copyable_opaque() = delete;
public:
  explicit copyable_opaque(opaque _o)
    : o_(std::make_shared<opaque>(std::move(_o)))
  {}

  void operator()() { o_->mysterious(); }
  void operator()(int i) { o_->mysterious(i); }
  void operator()(std::vector<std::string> v) { o_->mysterious(v); }
};

In diesem copyable_opaqueObjekt besteht eine Invariante der bei der Konstruktion festgelegten Klasse darin, dass das Element o_immer auf ein gültiges Objekt verweist, da es keinen Standard-ctor gibt und der einzige ctor, der kein Kopie-ctor ist, dies garantiert. Alle operator()Methoden setzen voraus, dass diese Invariante gilt, und bewahren sie anschließend auf.

Wenn das Objekt jedoch verschoben o_wird, zeigt es auf nichts. Und nach diesem Zeitpunkt führt der Aufruf einer der Methoden zu operator()einem Absturz von UB /.

Wird das Objekt nie verschoben, bleibt die Invariante bis zum Aufruf von dtor erhalten.

Nehmen wir an, dass ich diese Klasse hypothetisch geschrieben habe und mein imaginärer Kollege Monate später UB erlebte, weil er in einer komplizierten Funktion, in der viele dieser Objekte aus irgendeinem Grund durcheinandergebracht wurden, von einem dieser Dinge wegging und später eines von beiden nannte seine Methoden. Klar ist es am Ende des Tages seine Schuld, aber ist diese Klasse "schlecht designt"?

Gedanken:

  1. In C ++ ist es normalerweise eine schlechte Form, Zombie-Objekte zu erstellen, die explodieren, wenn Sie sie berühren.
    Wenn Sie ein Objekt nicht konstruieren können, die Invarianten nicht ermitteln können, lösen Sie eine Ausnahme vom ctor aus. Wenn Sie die Invarianten in einer Methode nicht beibehalten können, melden Sie einen Fehler und führen Sie ein Rollback durch. Sollte dies für bewegte Objekte anders sein?

  2. Reicht es aus, im Header nur zu dokumentieren, dass es nach dem Verschieben dieses Objekts unzulässig ist, etwas anderes zu tun, als es zu zerstören?

  3. Ist es besser, ständig zu behaupten, dass es in jedem Methodenaufruf gültig ist?

Wie so:

class copyable_opaque {
  std::shared_ptr<opaque> o_;

  copyable_opaque() = delete;
public:
  explicit copyable_opaque(opaque _o)
    : o_(std::make_shared<opaque>(std::move(_o)))
  {}

  void operator()() { assert(o_); o_->mysterious(); }
  void operator()(int i) { assert(o_); o_->mysterious(i); }
  void operator()(std::vector<std::string> v) { assert(o_); o_->mysterious(v); }
};

Die Behauptungen verbessern das Verhalten nicht wesentlich und verursachen eine Verlangsamung. Wenn Ihr Projekt das "Release Build / Debug Build" -Schema verwendet und nicht nur immer mit Assertions ausgeführt wird, ist dies meiner Meinung nach attraktiver, da Sie nicht für die Schecks im Release Build bezahlen. Wenn Sie keine Debug-Builds haben, scheint dies jedoch ziemlich unattraktiv zu sein.

  1. Ist es besser, die Klasse kopierbar, aber nicht beweglich zu machen?
    Dies scheint ebenfalls schlecht zu sein und führt zu Leistungseinbußen, löst jedoch das "invariante" Problem auf einfache Weise.

Was halten Sie hier für die relevanten "Best Practices"?



Antworten:


20

In C ++ ist es normalerweise eine schlechte Form, Zombie-Objekte zu erstellen, die explodieren, wenn Sie sie berühren.

Aber das machst du nicht. Sie erstellen ein "Zombie-Objekt", das explodiert, wenn Sie es falsch berühren . Was letztendlich nicht anders ist als jede andere staatliche Voraussetzung.

Betrachten Sie die folgende Funktion:

void func(std::vector<int> &v)
{
  v[0] = 5;
}

Ist diese Funktion sicher? Nein; Der Benutzer kann ein Leerzeichen übergeben vector . Die Funktion hat also eine De-facto-Voraussetzung, vdie mindestens ein Element enthält. Wenn dies nicht der Fall ist, erhalten Sie UB, wenn Sie anrufen func.

Diese Funktion ist also nicht "sicher". Das heißt aber nicht, dass es kaputt ist. Es ist nur dann fehlerhaft, wenn der verwendete Code die Vorbedingung verletzt. Möglicherweise funcwird eine statische Funktion als Hilfsmittel bei der Implementierung anderer Funktionen verwendet. So lokalisiert würde es niemand so nennen, dass es seine Voraussetzungen verletzt.

Viele Funktionen, unabhängig davon, ob sie über Namespaces oder Klassen verfügen, haben Erwartungen an den Zustand eines Werts, auf dem sie ausgeführt werden. Wenn diese Voraussetzungen nicht erfüllt sind, schlagen die Funktionen in der Regel mit UB fehl.

Die C ++ - Standardbibliothek definiert eine Regel "gültig, aber nicht angegeben". Dies besagt, dass, sofern der Standard nichts anderes besagt, jedes Objekt, von dem verschoben wird, gültig ist (es ist ein rechtmäßiges Objekt dieses Typs), aber der spezifische Zustand dieses Objekts ist nicht spezifiziert. Wie viele Elemente hat ein Umzug vector? Es sagt nicht.

Das heißt, Sie können keine Funktion aufrufen, die eine Vorbedingung hat. vector::operator[]Voraussetzung ist, dass der vectormindestens ein Element hat. Da Sie den Status des nicht kennen vector, können Sie ihn nicht aufrufen. Es wäre nicht besser, als anzurufen, funcohne vorher zu überprüfen, dass das vectornicht leer ist.

Dies bedeutet aber auch, dass Funktionen, die keine Voraussetzungen haben, in Ordnung sind. Dies ist vollkommen legaler C ++ 11-Code:

vector<int> v1 = {1, 2, 3, 4, 5};
vector<int> v2{std::move(v1)};
v1.assign({6, 7, 8, 9, 10});

vector::assignhat keine Voraussetzungen. Es funktioniert mit jedem gültigen vectorObjekt, auch mit einem Objekt, von dem es verschoben wurde.

Sie erstellen also kein Objekt, das beschädigt ist. Sie erstellen ein Objekt, dessen Status unbekannt ist.

Wenn Sie ein Objekt nicht konstruieren können, die Invarianten nicht ermitteln können, lösen Sie eine Ausnahme vom ctor aus. Wenn Sie die Invarianten in einer Methode nicht beibehalten können, melden Sie einen Fehler und führen Sie ein Rollback durch. Sollte dies für bewegte Objekte anders sein?

Das Auslösen von Ausnahmen von einem Verschiebungskonstruktor wird im Allgemeinen als ... unhöflich angesehen. Wenn Sie ein Objekt verschieben, das über Speicher verfügt, übertragen Sie das Eigentum an diesem Speicher. Und das beinhaltet normalerweise nichts, was werfen kann.

Leider können wir dies aus verschiedenen Gründen nicht durchsetzen . Wir müssen akzeptieren, dass ein Wurfzug eine Möglichkeit ist.

Es sollte auch beachtet werden, dass Sie nicht die "gültige, noch nicht festgelegte" Sprache befolgen müssen. Das ist einfach die Art und Weise der C ++ Standard - Bibliothek sagt , dass die Bewegung für Standardtypen arbeiten standardmäßig . Bestimmte Standardbibliothekstypen haben strengere Garantien. Zum Beispiel unique_ptrist der Status einer ausgelagerten unique_ptrInstanz sehr klar : Er ist gleich nullptr.

Sie können also eine stärkere Garantie wählen, wenn Sie dies wünschen.

Denken Sie daran: Bewegung ist eine Performance - Optimierung , eine , die in der Regel wird auf Objekte erfolgt, die etwa zerstört werden. Betrachten Sie diesen Code:

vector<int> func()
{
  vector<int> v;
  //fill up `v`.
  return v;
}

Dies verschiebt sich von vin den Rückgabewert (vorausgesetzt, der Compiler ignoriert ihn nicht). Und es gibt keine Möglichkeit , vnach Abschluss des Umzugs zu verweisen . Was auch immer Sie getan haben, um veinen nützlichen Zustand zu erreichen, ist bedeutungslos.

In den meisten Codes ist die Wahrscheinlichkeit, eine ausgelagerte Objektinstanz zu verwenden, gering.

Reicht es aus, im Header nur zu dokumentieren, dass es nach dem Verschieben dieses Objekts unzulässig ist, etwas anderes zu tun, als es zu zerstören?

Ist es besser, ständig zu behaupten, dass es in jedem Methodenaufruf gültig ist?

Voraussetzung ist, solche Dinge nicht zu überprüfen. operator[]Voraussetzung ist, dass die vectorein Element mit dem angegebenen Index haben. Sie erhalten UB, wenn Sie versuchen, außerhalb der Größe der zuzugreifen vector. vector::at hat keine solche Voraussetzung; Es vectorlöst explizit eine Ausnahme aus, wenn das keinen solchen Wert hat.

Voraussetzungen bestehen aus Performancegründen. Sie sind so, dass Sie keine Dinge überprüfen müssen, die der Anrufer für sich selbst hätte überprüfen können. Bei jedem Anruf v[0]muss nicht überprüft werden, ob vleer ist. nur der erste tut es.

Ist es besser, die Klasse kopierbar, aber nicht beweglich zu machen?

Nein. Tatsächlich sollte eine Klasse niemals "kopierbar, aber nicht beweglich" sein. Wenn es kopiert werden kann, sollte es durch Aufrufen des Kopierkonstruktors verschoben werden können. Dies ist das Standardverhalten von C ++ 11, wenn Sie einen benutzerdefinierten Kopierkonstruktor deklarieren, aber keinen Verschiebungskonstruktor deklarieren. Und es ist das Verhalten, das Sie übernehmen sollten, wenn Sie keine spezielle Verschiebungssemantik implementieren möchten.

Es gibt eine Verschiebungssemantik, um ein ganz bestimmtes Problem zu lösen: Objekte mit großen Ressourcen, bei denen das Kopieren unerschwinglich oder bedeutungslos wäre (z. B. Dateihandles). Wenn sich Ihr Objekt nicht qualifizieren lässt, ist das Kopieren und Verschieben dasselbe für Sie.


5
Nett. +1. Ich würde folgendes bemerken: "Voraussetzung ist, solche Dinge nicht zu überprüfen." - Ich glaube nicht, dass dies für Behauptungen gilt. Behauptungen sind IMHO ein gutes und gültiges Werkzeug, um die Voraussetzungen zu überprüfen (zumindest die meiste Zeit)
Martin Ba

3
Die Verwirrung beim Kopieren / Verschieben kann dadurch verdeutlicht werden, dass ein Verschieben das Quellobjekt in einem beliebigen Zustand belassen kann, einschließlich identisch mit dem neuen Objekt - was bedeutet, dass die möglichen Ergebnisse eine Obermenge derjenigen eines Kopierobjekts sind.
MSalters
Durch die Nutzung unserer Website bestätigen Sie, dass Sie unsere Cookie-Richtlinie und Datenschutzrichtlinie gelesen und verstanden haben.
Licensed under cc by-sa 3.0 with attribution required.