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

4

65

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 ?
Garibald answered 7/7, 2020 at 22:17 Comment(2)
Here's one person who'd be happy to use it #57460760 Then there's the age old uses of EBO #4325644 - except we can use composition instead of abusing inheritanceTechnetium
Neither gcc (trunk) nor clang (trunk) on godbolt make sizeof(W) == 2 (struct A in linked example), however they both do if the declarations with [[no_unique_address]] come before the other declarations. ExampleChristan
M
94

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.

Malaysia answered 7/7, 2020 at 22:33 Comment(2)
This is the first time I hear about that "unique identity rule". I know it probably wouldn't be of much use to derive from the same class multiple times (probably neither legal, I'm not sure), but does that rule also apply to base classes?Pomiferous
@DeltA: Objects are objects, whether they are members, base classes, or complete objects. All objects are subject to the same rule (at least, as far as the implementation's layout of objects is concerned).Malaysia
W
24

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...
Woehick answered 7/7, 2020 at 22:31 Comment(4)
+1, but this is not really an issue in practice because most, if not all, unique_ptr implementations avoid the problem by storing the pointer and deleter in compressed_pair or similar type that uses empty base optimization. Of course, as Nicol's answer says, that's not foolproof because you may have a final deleter type etc.Comedic
@Praetorian: "this is not really an issue in practice" Why not? Because normal C++ programmers aren't allowed to write compressed_pair on their own? The fact that boost::compressed_pair exists at all is proof enough that this is a thing people have a real, practical need for. Just like boost::noncopyable is proof that we needed a way to make types that were not copyable.Malaysia
@Nicol I think you misunderstood what I was trying to say. I meant unique_ptr stdlib implementations don't exceed the size of a pointer, assuming the deleter is stateless. The answer seems to imply the unique_ptr size will always be greater than that prior to the existence of [[no_unique_address]].Comedic
@Comedic If an issue can be worked around by increasing complexity, it's still an issue.Frier
S
14

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.

Sprag answered 8/7, 2020 at 9:5 Comment(14)
To my mind, an object of size N should have been defined as having a total of N+1 distinct addresses, of which the first N would be pointers to bytes and the last N would be pointers "just past" bytes. No pointer to a byte within an object would share an address with any pointer to a byte in any other, and no pointer just past a byte in an object would share an address with a pointer just past a byte in any other, but a pointer to a byte in one object could share an address with a pointer just past a byte in an object.Parshall
An object of size zero would have an address, to/from which an offset of zero could be added or subtracted to yield the same address, or which could be subtracted from itself to yield zero, but the address would not be a pointer to or just past any byte, and may or may not arbitrarily compare equal to that of any other object.Parshall
@supercat: Not sure if I understand correctly. Essentially you say pointer arithmetic with zero size objects would work. There is one thing to consider: If you have an array of zero size types, e.g. Empty e[5]: The C++ idiomatic way of iterating over the elements for (i = begin(e); i != end(e); i++) will not produce 5 iterations, but 0. This may be unexpected.Sprag
Think of std::destroy(): it would not run the correct number of times the destructor, which it probably should. But perhaps it would not be an issue to just disallow pointer arithmetic on pointers to zero size types. Also one could consider to disallowing pointer comparisons for pointers to zero size types (in that case destroy_n could be made to work and destroy would give a compile time error). Probably it is possible to come up with a consistent proposal for zero-size objects, but it might be a lot of work.Sprag
One thing I also do not undestand is that why one wants to objects, which are stateless per programmers choice (they 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 to me that makes not much sense anyway.Sprag
I would only define pointer arithmetic on incomplete types in the cases of adding or subtracting zero, or subtracting a pointer from itself. The primary uses I see for zero-size objects would be to facilitate syntactic sugar behavioral couplings via inheritance, arrays which might or might not be needed based upon compile-time computations, or flexible-array-member-style constructs (a purpose for which such types were often supported prior to C89).Parshall
For example, if one needs a data structure that stores N things as N/256 objects that hold 256 each, along with an object that holds N%256 things, being able to treat multiples of 256 just like any other number would be more convenient than having to either exclude or pad the "tail" object when N is a multiple of 256.Parshall
BTW, in regard to your last question, I suspect that some programs in pre-standard days used empty objects as identity tokens, and the authors of C99 wanted neither to break such programs, nor define a new syntax to indicate whether an empty object might be used as an identity token. IMHO, the proper fix would have been to add an explicit "identity token" type which would have an address which is distinct from that associated with any other identity token but need not have any addressable storage there, and deprecate the use of empty objects for that purpose. On some platforms,...Parshall
...address space is far more plentiful than actual storage, so a compiler and linker could cooperate to place identity tokens in unmapped regions of address space.Parshall
@AndreasH. Also unexpected would be the result of the canonical way of computing the number of elements in an array of zero-sized elements (sizeof(arr)/sizeof(*arr) ...)Sil
@Peter-ReinstateMonica The C++17 idiomatic size(arr) would work, though (or could be made to work).Sprag
@Parshall True, due to backward compatibility some explicit user intervention for zero-sizedness would be necessary. Then we probably arrive at some attribute nevertheless ...Sprag
@Peter-ReinstateMonica: Most of the purposes that zero-sized objects would serve could be satisfied even if arrays of such objects, or arithmetic on pointers with such target types, were disallowed (given int foo[0],*p=foo,*q=p+0;, computation of (q-p)/sizeof (*p) wouldn't pose any problem).Parshall
I'd add std::is_empty as another work-around invented to mitigate this design choice. Give the stock sizeof(SomeEmptyStruct) doesn't return 0 as originally expected, I've written little helper functions with it for executing test cases on API's that validate the empty case too.Piperine
Y
0

It might be worth motivating the sizeof(foo) > 0 requirement in the first place

Consider:

struct foo {};

foo v[100];
int loop_cnt = 0;
for (foo *p = &(v[0]), *e = &(v[100]); p < e; ++p, ++loop_cnt) {
    // do something
}

If sizeof(foo) is 0, then loop executes zero times instead of 100 times, probably not what you want.

The relaxation with [[no_unique_address]] allowed in c++20 applies only when variables are of distinct types, which rules out interference with address-based loops like the example above.

Yielding answered 23/3 at 18:12 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.