Avant C++20, l'Optimisation de Base Vide (EBO) permettait aux classes de base vides de partager des adresses mémoire avec les membres de données des classes dérivées, consommant effectivement zéro stockage. Cependant, les membres de données étaient strictement requis pour posséder des adresses uniques et des tailles non nulles, forçant les allocateurs sans état dans des conteneurs comme std::map à soit gonfler la taille des nœuds, soit à dépendre d'héritages privés fragiles. L'attribut [[no_unique_address]] permet explicitement à un membre de données non statique d'occuper zéro octet si son type est vide, permettant ainsi la composition plutôt que l'héritage pour le stockage des allocateurs tout en maintenant une densité mémoire optimale dans les conteneurs STL.
Le modèle d'allocateur C++98 utilisait principalement des foncteurs sans état, où l'EBO par héritage était la technique standard pour éviter les surcharges de stockage dans les conteneurs standard. Avec l'introduction des allocateurs scoping et des traits de propagation des allocateurs sophistiqués dans C++11, la complexité de l'héritage à partir d'allocateurs potentiellement avec état a augmenté, risquant des comportements indéfinis ou des inefficacités de disposition lors du passage entre des variantes. C++20 a standardisé l'attribut [[no_unique_address]] pour fournir un support linguistique de première classe pour la composition sans surcharge, aligné avec le principe de zéro surcoût sans exiger des hiérarchies d'héritage fragiles qui compliquent les interfaces des classes.
Le modèle d'objet C++ impose que des objets complets et des sous-objets potentiellement chevauchants aient des tailles distinctes non nulles et des adresses uniques, empêchant deux membres de données de la même classe de partager des emplacements mémoire même si leurs types sont vides. Pour des conteneurs basés sur des nœuds tel que std::list ou std::map, chaque nœud stocke généralement une instance d'allocateur ; sans optimisation, un allocateur sans état ajoute au moins un octet (arrondi à l'alignement), augmentant significativement la consommation mémoire pour des millions de petits nœuds. Les solutions traditionnelles utilisaient l'héritage privé, ce qui compliquait les hiérarchies de classes et empêchait le remplacement facile des allocateurs par des alternatives avec état sans redessiner la machinerie de modèles.
L'attribut [[no_unique_address]] signale au compilateur qu'un membre de données n'exige pas d'adresse unique, permettant qu'il soit placé au même emplacement mémoire qu'un autre sous-objet si le type du membre est une classe triviale vide. Cela permet aux implémenteurs de conteneur de déclarer des allocateurs comme membres directs tout en garantissant un coût de stockage nul pour les types sans état, le compilateur ajustant automatiquement le rembourrage et la disposition. L'attribut préserve les règles d'aliasing strictes et la sémantique de durée de vie des objets, assouplissant simplement la contrainte d'unicité de l'adresse spécifiquement pour le membre annoté.
#include <iostream> #include <memory> #include <cstdint> // Exemple d'allocateur sans état template <typename T> struct EmptyAllocator { using value_type = T; EmptyAllocator() = default; template <typename U> EmptyAllocator(const EmptyAllocator<U>&) {} T* allocate(std::size_t n) { return std::allocator<T>().allocate(n); } void deallocate(T* p, std::size_t n) { std::allocator<T>().deallocate(p, n); } // Type vide bool operator==(const EmptyAllocator&) const = default; }; // Nœud avec [[no_unique_address]] template <typename T, typename Alloc = EmptyAllocator<T>> struct NodeOptimized { [[no_unique_address]] Alloc allocator; // Zéro octets si Alloc est vide T value; NodeOptimized* next; explicit NodeOptimized(const T& val) : value(val), next(nullptr) {} }; // Nœud sans optimisation (pour comparaison) template <typename T, typename Alloc = EmptyAllocator<T>> struct NodeNaive { Alloc allocator; // Toujours 1+ octets T value; NodeNaive* next; explicit NodeNaive(const T& val) : value(val), next(nullptr) {} }; int main() { std::cout << "Taille du nœud optimisé : " << sizeof(NodeOptimized<int>) << " octets "; std::cout << "Taille du nœud naïf : " << sizeof(NodeNaive<int>) << " octets "; // Dans des implémentations typiques, Optimized sera de 16 octets (8+4+4 ou similaire) // tandis que Naive sera de 24 octets (1 rembourré à 8 + 8 + 4 + rembourrage) return 0; }
Dans un projet d'infrastructure de trading à faible latence, l'équipe devait implémenter un arbre rouge-noir intrusif personnalisé pour l'appariement des ordres, où chaque nœud représentait un ordre limite. Le système nécessitait des stratégies de mémoire modulaires : un allocateur de pile pour des morceaux fixes durant les heures de marché, et std::allocator pour les scénarios de test rétrospectif.
L'implémentation initiale utilisait l'héritage privé de l'allocateur pour tirer parti de l'Optimisation de Base Vide, en supposant que l'allocateur standard coûterait zéro octets.
// Approche initiale : EBO basé sur l'héritage template <typename T, typename Alloc> class OrderNode : private Alloc { // Maladroit : Alloc est une base T data; OrderNode* left; OrderNode* right; Color color; public: // Problème : Ambiguïté si Alloc a des méthodes nommées 'left' ou 'color' // Problème : Impossible de stocker facilement Alloc comme membre si avec état };
Cette approche s'est avérée fragile. Lorsque l'équipe de gestion des risques a exigé un allocateur d'audit avec état qui suivait les compteurs d'utilisation mémoire, le passage à une variable membre a entraîné une inflation de 8 octets par nœud due à l'alignement, augmentant l'empreinte mémoire totale de 40% et dégradant les performances du cache.
Solution alternative A : Stockage effacé par type avec std::variant.
L'équipe a envisagé de stocker soit un pointeur vers l'allocateur (pour l'état) soit rien (pour sans état) en utilisant std::variant ou un effacement de type manuel.
Avantages : Interface unifiée pour les allocateurs avec état et sans état sans explosion de modèles.
Inconvénients : Surcharge d'indirection pour les allocateurs avec état, et le variant lui-même nécessitait au moins un octet (plus l'alignement) pour le stockage du discriminateur, ne répondant pas à l'exigence de zéro surcharge pour le chemin critique où les allocateurs sans état étaient prédominants.
Solution alternative B : Spécialisation de modèle avec des classes distinctes.
Ils ont évalué la spécialisation de toute la classe OrderNode en fonction de std::is_empty_v<Alloc>, héritant lorsque vide et composant lorsqu'avec état.
Avantages : Garantie de zéro surcharge pour le cas vide.
Inconvénients : Duplication de code entre les deux spécialisations, temps de compilation doublés, et cauchemars de maintenance lors de l'ajout de nouveaux champs de nœud, car les changements devaient être reflétés dans les deux branches de modèle.
Solution choisie et résultat :
L'équipe a migré vers C++20 et a appliqué [[no_unique_address]] au membre allocateur.
template <typename T, typename Alloc> struct OrderNode { [[no_unique_address]] Alloc alloc; // Coût nul si vide T data; OrderNode* left; OrderNode* right; // ... reste de l'implémentation };
Ce design a éliminé le besoin d'héritage tout en maintenant zéro octets de surcoût pour l'allocateur de pile de production. Lorsque l'allocateur d'audit (avec état) a été substitué, le membre s'est automatiquement agrandi pour accommoder ses compteurs sans changements de code. Les benchmarks ont montré une réduction de 15% des manques de cache par rapport à la version basée sur l'héritage en raison de meilleures optimisations de compilateur sur la hiérarchie de classes plus plate, et le code est devenu significativement plus maintenable.
Deux membres de données [[no_unique_address]] du même type vide peuvent-ils occuper la même adresse mémoire ?
Non, ils ne le peuvent pas. Bien que [[no_unique_address]] supprime l'exigence d'une adresse unique par rapport à d'autres sous-objets, C++ impose toujours que des objets complets distincts du même type doivent avoir des adresses distinctes. Si deux membres m1 et m2 du même type de classe vide étaient annotés, le compilateur doit allouer un stockage séparé (typiquement 1 octet chacun, soumis à l'alignement) pour garantir &node.m1 != &node.m2. L'attribut ne permet un chevauchement qu'avec des membres de types différents ou avec des sous-objets de classe de base.
Comment [[no_unique_address]] interagit-il avec offsetof et les types à disposition standard ?
L'interaction est subtile et potentiellement dangereuse. Si une classe contient des membres [[no_unique_address]], elle peut toujours être à disposition standard, mais invoquer offsetof sur un tel membre donne des résultats définis par l'implémentation si le membre est vide et chevauche un autre sous-objet. De plus, puisque les règles de disposition standard supposent que les membres de données non statiques occupent des octets distincts dans l'ordre de déclaration, chevaucher un membre vide avec un membre suivant viole techniquement l'hypothèse d'ordre strict que certains codes hérités font. Les développeurs devraient éviter l'arithmétique des pointeurs basée sur offsetof pour les membres [[no_unique_address]] et s'appuyer plutôt sur std::addressof.
Pourquoi [[no_unique_address]] est-il inutile pour les classes de base, et quels risques évite-t-il par rapport à l'héritage ?
Les classes de base se qualifient intrinsèquement pour l'Optimisation de Base Vide sans attributs, car un sous-objet de base vide est autorisé à partager l'adresse du premier membre de données non statique de la classe dérivée. [[no_unique_address]] existe spécifiquement pour accorder cette capacité aux membres de données, permettant la composition. L'utilisation de membres de données évite les pièges de cachage de nom et d'ambiguïté d'héritage multiple de l'héritage privé. Par exemple, si un conteneur héritait d'un allocateur qui définissait un typedef de pointer imbriqué, et que le conteneur définissait également son propre type de pointer, la recherche non qualifiée résoudrait le membre de la classe de base, causant des erreurs de compilation obscures. Les membres de données avec [[no_unique_address]] éliminent cette pollution de portée tout en préservant l'efficacité de disposition.