Invariants de durée de vie des objets vs sémantique de déplacement

13

Lorsque j'ai appris le C ++ il y a longtemps, il m'a été fortement souligné qu'une partie de l'intérêt du C ++ est que, tout comme les boucles ont des "invariants de boucle", les classes ont également des invariants associés à la durée de vie de l'objet - des choses qui devraient être vraies. tant que l'objet est vivant. Choses qui devraient être établies par les constructeurs et préservées par les méthodes. L'encapsulation / contrôle d'accès est là pour vous aider à appliquer les invariants. RAII est une chose que vous pouvez faire avec cette idée.

Depuis C ++ 11, nous avons maintenant la sémantique des mouvements. Pour une classe qui prend en charge le déplacement, le déplacement d'un objet ne met pas officiellement fin à sa durée de vie - le déplacement est censé le laisser dans un état "valide".

Lors de la conception d'une classe, est-ce une mauvaise pratique si vous la concevez de manière à ce que les invariants de la classe ne soient conservés que jusqu'au moment où elle est déplacée? Ou est-ce que ça va si cela vous permet d'aller plus vite.

Pour le rendre concret, supposons que j'ai un type de ressource non copiable mais mobile comme ceci:

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

public:
  opaque(opaque &&);

  ...

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

Et pour une raison quelconque, j'ai besoin de créer un wrapper copiable pour cet objet, afin qu'il puisse être utilisé, peut-être dans un système de répartition existant.

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); }
};

Dans cet copyable_opaqueobjet, un invariant de la classe établie à la construction est que le membre o_pointe toujours vers un objet valide, car il n'y a pas de ctor par défaut, et le seul ctor qui n'est pas une copie ctor le garantit. Toutes les operator()méthodes supposent que cet invariant est valable et le conservent ensuite.

Cependant, si l'objet est déplacé de, alors o_ne pointera alors vers rien. Et après ce point, appeler l'une des méthodes operator()entraînera un crash UB /.

Si l'objet n'est jamais déplacé, l'invariant sera conservé jusqu'à l'appel de dtor.

Supposons que, par hypothèse, j'ai écrit ce cours, et des mois plus tard, mon collègue imaginaire a fait l'expérience de l'UB parce que, dans une fonction compliquée où beaucoup de ces objets étaient mélangés pour une raison quelconque, il est passé de l'une de ces choses et plus tard appelé l'un des ses méthodes. Il est clair que c'est sa faute à la fin de la journée, mais cette classe est-elle "mal conçue?"

Pensées:

  1. C'est généralement une mauvaise forme en C ++ de créer des objets zombies qui explosent si vous les touchez.
    Si vous ne pouvez pas construire un objet, ne pouvez pas établir les invariants, alors lancez une exception du ctor. Si vous ne pouvez pas conserver les invariants dans une méthode, signalez une erreur d'une manière ou d'une autre et annulez. Cela devrait-il être différent pour les objets déplacés?

  2. Est-il suffisant de simplement documenter "après que cet objet a été déplacé, il est illégal (UB) de faire autre chose que de le détruire" dans l'en-tête?

  3. Est-il préférable d'affirmer continuellement qu'il est valide dans chaque appel de méthode?

Ainsi:

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); }
};

Les assertions n'améliorent pas sensiblement le comportement, et elles provoquent un ralentissement. Si votre projet utilise le schéma "release build / debug build", plutôt que de simplement s'exécuter avec des assertions, je suppose que c'est plus attrayant, car vous ne payez pas les vérifications dans la version release. Si vous n'avez pas vraiment de versions de débogage, cela semble cependant assez peu attrayant.

  1. Est-il préférable de rendre la classe copiable, mais pas mobile?
    Cela semble également mauvais et cause un impact sur les performances, mais cela résout le problème "invariant" de manière simple.

Selon vous, quelles sont les "meilleures pratiques" pertinentes ici?

Chris Beck
la source

Réponses:

20

C'est généralement une mauvaise forme en C ++ de créer des objets zombies qui explosent si vous les touchez.

Mais ce n'est pas ce que tu fais. Vous créez un "objet zombie" qui explosera si vous le touchez mal . Ce qui n'est finalement pas différent de toute autre condition préalable basée sur l'État.

Considérez la fonction suivante:

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

Cette fonction est-elle sûre? Non; l'utilisateur peut passer un vide vector . Donc, la fonction a une condition préalable de fait quev contient au moins un élément. Si ce n'est pas le cas, vous obtenez UB lorsque vous appelez func.

Cette fonction n'est donc pas "sûre". Mais cela ne signifie pas qu'il est cassé. Il n'est cassé que si le code qui l'utilise viole la condition préalable. Peut func- être est une fonction statique utilisée comme aide dans l'implémentation d'autres fonctions. Localisé de cette manière, personne ne l'appellerait d'une manière qui viole ses conditions préalables.

De nombreuses fonctions, qu'elles aient une portée d'espace de noms ou des membres de classe, auront des attentes sur l'état d'une valeur sur laquelle elles opèrent. Si ces conditions préalables ne sont pas remplies, les fonctions échoueront, généralement avec UB.

La bibliothèque standard C ++ définit une règle "valide mais non spécifiée". Cela signifie que, sauf indication contraire de la norme, chaque objet déplacé sera valide (c'est un objet légal de ce type), mais l' état spécifique de cet objet n'est pas spécifié. Combien d'éléments comporte un déplacé vector? Ça ne dit pas.

Cela signifie que vous ne pouvez appeler aucune fonction ayant une condition préalable. vector::operator[]a la condition préalable d' vectoravoir au moins un élément. Puisque vous ne connaissez pas l'état de la vector, vous ne pouvez pas l'appeler. Ce ne serait pas mieux que d'appeler funcsans d'abord vérifier que le vectorn'est pas vide.

Mais cela signifie également que les fonctions qui n'ont pas de conditions préalables sont bonnes. C'est du code C ++ 11 parfaitement légal:

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

vector::assignn'a pas de conditions préalables. Cela fonctionnera avec tout validevector objet , même celui qui a été déplacé.

Vous ne créez donc pas un objet cassé. Vous créez un objet dont l'état est inconnu.

Si vous ne pouvez pas construire un objet, ne pouvez pas établir les invariants, alors lancez une exception du ctor. Si vous ne pouvez pas conserver les invariants d'une manière ou d'une autre, signalez une erreur et annulez. Cela devrait-il être différent pour les objets déplacés?

Lancer des exceptions à partir d'un constructeur de mouvement est généralement considéré comme ... grossier. Si vous déplacez un objet qui possède de la mémoire, vous transférez la propriété de cette mémoire. Et cela n'implique généralement rien qui puisse jeter.

Malheureusement, nous ne pouvons pas appliquer cela pour diverses raisons . Nous devons accepter que lancer-déplacer est une possibilité.

Il convient également de noter que vous n'avez pas à suivre le langage "valide mais non spécifié". C'est simplement la façon dont la bibliothèque standard C ++ dit que le mouvement pour les types standard fonctionne par défaut . Certains types de bibliothèques standard ont des garanties plus strictes. Par exemple, unique_ptrest très clair sur l'état d'une unique_ptrinstance déplacée : il est égal ànullptr .

Vous pouvez donc choisir de fournir une garantie plus forte si vous le souhaitez.

N'oubliez pas: le mouvement est une optimisation des performances , qui est généralement effectuée sur des objets sur le point d'être détruits. Considérez ce code:

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

Cela se déplacera de vdans la valeur de retour (en supposant que le compilateur ne l'élide pas). Et il n'y a aucun moyen de faire référence une vfois le déménagement terminé. Donc, tout travail que vous avez fait pour mettre vdans un état utile n'a pas de sens.

Dans la plupart des codes, la probabilité d'utiliser une instance d'objet déplacé est faible.

Est-il suffisant de simplement documenter "après que cet objet a été déplacé, il est illégal (UB) de faire autre chose que de le détruire" dans l'en-tête?

Est-il préférable d'affirmer continuellement qu'il est valide dans chaque appel de méthode?

L'intérêt d'avoir des conditions préalables est de ne pas vérifier de telles choses. operator[]a une condition préalable pour vectoravoir un élément avec l'index donné. Vous obtenez UB si vous essayez d'accéder en dehors de la taille du fichier vector. vector::at n'a pas une telle condition préalable; il lève explicitement une exception si le vectorn'a pas une telle valeur.

Des conditions préalables existent pour des raisons de performances. Ils sont pour que vous n'ayez pas à vérifier des choses que l'appelant aurait pu vérifier par lui-même. Chaque appel à v[0]n'a pas à vérifier s'il vest vide; seule la première le fait.

Est-il préférable de rendre la classe copiable, mais pas mobile?

Non. En fait, une classe ne devrait jamais être "copiable mais non mobile". S'il peut être copié, il doit pouvoir être déplacé en appelant le constructeur de copie. Il s'agit du comportement standard de C ++ 11 si vous déclarez un constructeur de copie défini par l'utilisateur mais ne déclarez pas de constructeur de déplacement. Et c'est le comportement que vous devez adopter si vous ne voulez pas implémenter de sémantique de déplacement spécial.

La sémantique de mouvement existe pour résoudre un problème très spécifique: traiter des objets qui ont de grandes ressources où la copie serait prohibitive ou sans signification (par exemple: les poignées de fichier). Si votre objet ne se qualifie pas, la copie et le déplacement sont les mêmes pour vous.

Nicol Bolas
la source
5
Agréable. +1. Je ferais remarquer que: "L'intérêt d'avoir des conditions préalables est de ne pas vérifier de telles choses." - Je ne pense pas que cela soit valable pour les affirmations. Les affirmations sont à mon humble avis un bon outil et valide pour vérifier les conditions préalables (la plupart du temps au moins.)
Martin Ba
3
La confusion copie / déplacement peut être clarifiée en réalisant qu'un ctor de déplacement peut laisser l'objet source dans n'importe quel état, y compris identique au nouvel objet - ce qui signifie que les résultats possibles sont un surensemble de celui d'un ctor de copie.
MSalters