Why are references forbidden in std::variant?
Asked Answered
F

2

35

I use boost::variant a lot and am quite familiar with it. boost::variant does not restrict the bounded types in any way, in particular, they may be references:

#include <boost/variant.hpp>
#include <cassert>
int main() {
  int x = 3;
  boost::variant<int&, char&> v(x); // v can hold references
  boost::get<int>(v) = 4; // manipulate x through v
  assert(x == 4);
}

I have a real use-case for using a variant of references as a view of some other data.

I was then surprised to find, that std::variant does not allow references as bounded types, std::variant<int&, char&> does not compile and it says here explicitly:

A variant is not permitted to hold references, arrays, or the type void.

I wonder why this is not allowed, I don't see a technical reason. I know that the implementations of std::variant and boost::variant are different, so maybe it has to do with that? Or did the authors think it is unsafe?

PS: I cannot really work around the limitation of std::variant using std::reference_wrapper, because the reference wrapper does not allow assignment from the base type.

#include <variant>
#include <cassert>
#include <functional>

int main() {
  using int_ref = std::reference_wrapper<int>;
  int x = 3;
  std::variant<int_ref> v(std::ref(x)); // v can hold references
  static_cast<int&>(std::get<int_ref>(v)) = 4; // manipulate x through v, extra cast needed
  assert(x == 4);
}
Frosting answered 16/1, 2019 at 13:53 Comment(5)
A cast or std::get<int_ref>(v).get(), to be fair.Eurhythmics
You can use pointers though no?Danby
Same problems as optional<T&>: do you assign through or reseat the reference?Bozarth
@Danby In my application I could make it work with pointers, but I would have to write a lot of extra code. I have a lot of boost::get calls, which work seamlessly if the variant holds full types or references. It wouldn't work a seamlessly with pointers.Frosting
Sorry, should have said boost::get and boost::apply_visitor calls. I could simply wrap the first to handle pointers seamlessly, but it is not easy to wrap second.Frosting
C
34

Fundamentally, the reason that optional and variant don't allow reference types is that there's disagreement on what assignment (and, to a lesser extent, comparison) should do for such cases. optional is easier than variant to show in examples, so I'll stick with that:

int i = 4, j = 5;
std::optional<int&> o = i;
o = j; // (*)

The marked line can be interpreted to either:

  1. Rebind o, such that &*o == &j. As a result of this line, the values of i and j themselves remain changed.
  2. Assign through o, such &*o == &i is still true but now i == 5.
  3. Disallow assignment entirely.

Assign-through is the behavior you get by just pushing = through to T's =, rebind is a more sound implementation and is what you really want (see also this question, as well as a Matt Calabrese talk on Reference Types).

A different way of explaining the difference between (1) and (2) is how we might implement both externally:

// rebind
o.emplace(j);

// assign through
if (o) {
    *o = j;
} else {
    o.emplace(j);
}

The Boost.Optional documentation provides this rationale:

Rebinding semantics for the assignment of initialized optional references has been chosen to provide consistency among initialization states even at the expense of lack of consistency with the semantics of bare C++ references. It is true that optional<U> strives to behave as much as possible as U does whenever it is initialized; but in the case when U is T&, doing so would result in inconsistent behavior w.r.t to the lvalue initialization state.

Imagine optional<T&> forwarding assignment to the referenced object (thus changing the referenced object value but not rebinding), and consider the following code:

optional<int&> a = get();
int x = 1 ;
int& rx = x ;
optional<int&> b(rx);
a = b ;

What does the assignment do?

If a is uninitialized, the answer is clear: it binds to x (we now have another reference to x). But what if a is already initialized? it would change the value of the referenced object (whatever that is); which is inconsistent with the other possible case.

If optional<T&> would assign just like T& does, you would never be able to use Optional's assignment without explicitly handling the previous initialization state unless your code is capable of functioning whether after the assignment, a aliases the same object as b or not.

That is, you would have to discriminate in order to be consistent.

If in your code rebinding to another object is not an option, then it is very likely that binding for the first time isn't either. In such case, assignment to an uninitialized optional<T&> shall be prohibited. It is quite possible that in such a scenario it is a precondition that the lvalue must be already initialized. If it isn't, then binding for the first time is OK while rebinding is not which is IMO very unlikely. In such a scenario, you can assign the value itself directly, as in:

assert(!!opt);
*opt=value;

Lack of agreement on what that line should do meant it was easier to just disallow references entirely, so that most of the value of optional and variant can at least make it for C++17 and start being useful. References could always be added later - or so the argument went.

Calgary answered 16/1, 2019 at 15:39 Comment(7)
The explanation makes sense, thank you. As a naive user, though, I expect my variant to behave exactly like the type it currently holds. I cannot rebind references, so I wouldn't expect variant to rebind upon assignment.Frosting
"rebind is a more sound implementation and is what you really want" I guess you mean "you" in the general sense and not me personally, because I don't want it to rebind.Frosting
@Frosting I agree that the naive expectation is that optional<T&> does assign-through. But that is a bad choice for that type. Note that boost::optional<T&> rebinds .Calgary
Host Jason Turner and Guest Jonathan Müller discuss this issue a bit starting at around 18:54 in episode 136 of CppCast.Myrnamyrobalan
It is a reasonable choice for variant, though, so I perhaps optional and variant shouldn't be forced to act alike.Frosting
@Frosting I disagree both that (a) it's a reasonable choice for variant and (b) that optional and variant are meaningfully different. If optional<T> had different semantics from variant<nullopt, T>, that would be very confusingCalgary
If the naive expectation is that optional<T&> does assign-through, then having it rebind is accident prone, which you cant have. Since operations on wrapper types should affect the wrapper, not the thing being wrapped, you cant have it assign-through. That leaves disallowing assignment and forcing us to explicitly assigning-through with *o=jBadgett
A
3

The fundamental reason is that a reference must be assigned to something. Unions naturally do not - can not, even - set all their fields simultaneously and therefore simply cannot contain references, from the C++ standard:

If a union contains a non-static data member of reference type the program is ill-formed.

std::variant is a union with extra data denoting the type currently assigned to the union, so the above statement implicitly holds true for std:variant as well. Even if it were to be implemented as a straight class rather than a union, we'd be back to square one and have an uninitialised reference when a different field was in use.

Of course we can get around this by faking references using pointers, but this is what std::reference_wrapper takes care of.

Academician answered 29/9, 2022 at 9:8 Comment(0)

© 2022 - 2025 — McMap. All rights reserved.