[[no_unique_address]] and two member values of the same type
Asked Answered
I

2

21

I'm playing around with [[no_unique_address]] in c++20.

In the example on cppreference we have an empty type Empty and type Z

struct Empty {}; // empty class

struct Z {
    char c;
    [[no_unique_address]] Empty e1, e2;
};

Apparently, the size of Z has to be at least 2 because types of e1 and e2 are the same.

However, I really want to have Z with size 1. This got me thinking, what about wrapping Empty in some wrapper class with extra template parameter that enforces different types of e1 and e2.

template <typename T, int i>
struct Wrapper : public T{};

struct Z1 {
    char c;
    [[no_unique_address]] Wrapper<Empty,1> e1;
    [[no_unique_address]] Wrapper<Empty,2> e2;
};

Unfortunately, sizeof(Z1)==2. Is there a trick to make size of Z1 to be one?

I'm testing this with gcc version 9.2.1 and clang version 9.0.0


In my application, I have lots of empty types of the form

template <typename T, typename S>
struct Empty{
    [[no_unique_address]] T t;
    [[no_unique_address]] S s;
};

Which is an empty type if T and S are also empty types and distinct! I want this type to be empty even if T and S are the same types.

Indaba answered 29/11, 2019 at 17:39 Comment(8)
What about adding template arguments to T itself? That would generate distinct types. Right now the fact that both Wrappers inherit from T is holding you back...Disability
@MaxLanghof What do you mean by adding a template argument to T? Right now, T is a template argument.Indaba
Don't inherit from T.Frontality
@Frontality makes no difference here.Dolerite
Just because it is bigger than 1 doesn't make it non-empty: coliru.stacked-crooked.com/a/51aa2be4aff4842eDrillmaster
@Frontality With your new suggestion of no composition, you've lost all information about the type argument. It would be even simpler to have no member at all, if you don't need the type.Dolerite
@eerorika, got it.Frontality
@Frontality I would like that Wrapper1 still holds a value of T. In 90% use cases I have , the type T is empty, but from time to time it is not and its value might be important.Indaba
F
8

Which is an empty type if T and S are also empty types and distinct! I want this type to be empty even if T and S are the same types.

You can't get that. Technically speaking, you can't even guarantee that it will be empty even if T and S are different empty types. Remember: no_unique_address is an attribute; the ability of it to hide objects is entirely implementation-dependent. From a standards perspective, you cannot enforce the size of empty objects.

As C++20 implementations mature, you should assume that [[no_unique_address]] will generally follow the rules of empty base optimization. Namely, so long as two objects of the same type aren't subobjects, you can probably expect to get hiding. But at this point, it's kind of pot-luck.

As to the specific case of T and S being the same type, that is simply not possible. Despite the implications of the name "no_unique_address", the reality is that C++ requires that, given two pointers to objects of the same type, those pointers either point to the same object or have different addresses. I call this the "unique identity rule", and no_unique_address does not affect that. From [intro.object]/9:

Two objects with overlapping lifetimes that are not bit-fields may have the same address if one is nested within the other, or if at least one is a subobject of zero size and they are of different types; otherwise, they have distinct addresses and occupy disjoint bytes of storage.

Members of empty types declared as [[no_unique_address]] are zero-sized, but having the same type makes this impossible.

Indeed, thinking about it, attempting to hide the empty type via nesting still violates the unique identity rule. Consider your Wrapper and Z1 case. Given a z1 which is an instance of Z1, it is clear that z1.e1 and z1.e2 are different objects with different types. However, z1.e1 is not nested within z1.e2 nor vice-versa. And while they have different types, (Empty&)z1.e1 and (Empty&)z1.e2 are not different types. But they do point to different objects.

And by the unique identity rule, they must have different addresses. So even though e1 and e2 are nominally different types, their internals must also obey unique identity against other subobjects in the same containing object. Recursively.

What you want is simply impossible in C++ as it currently stands, regardless of how you try.

Footstone answered 29/11, 2019 at 18:18 Comment(4)
no_unique_address is so disappointing 😢. It's 99% of the way there for what people want (the original poster and myself), and then drops the ball at the last moment by saying that if they're the same type, it ignores your attribute and gives them unique addresses anyway. 🤦‍♀️Downward
@DwayneRobinson: It's 100% of the way there for what people need. I contest that what is "wanted" in this question is a reasonable thing to want. I mean, if an object is in fact an empty type, and it shouldn't have a unique address... why would you have two of them as members right next to each other? Every instance of that type ought to work the same, right? So why do you have two of them?Footstone
@DwayneRobinson: And if the type is a template and therefore doesn't know that the two types it is given are the same, then it's even more important not to violate the unique identity rule. You never know when some unknown type might do something like store references to instances of its class somewhere or something crazy like that. That is, it might actually meaningfully take advantage of its unique identity, and you can't just have some class deny it that capability.Footstone
@NicolBolas IMO if a class wants to take advantage of having a unique identity, it should be non-empty. It wouldn't be a problem if the language simply special cased empty objects; it's easy to opt out by adding a dummy char member or whatever.Afforest
D
2

As far as I can tell, that is not possible if you want to have both members. But you can specialise and have only one of the members when the type is same and empty:

template <typename T, typename S, typename = void>
struct Empty{
    [[no_unique_address]] T t;
    [[no_unique_address]] S s;

    constexpr T& get_t() noexcept { return t; };
    constexpr S& get_s() noexcept { return s; };
};

template<typename TS>
struct Empty<TS, TS, typename std::enable_if_t<std::is_empty_v<TS>>>{
    [[no_unique_address]] TS ts;

    constexpr TS& get_t() noexcept { return ts; };
    constexpr TS& get_s() noexcept { return ts; };
};

Of course, rest of the program that uses the the members would need to be changed to deal with the case where there is only one member. It shouldn't matter which member is used in this case - after all, it is a stateless object with no unique address. The shown member functions should make that simple.

unfortunately sizeof(Empty<Empty<A,A>,A>{})==2 where A is a completely empty struct.

You could introduce more specialisations to support recursive compression of empty pairs:

template<class TS>
struct Empty<Empty<TS, TS>, TS, typename std::enable_if_t<std::is_empty_v<TS>>>{
    [[no_unique_address]] Empty<TS, TS> ts;

    constexpr Empty<TS, TS>& get_t() noexcept { return ts; };
    constexpr TS&            get_s() noexcept { return ts.get_s(); };
};

template<class TS>
struct Empty<TS, Empty<TS, TS>, typename std::enable_if_t<std::is_empty_v<TS>>>{
    [[no_unique_address]] Empty<TS, TS> ts;

    constexpr TS&            get_t() noexcept { return ts.get_t(); };
    constexpr Empty<TS, TS>& get_s() noexcept { return ts; };
};

Even more, to compress something like Empty<Empty<A, char>, A>.

template <typename T, typename S>
struct Empty<Empty<T, S>, S, typename std::enable_if_t<std::is_empty_v<S>>>{
     [[no_unique_address]] Empty<T, S> ts;

    constexpr Empty<T, S>& get_t() noexcept { return ts; };
    constexpr S&           get_s() noexcept { return ts.get_s(); };
};

template <typename T, typename S>
struct Empty<Empty<S, T>, S, typename std::enable_if_t<std::is_empty_v<S>>>{
     [[no_unique_address]] Empty<S, T> st;

    constexpr Empty<S, T>& get_t() noexcept { return st; };
    constexpr S&           get_s() noexcept { return st.get_t(); };
};


template <typename T, typename S>
struct Empty<T, Empty<T, S>, typename std::enable_if_t<std::is_empty_v<T>>>{
     [[no_unique_address]] Empty<T, S> ts;

    constexpr T&           get_t() noexcept { return ts.get_t(); };
    constexpr Empty<T, S>  get_s() noexcept { return ts; };
};

template <typename T, typename S>
struct Empty<T, Empty<S, T>, typename std::enable_if_t<std::is_empty_v<T>>>{
     [[no_unique_address]] Empty<S, T> st;

    constexpr T&           get_t() noexcept { return st.get_s(); };
    constexpr Empty<S, T>  get_s() noexcept { return st; };
};
Dolerite answered 29/11, 2019 at 18:1 Comment(2)
This is nice, but still unfortunately sizeof(Empty<Empty<A,A>,A>{})==2 where A is a completely empty struct.Indaba
I'd add a get_empty<T> function. Then you can reuse the get_empty<T> on left or right if it already works there.Crabbed

© 2022 - 2024 — McMap. All rights reserved.