C++20'den önce, Boş Temel Optimizasyonu (EBO), boş temel sınıfların türetilen sınıf veri üyeleriyle bellek adreslerini paylaşmasına izin vererek sıfır depolama tüketimi sağlıyordu. Ancak, veri üyelerinin kesinlikle benzersiz adreslere ve sıfırdan büyük boyutlara sahip olması gerekiyordu, bu da durumdan bağımsız ayarlayıcıların std::map gibi konteynerlerde düğüm boyutlarını artırmasına veya kırılgan özel miras almaya dayanmasına neden oluyordu. [[no_unique_address]] niteliği, tipi boş olduğunda bir statik olmayan veri üyesinin sıfır bayt yer kaplamasına açıkça izin veriyor, böylece durumdan bağımsız ayarlayıcı depolaması için miras yerine bileşimin kullanılması sağlanıyordu ve bu, STL konteynerlerinde ideal bellek yoğunluğunu koruyordu.
C++98 ayarlayıcı modeli, durumdan bağımsız eğlenceler kullanıyordu; burada, standart konteynerlerde depolama yükünden kaçınmanın standart tekniği olarak miras yoluyla EBO kullanılıyordu. C++11 kapsamlı ayarlayıcılar ve karmaşık ayarlayıcı yayılma özellikleri tanıdığında, durumdan bağımsız ayarlayıcılardan miras almanın karmaşıklığı arttı ve bu, değişkenler arasında geçiş yaparken tanımsız davranış veya düzenleme verimsizliklerini riske attı. C++20, sıfır üst yük bileşimi için birinci sınıf dil desteği sağlamak amacıyla [[no_unique_address]] niteliğini standartlaştırarak, sınıf arayüzlerini karmaşıklaştırmayan kırılgan miras hiyerarşilerine ihtiyaç duymadan Sıfır Üst Yük İlkesi ile uyum sağladı.
C++ nesne modeli, tam nesnelerin ve potansiyel olarak örtüşen alt nesnelerin farklı sıfırdan büyük boyutlara ve benzersiz adreslere sahip olmasını zorunlu kılıyor, böylece aynı sınıftan iki veri üyesinin aynı bellek konumunu paylaşmasının önüne geçiyor, hatta türleri boş olsa bile. std::list veya std::map gibi düğüm tabanlı konteynerler için, her düğüm tipik olarak bir ayarlayıcı örneği saklar; optimizasyon olmadan durumdan bağımsız bir ayarlayıcı en az bir bayt (hizalamaya yuvarlanmış) ekler, bu da milyonlarca küçük düğüm için bellek tüketimini önemli ölçüde artırır. Geleneksel çözümler, sınıf hiyerarşilerini karmaşıklaştıran özel mirası kullandı ve durumdan bağımsız ayarlayıcıların durumlu alternatifleri ile kolayca değiştirilmesini engelledi.
[[no_unique_address]] niteliği, derleyiciye bir veri üyesinin benzersiz bir adres gerektirmediğini bildirir ve üyenin tipi boş olan bir triviyal kopyalanabilir sınıf olduğunda başka bir alt nesne ile aynı bellek konumuna yerleştirilmesine izin verir. Bu, konteyner uygulayıcılarının ayarlayıcıları doğrudan üyeler olarak belirtmesine olanak tanırken, durumdan bağımsız türler için sıfır depolama maliyeti sağlamaktadır ve derleyici otomatik olarak dolgu ve düzenlemeleri ayarlamaktadır. Nitelik, sıkı aliasing kurallarını ve nesne ömrü semantiklerini korur, yalnızca belirtilen üye için adres benzersizliği kısıtlamasını gevşetir.
#include <iostream> #include <memory> #include <cstdint> // Durumdan bağımsız ayarlayıcı örneği 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); } // Boş tür bool operator==(const EmptyAllocator&) const = default; }; // [[no_unique_address]] ile düğüm template <typename T, typename Alloc = EmptyAllocator<T>> struct NodeOptimized { [[no_unique_address]] Alloc allocator; // Eğer Alloc boşsa sıfır bayt T value; NodeOptimized* next; explicit NodeOptimized(const T& val) : value(val), next(nullptr) {} }; // Optimizasyon olmadan düğüm (karşılaştırma için) template <typename T, typename Alloc = EmptyAllocator<T>> struct NodeNaive { Alloc allocator; // Her zaman 1+ bayt T value; NodeNaive* next; explicit NodeNaive(const T& val) : value(val), next(nullptr) {} }; int main() { std::cout << "Optimizasyonlu düğüm boyutu: " << sizeof(NodeOptimized<int>) << " bayt "; std::cout << "Naif düğüm boyutu: " << sizeof(NodeNaive<int>) << " bayt "; // Tipik uygulamalarda, Optimizasyonlu 16 bayt (8+4+4 veya benzeri) olurken // Naif 24 bayt (1, 8'e hizalanmış + 8 + 4 + doldurma) olacaktır. return 0; }
Düşük gecikmeli bir ticaret altyapısı projesinde, ekip, her düğümün bir limit emrini temsil ettiği bir özel girişken kırmızı-siyah ağaç uygulamak zorundaydı. Sistem, piyasa saatleri boyunca havuzlanmış sabit boyutlu parçalar için bir yığın ayarlayıcı ve geri test senaryoları için std::allocator gerektiriyordu.
İlk uygulama, depolama gereksinimlerini karşılarken Boş Temel Optimizasyonu'ndan yararlanmak için ayarlayıcıdan özel miras alıyordu, standart ayarlayıcının sıfır bayt maliyetinde olacağını varsayıyordu.
// İlk yaklaşım: Miras temelli EBO template <typename T, typename Alloc> class OrderNode : private Alloc { // Rahatsız edici: Alloc bir temel T data; OrderNode* left; OrderNode* right; Color color; public: // Problem: Eğer Alloc'ın 'left' veya 'color' isimli metotları varsa belirsizlik // Problem: Durumluysa kolayca bir üye olarak saklanamaz };
Bu yaklaşım kırılgan oldu. Risk yönetim ekibi, bellek kullanımı sayaçlarını takip eden durumda denetleme ayarlayıcısı talep ettiğinde, üye değişkenine geçiş yapmak, hizalama nedeniyle her düğümde hemen 8 bayt artış ile sonuçlandı ve toplam bellek boyutunu %40 artırarak önbellek performansını bozdu.
Alternatif Çözüm A: std::variant ile tür silinmiş depolama.
Ekip, durumdan bağımsız olan için hiçbir şey (durumlu olan için bir ayarlayıcıya yönlendirilmiş bir işaretçi) depolamayı düşünmüştü; bunun için std::variant veya manuel tür silinmesi kullanmayı düşündü.
Avantajlar: Durumdan bağımsız ve durumlu ayarlayıcılar için birleşik arayüz, şablon patlaması olmadan.
Dezavantajlar: Durumlu ayarlayıcılar için dolaylılık aşırı yükü ve variantın kendisi en az bir bayt (artı hizalama) gerektirdi, bu da kritik yol için sıfır üst yük gereksinimini karşılamadı, durumdan bağımsız ayarlayıcıların ön planda olduğu durumlarda.
Alternatif Çözüm B: Farklı sınıflarla şablon spesifikasyonu.
Ekip, std::is_empty_v<Alloc>'ye dayalı olarak tamamen OrderNode sınıfını spesiyalize etmeyi değerlendirdi, boş olduğunda miras alarak ve durumlu olduğunda bileşerek.
Avantajlar: Boş durumda sıfır üst yük garantisi.
Dezavantajlar: İki spesifikasyon arasında kod tekrarları, iki katı derleme süreleri ve yeni düğüm alanları eklendiğinde bakım kabusları, çünkü değişiklikler her iki şablon dalında da yansıtılmalıydı.
Seçilen Çözüm ve Sonuç:
Ekip, C++20'ye geçerek ayarlayıcı üyesine [[no_unique_address]] uyguladılar.
template <typename T, typename Alloc> struct OrderNode { [[no_unique_address]] Alloc alloc; // Boşsa sıfır maliyet T data; OrderNode* left; OrderNode* right; // ... uygulamanın geri kalanı };
Bu tasarım, miras almadan yine de üretim yığın ayarlayıcısı için sıfır bayt yükü gereksinimini ortadan kaldırıyordu. Denetim ayarlayıcısı (durumlu) değiştirildiğinde, üye otomatik olarak sayaçlarını barındırmak için genişledi, kod değişikliği yapılmadan. Performans testleri, daha iyi derleyici optimizasyonları sayesinde miras tabanlı versiyona göre ön bellek hatalarında %15 azalma sağladı ve kod tabanı önemli ölçüde daha sürdürülebilir hale geldi.
İki [[no_unique_address]] veri üyesi aynı boş türün aynı bellek adresini paylaşabilir mi?
Hayır, paylaşamazlar. [[no_unique_address]] diğer alt nesnelere göre benzersiz adres gereksinimini kaldırırken, C++ yine de aynı türden farklı tam nesnelerin farklı adreslere sahip olmasını zorunlu kılar. Eğer aynı boş sınıf türünden iki üye m1 ve m2 not edildiyse, derleyici ayrı depolama (genellikle hizalamaya tabi olarak her biri için 1 bayt) tahsis etmek zorundadır; bu nedenle &node.m1 != &node.m2 olmalıdır. Nitelik yalnızca farklı türlere sahip üyeler ile veya temel sınıf alt nesneleriyle örtüşmeyi sağlar.
[[no_unique_address]] nasıl offsetof ve standart düzen türleriyle etkileşimde bulunur?
Etkileşim, ince ve potansiyel olarak tehlikelidir. Bir sınıf [[no_unique_address]] üyelerine sahipse, hala standart düzen olabilir, ancak böyle bir üyeye offsetof uygulanması, üye boşsa ve başka bir alt nesne ile örtüşüyorsa uygulama tanımlı sonuçlar verir. Dahası, standart düzen kuralları, statik olmayan veri üyelerinin beyanda sıralı konumları işgal ettiği varsayımı ile çalıştığı için, boş bir üyenin sonrasında bir üye ile örtüşmesi, bazı eski kodların sıkı sıralama varsayımlarını ihlal eder. Geliştiricilerin offsetof'a dayalı gösterge aritmetiğinden kaçınmaları ve bunun yerine std::addressof kullanmaları gerekmektedir.
Neden [[no_unique_address]] temel sınıflar için gereksizdir ve mirasa göre hangi riskleri önler?
Temel sınıflar, boş bir temel alt nesne, türetilmiş sınıfın ilk statik olmayan veri üyesinin adresini paylaşacak şekilde tanımlandığından, nitelikler olmadan da Boş Temel Optimizasyonu'ndan yararlanır. [[no_unique_address]], bu yeteneği veri üyeleri sağlamak için var olup, bileşim sağlamaktadır. Veri üyeleri kullanmak, özel mirasın isim gizleme ve çoklu miras belirsizliği tuzaklarını önler. Örneğin, bir ayarlayıcıdan miras alan bir konteyner, içindeki bir pointer türünü tanımlıyorsa ve konteyner de kendi pointer türünü tanımlıyorsa, nitelik dereceli görünüm belirsizlikleri ile sonuçlanır; bu da belirsiz derleme hatalarına yol açar. [[no_unique_address]] bulunan veri üyeleri, bu kapsam kirliliğini ortadan kaldırarak düzenleme verimliliğini korur.