Przed C++20 optymalizacja dla pustych baz (EBO) pozwalała pustym klasom bazowym dzielić adresy pamięci z członkami danych klas pochodnych, efektywnie konsumując zero miejsca. Jednakże, od członków danych ściśle wymagano posiadania unikalnych adresów i rozmiarów różniących się od zera, zmuszając alokatory bezstanowe w kontenerach takich jak std::map do powiększania rozmiarów węzłów lub korzystania z delikatnej prywatnej dziedziczenia. Atrybut [[no_unique_address]] eksplicytnie zezwala na to, aby członek danych nie statycznych zajmował zero bajtów, jeśli jego typ jest pusty, co umożliwia kompozycję zamiast dziedziczenia w celu przechowywania alokatora, jednocześnie utrzymując optymalną gęstość pamięci w kontenerach STL.
Model alokatora C++98 przeważnie wykorzystywał bezstanowe funktory, gdzie EBO poprzez dziedziczenie był standardową techniką unikania nadmiaru pamięci w standardowych kontenerach. Wraz z wprowadzeniem C++11 alokatorów ze zasięgiem i zaawansowanymi cechami propagacji alokatorów, złożoność dziedziczenia od potencjalnie stanowych alokatorów wzrosła, stwarzając ryzyko nieokreślonego zachowania lub nieefektywności układu podczas przełączania między wariantami. C++20 ustandaryzował atrybut [[no_unique_address]], aby zapewnić językowe wsparcie dla kompozycji bez dodatkowych kosztów, zgodne z zasadą Zero-overhead, bez potrzeby delikatnych hierarchii dziedziczenia, które komplikowały interfejsy klas.
Model obiektowy C++ wymusza, aby obiekty kompletne i potencjalnie nakładające się podobiekty miały różne rozmiary różniące się od zera i unikalne adresy, zapobiegając dwóm członom danych tej samej klasy dzielącym lokalizacje pamięci, nawet jeśli ich typy są puste. W kontenerach opartych na węzłach, takich jak std::list lub std::map, każdy węzeł zazwyczaj przechowuje instancję alokatora; bez optymalizacji alokator bezstanowy dodaje co najmniej jeden bajt (zaokrąglony do wyrównania), znacząco zwiększając zużycie pamięci dla milionów małych węzłów. Tradycyjne obejścia wykorzystywały prywatne dziedziczenie, co komplikowało hierarchie klas i uniemożliwiało łatwą zamianę alokatorów na alternatywy stanowe bez przekształcania maszyny szablonowej.
Atrybut [[no_unique_address]] sygnalizuje kompilatorowi, że członek danych nie wymaga unikalnego adresu, co pozwala na umieszczenie go w tej samej lokalizacji pamięci co inny podobiekt, jeśli typ członka jest pustą klasą, która jest prosto skopiowalna. To umożliwia implementatorom kontenerów deklarowanie alokatorów jako bezpośrednich członów, zapewniając zerowy koszt przechowywania dla typów bezstanowych, z automatycznym dostosowaniem wyrównania i układu przez kompilator. Atrybut zachowuje zasady surowego aliasowania i semantykę życia obiektów, jedynie łagodząc wymóg unikalności adresu specjalnie dla oznaczonego członka.
W projekcie infrastruktury handlu o niskiej latencji, zespół potrzebował wdrożyć własne drzewo czerwono-czarne oparte na węzłach do dopasowywania zleceń, gdzie każdy węzeł reprezentował zlecenie limitujące. System wymagał wymiennych strategii pamięci: alokatora stosu dla zgrupowanych fragmentów stałych rozmiarów podczas godzin otwarcia rynku oraz std::allocator dla scenariuszy testowych.
Początkowa implementacja wykorzystywała prywatne dziedziczenie od alokatora, aby wykorzystać Empty Base Optimization, zakładając, że standardowy alokator nie kosztowałby żadnych bajtów.
// Początkowe podejście: EBO oparte na dziedziczeniu template <typename T, typename Alloc> class OrderNode : private Alloc { // Niezręczne: Alloc jest bazą T data; OrderNode* left; OrderNode* right; Color color; public: // Problem: Niejasność, jeśli Alloc ma metody o nazwach 'left' lub 'color' // Problem: Nie można łatwo przechowywać Alloc jako człona, jeśli jest stanowy };
To podejście okazało się kruche. Gdy zespół ds. zarządzania ryzykiem zażądał alokatora audytowego ze stanem, który śledził liczniki użycia pamięci, przejście do zmiennej członowej spowodowało natychmiastowy wzrost o 8 bajtów na węzeł z powodu wyrównania, zwiększając całkowity ślad pamięci o 40% i pogarszając wydajność pamięci podręcznej.
Alternatywne rozwiązanie A: Przechowywanie z typem wymazanym za pomocą std::variant.
Zespół rozważył przechowywanie wskaźnika do alokatora (dla stanowego) lub niczego (dla bezstanowego) za pomocą std::variant lub ręcznego wymazania typu.
Zalety: Ujednolicona interfejs dla alokatorów stanowych i bezstanowych bez eksplozji szablonów.
Wady: Narzut pośrednictwa dla alokatorów stanowych, a sam wariant wymagał co najmniej jednego bajtu (plus wyrównania) na przechowywanie dyskryminatora, co nie spełniało wymogu zerowego nadmiaru dla ścieżki krytycznej, gdzie alokatory bezstanowe dominowały.
Alternatywne rozwiązanie B: Specjalizacja szablonów z wyraźnymi klasami.
Rozważano specjalizację całej klasy OrderNode w oparciu o std::is_empty_v<Alloc>, dziedziczenie w przypadku pustych oraz kompozycję, gdy były stanowe.
Zalety: Gwarantowany zerowy nadmiar dla przypadku pustego.
Wady: Powielanie kodu między obiema specjalizacjami, podwójne czasy kompilacji i koszmary w utrzymaniu przy dodawaniu nowych pól do węzłów, ponieważ zmiany musiały być odwzorowane w obu gałęziach szablonu.
Wybrane rozwiązanie i wynik:
Zespół przeszedł na C++20 i zastosował [[no_unique_address]] do człona alokatora.
template <typename T, typename Alloc> struct OrderNode { [[no_unique_address]] Alloc alloc; // Zero kosztu, jeśli pusty T data; OrderNode* left; OrderNode* right; // ... reszta implementacji };
Ten projekt wyeliminował potrzebę dziedziczenia, zachowując zero bajtów nadmiaru dla alokatora stosu w produkcji. Gdy alokator audytowy (stanowy) został zastąpiony, człon automatycznie rozszerzył się, aby pomieścić swoje liczniki, bez zmian w kodzie. Testy wydajnościowe wykazały 15% redukcję w błędach pamięci podręcznej w porównaniu do wersji opartej na dziedziczeniu z powodu lepszych optymalizacji kompilatora w bardziej płaskiej hierarchii klas, a kod stał się znacznie bardziej zdatny do utrzymania.
Czy dwa człony danych [[no_unique_address]] tego samego pustego typu mogą zajmować ten sam adres pamięci?
Nie, nie mogą. Choć [[no_unique_address]] usuwa wymóg względem unikalnego adresu w stosunku do innych podobiektów, C++ nadal wymaga, aby odrębne obiekty kompletne tego samego typu miały różne adresy. Jeśli dwa człony m1 i m2 tego samego pustego typu byłyby oznaczone, kompilator musi przydzielić osobne miejsce (typowo 1 bajt każdy, w zależności od wyrównania), aby zapewnić &node.m1 != &node.m2. Atrybut pozwala tylko na nakładanie się z członami różnych typów lub z podobiektami klas bazowych.
Jak [[no_unique_address]] interaguje z offsetof i typami o standardowym układzie?
Interakcja jest subtelna i potencjalnie niebezpieczna. Jeśli klasa zawiera człony [[no_unique_address]], może być nadal o standardowym układzie, ale wywołanie offsetof na takim członie przynosi wyniki zdefiniowane przez implementację, jeśli człon jest pusty i pokrywa się z innym podobyktiem. Ponadto, ponieważ zasady dotyczące standardowego układu zakładają, że człony danych nie statycznych zajmują różne bajty w kolejności deklaracji, nakładanie pustego członu na następny człon technicznie narusza surowe założenie o kolejności niektórych starych kodów. Programiści powinni unikać arytmetyki wskaźników opartej na offsetof dla członów [[no_unique_address]] i zamiast tego polegać na std::addressof.
Dlaczego [[no_unique_address]] jest zbędne dla klas bazowych i jakie ryzyka unika w porównaniu z dziedziczeniem?
Klasy bazowe z natury kwalifikują się do Empty Base Optimization bez atrybutów, ponieważ pusty podobiekt bazowy może dzielić adres pierwszego nie-statycznego człona danych klasy pochodnej. [[no_unique_address]] istnieje specjalnie, aby nadać tę zdolność członom danych, umożliwiając kompozycję. Użycie członów danych unika pułapek związanych z ukrywaniem nazw i niejednoznacznością dziedziczenia wielokrotnego w przypadku dziedziczenia prywatnego. Na przykład, jeśli kontener dziedziczył od alokatora, który definiował zagnieżdżony typedef pointer, a kontener również definiował własny typ pointer, niekwalifikowane wyszukiwanie rozwiązywałoby się na człona klasy bazowej, co prowadziłoby do niejasnych błędów kompilacji. Człony danych z [[no_unique_address]] eliminują to zanieczyszczenie zakresu, zachowując jednocześnie efektywność układu.