Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

what is the new feature in c++20 [[no_unique_address]]?

i have read the new c++20 feature no_unique_address several times and i hope if some one can explain and illustrate with an example better than this example below taken from c++ reference.

Explanation Applies to the name being declared in the declaration of a non-static data member that's not a bit field.

Indicates that this data member need not have an address distinct from all other non-static data members of its class. This means that if the member has an empty type (e.g. stateless Allocator), the compiler may optimise it to occupy no space, just like if it were an empty base. If the member is not empty, any tail padding in it may be also reused to store other data members.

#include <iostream>
 
struct Empty {}; // empty class
 
struct X {
    int i;
    Empty e;
};
 
struct Y {
    int i;
    [[no_unique_address]] Empty e;
};
 
struct Z {
    char c;
    [[no_unique_address]] Empty e1, e2;
};
 
struct W {
    char c[2];
    [[no_unique_address]] Empty e1, e2;
};
 
int main()
{
    // e1 and e2 cannot share the same address because they have the
    // same type, even though they are marked with [[no_unique_address]]. 
    // However, either may share address with c.
    static_assert(sizeof(Z) >= 2);
 
    // e1 and e2 cannot have the same address, but one of them can share with
    // c[0] and the other with c[1]
    std::cout << "sizeof(W) == 2 is " << (sizeof(W) == 2) << '\n';
}
  1. can some one explain to me what is the purpose behind this feature and when should i use it?
  2. e1 and e2 cannot have the same address, but one of them can share with c[0] and the other with c[1] can some one explain? why do we have such kind of relation ?
like image 986
Adam Avatar asked Jul 07 '20 22:07

Adam


3 Answers

The purpose behind the feature is exactly as stated in your quote: "the compiler may optimise it to occupy no space". This requires two things:

  1. An object which is empty.

  2. An object that wants to have an non-static data member of a type which may be empty.

The first one is pretty simple, and the quote you used even spells it out an important application. Objects of type std::allocator do not actually store anything. It is merely a class-based interface into the global ::new and ::delete memory allocators. Allocators that don't store data of any kind (typically by using a global resource) are commonly called "stateless allocators".

Allocator-aware containers are required to store the value of an allocator that the user provides (which defaults to a default-constructed allocator of that type). That means the container must have a subobject of that type, which is initialized by the allocator value the user provides. And that subobject takes up space... in theory.

Consider std::vector. The common implementation of this type is to use 3 pointers: one for the beginning of the array, one for the end of the useful part of the array, and one for the end of the allocated block for the array. In a 64-bit compilation, these 3 pointers require 24 bytes of storage.

A stateless allocator doesn't actually have any data to store. But in C++, every object has a size of at least 1. So if vector stored an allocator as a member, every vector<T, Alloc> would have to take up at least 32 bytes, even if the allocator stores nothing.

The common workaround to this is to derive vector<T, Alloc> from Alloc itself. The reason being that base class subobject are not required to have a size of 1. If a base class has no members and has no non-empty base classes, then the compiler is permitted to optimize the size of the base class within the derived class to not actually take up space. This is called the "empty base optimization" (and it's required for standard layout types).

So if you provide a stateless allocator, a vector<T, Alloc> implementation that inherits from this allocator type is still just 24 bytes in size.

But there's a problem: you have to inherit from the allocator. And that's really annoying. And dangerous. First, the allocator could be final, which is in fact allowed by the standard. Second, the allocator could have members that interfere with the vector's members. Third, it's an idiom that people have to learn, which makes it folk wisdom among C++ programmers, rather than an obvious tool for any of them to use.

So while inheritance is a solution, it's not a very good one.

This is what [[no_unique_address]] is for. It would allow a container to store the allocator as a member subobject rather than as a base class. If the allocator is empty, then [[no_unique_address]] will allow the compiler to make it take up no space within the class's definition. So such a vector could still be 24 bytes in size.


e1 and e2 cannot have the same address, but one of them can share with c[0] and the other with c1 can some one explain? why do we have such kind of relation ?

C++ has a fundamental rule that its object layout must follow. I call it the "unique identity rule".

For any two objects, at least one of the following must be true:

  1. They must have different types.

  2. They must have different addresses in memory.

  3. They must actually be the same object.

e1 and e2 are not the same object, so #3 is violated. They also share the same type, so #1 is violated. Therefore, they must follow #2: they must not have the same address. In this case, since they are subobjects of the same type, this means that the compiler-defined object layout of this type cannot give them the same offset within the object.

e1 and c[0] are distinct objects, so again #3 fails. But they satisfy #1, since they have different types. Therefore (subject to the rules of [[no_unique_address]]) the compiler could assign them to the same offset within the object. The same goes for e2 and c[1].

If the compiler wants to assign two different members of a class to the same offset within the containing object, then they must be of different types (note that this is recursive through all of each of their subobjects). Therefore, if they have the same type, they must have different addresses.

like image 186
Nicol Bolas Avatar answered Oct 31 '22 23:10

Nicol Bolas


In order to understand [[no_unique_address]], let's take a look at unique_ptr. It has the following signature:

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

In this declaration, Deleter represents a type which provides the operation used to delete a pointer.

We can implement unique_ptr like this:

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

So what's wrong with this implementation? We want unique_ptr to be as light-weight as possible. Ideally, it should be the exact same size as a regular pointer. But because we have the Deleter member, unqiue_ptr will end up being at least 16 bytes: 8 for the pointer, and then 8 additional ones to store the Deleter, even if Deleter is empty.

[[no_unique_address]] solves this issue:

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...
like image 22
Alecto Irene Perez Avatar answered Oct 31 '22 23:10

Alecto Irene Perez


While the other answers explained it pretty well already, let me explain it from a slightly different perspective:

The root of the problem is that C++ does not allow for zero sized objects (i.e. we always have sizeof(obj) > 0).

This is essentially a consequence of very fundamental definitions in the C++ standard: The unique identity rule (as Nicol Bolas explained) but also from the definition of the "object" as a non-empty sequence of bytes.

However this leads to unpleasant issues when writing generic code. This is somewhat expected because here a corner-case (-> empty type) receives a special treatment, that deviates from the systematic behavior of the other cases (-> size increases in a non-systematic way).

The effects are:

  1. Space is wasted, when stateless objects (i.e. classes/structs with no members) are used
  2. Zero length arrays are forbidden.

Since one arrives at these problems very quickly when writing generic code, there have been several attempts for mitigation

  • The empty base class optimization. This solves 1) for a subset of cases
  • Introduction of std::array which allows for N==0. This solves 2) but still has issue 1)
  • The introcduction of [no_unique_address], which finally solves 1) for all remaining cases. At least when the user explicity requests it.
  • Introduction of std::is_empty. Needed because the obvious sizeof does not work (as sizeof(Empty) >= 1). (Thanks to Dwayne Robinson)

Maybe allowing zero-sized objects would have been the cleaner solution which could have prevented the fragmentation *). However when you search for zero-sized object on SO you will find questions with different answers (sometimes not convincing) and quickly notice that this is a disputed topic. Allowing zero-sized objects would require a change at the heart of the C++ language and given the fact that the C++ language is very complex already, the standard comittee likely decided for the minimal invasive route and just introduced a new attribute.

Together with the other mitigations from above it finally solves all issues due to disallowal of zero-sized objects. Even though it is maybe not be the nicest solution from a fundamental point of view, it is effective.

*) To me the unique-identity-rule for zero sized types does not make much sense anyway. Why should we want objects, which are stateless per programmers choice (i.e. have no non-static data members), to have an unique address in the first place? The address is some kind of (immutable) state of an object and if the programmer wanted a state they could just add a nonstatic data member.

like image 11
Andreas H. Avatar answered Nov 01 '22 01:11

Andreas H.