quelle est la nouvelle fonctionnalité de c++20 [[no_unique_address]] ?

quelle est la nouvelle fonctionnalité de c++20 [[no_unique_address]] ?

Le but de la fonctionnalité est exactement celui indiqué dans votre citation :"le compilateur peut l'optimiser pour qu'il n'occupe aucun espace". Cela nécessite deux choses :

  1. Un objet qui est vide.

  2. Un objet qui veut avoir un membre de données non statique d'un type qui peut être vide.

La première est assez simple, et la citation que vous avez utilisée en fait même une application importante. Objets de type std::allocator ne pas en fait stocker quoi que ce soit. Il s'agit simplement d'une interface basée sur les classes dans le ::new global et ::delete alloueurs de mémoire. Les allocateurs qui ne stockent aucune donnée (généralement en utilisant une ressource globale) sont communément appelés "allocateurs sans état".

Les conteneurs compatibles avec l'allocateur sont nécessaires pour stocker la valeur d'un alternateur fourni par l'utilisateur (qui est par défaut un alternateur construit par défaut de ce type). Cela signifie que le conteneur doit avoir un sous-objet de ce type, qui est initialisé par la valeur d'allocateur fournie par l'utilisateur. Et ce sous-objet prend de la place... en théorie.

Considérez std::vector . L'implémentation courante de ce type consiste à utiliser 3 pointeurs :un pour le début du tableau, un pour la fin de la partie utile du tableau et un pour la fin du bloc alloué au tableau. Dans une compilation 64 bits, ces 3 pointeurs nécessitent 24 octets de stockage.

Un alternateur sans état n'a en fait aucune donnée à stocker. Mais en C++, chaque objet a une taille d'au moins 1. Donc si vector stocké un alternateur en tant que membre, tous les vector<T, Alloc> devrait occuper au moins 32 octets, même si l'allocateur ne stocke rien.

La solution de contournement courante consiste à dériver vector<T, Alloc> à partir de Alloc lui-même . La raison étant que les sous-objets de la classe de base ne sont pas obligatoires pour avoir une taille de 1. Si une classe de base n'a pas de membres et n'a pas de classes de base non vides, le compilateur est autorisé à optimiser la taille de la classe de base dans la classe dérivée pour ne pas occuper réellement d'espace. C'est ce qu'on appelle "l'optimisation de base vide" (et elle est requise pour les types de mise en page standard).

Donc, si vous fournissez un alternateur sans état, un vector<T, Alloc> l'implémentation qui hérite de ce type d'allocateur n'a toujours qu'une taille de 24 octets.

Mais il y a un problème :vous devez hériter de l'allocataire. Et c'est vraiment énervant. Et dangereux. Tout d'abord, l'allocateur pourrait être final , ce qui est en fait autorisé par la norme. Deuxièmement, l'allocateur pourrait avoir des membres qui interfèrent avec le vector les membres. Troisièmement, c'est un idiome que les gens doivent apprendre, ce qui en fait une sagesse populaire parmi les programmeurs C++, plutôt qu'un outil évident à utiliser pour n'importe lequel d'entre eux.

Ainsi, bien que l'héritage soit une solution, ce n'est pas une très bonne solution.

C'est ce que [[no_unique_address]] est pour. Cela permettrait à un conteneur de stocker l'allocateur en tant que sous-objet membre plutôt qu'en tant que classe de base. Si l'allocateur est vide, alors [[no_unique_address]] permettra au compilateur de lui faire prendre aucun espace dans la définition de la classe. Donc un tel vector peut toujours avoir une taille de 24 octets.

C++ a une règle fondamentale que sa disposition d'objet doit suivre. Je l'appelle la "règle d'identité unique".

Pour deux objets, au moins l'un des éléments suivants doit être vrai :

  1. Ils doivent avoir des types différents.

  2. Ils doivent avoir des adresses différentes en mémoire.

  3. Ils doivent en fait être le même objet.

e1 et e2 ne sont pas le même objet, donc #3 est violé. Ils partagent également le même type, donc #1 est violé. Par conséquent, ils doivent suivre le #2 :ils ne doivent pas avoir la même adresse. Dans ce cas, puisqu'il s'agit de sous-objets du même type, cela signifie que la mise en page de l'objet défini par le compilateur de ce type ne peut pas leur donner le même décalage dans l'objet.

e1 et c[0] sont des objets distincts, donc encore une fois #3 échoue. Mais ils satisfont #1, puisqu'ils ont des types différents. Par conséquent (sous réserve des règles de [[no_unique_address]] ), le compilateur pourrait les affecter au même décalage dans l'objet. Il en va de même pour e2 et c[1] .

Si le compilateur veut affecter deux membres différents d'une classe au même décalage dans l'objet conteneur, alors ils doivent être de types différents (notez que c'est récursif à travers chacun de leurs sous-objets). Par conséquent, s'ils ont le même type, ils doivent avoir des adresses différentes.


Pour comprendre [[no_unique_address]] , regardons unique_ptr . Il porte la signature suivante :

template<class T, class Deleter = std::default_delete<T>>
class unique_ptr;

Dans cette déclaration, Deleter représente un type qui fournit l'opération utilisée pour supprimer un pointeur.

Nous pouvons implémenter unique_ptr comme ceci :

template<class T, class Deleter>
class unique_ptr {
    T* pointer = nullptr;
    Deleter deleter;

   public:
    // Stuff

    // ...

    // Destructor:
    ~unique_ptr() {
        // deleter must overload operator() so we can call it like a function
        // deleter can also be a lambda
        deleter(pointer);
    }
};

Alors, qu'est-ce qui ne va pas avec cette mise en œuvre ? Nous voulons unique_ptr être le plus léger possible. Idéalement, il devrait avoir exactement la même taille qu'un pointeur ordinaire. Mais parce que nous avons le Deleter membre , unqiue_ptr finira par faire au moins 16 octets :8 pour le pointeur, puis 8 supplémentaires pour stocker le Deleter , même si Deleter est vide .

[[no_unique_address]] résout ce problème :

template<class T, class Deleter>
class unique_ptr {
    T* pointer = nullptr;
    // Now, if Deleter is empty it won't take up any space in the class
    [[no_unique_address]] Deleter deleter;
   public:
    // STuff...

Alors que les autres réponses l'expliquaient déjà assez bien, laissez-moi l'expliquer d'un point de vue légèrement différent :

La racine du problème est que C++ n'autorise pas les objets de taille nulle (c'est-à-dire que nous avons toujours sizeof(obj) > 0 ).

Ceci est essentiellement une conséquence des définitions très fondamentales du standard C++ :la règle d'identité unique (comme l'a expliqué Nicol Bolas) mais aussi de la définition de "l'objet" comme une séquence d'octets non vide.

Cependant, cela conduit à des problèmes désagréables lors de l'écriture de code générique. Ceci est quelque peu attendu car ici un coin-cas (-> type vide) reçoit un traitement spécial, qui s'écarte du comportement systématique des autres cas (-> la taille augmente de manière non systématique).

Les effets sont :

  1. L'espace est gaspillé lorsque des objets sans état (c'est-à-dire des classes/structures sans membres) sont utilisés
  2. Les tableaux de longueur nulle sont interdits.

Comme on arrive très vite à ces problèmes lors de l'écriture de code générique, il y a eu plusieurs tentatives d'atténuation

  • L'optimisation de la classe de base vide. Cela résout 1) pour un sous-ensemble de cas
  • Introduction de std::array qui permet N==0. Cela résout 2) mais a toujours le problème 1)
  • L'introduction de [no_unique_address], qui résout finalement 1) pour tous les cas restants. Au moins lorsque l'utilisateur le demande explicitement.

Peut-être que l'autorisation d'objets de taille nulle aurait été la solution la plus propre qui aurait pu empêcher la fragmentation. Cependant, lorsque vous recherchez un objet de taille nulle sur SO, vous trouverez des questions avec des réponses différentes (parfois peu convaincantes) et vous remarquerez rapidement qu'il s'agit d'un sujet controversé. Autoriser des objets de taille nulle nécessiterait un changement au cœur du langage C++ et étant donné que le langage C++ est déjà très complexe, le comité standard a probablement opté pour la voie minimalement invasive et vient d'introduire un nouvel attribut.

Avec les autres atténuations ci-dessus, il résout enfin tous les problèmes dus à l'interdiction des objets de taille nulle. Même si ce n'est peut-être pas la meilleure solution d'un point de vue fondamental, c'est efficace.