strict_variant
is a safe
and performant discriminated union container, in the style of boost::variant
, designed for the C++11 standard.
strict_variant
is similar
in design to boost::variant
, however, there are a few important
changes to the interface and the implementation. The basic goal is to make it more
convenient for the programmer to use -- debatably there are some efficiency
gains in some situations, but that's not the primary goal.
Both boost::variant
and std::variant
support construction from any instance of one of the value types, or any
type convertible to one of the value types. In boost::variant
,
even C++03 is supported, so in that case there actually is one constructor
for each value type. In C++17 std::variant
,
it is specified that when constructing a variant from a value, the value-type
/ conversion which will be used is determined by overload resolution, essentially
the same mechanism.
However, this behavior is somewhat inconvenient in some scenarios. For instance, this code compiles just fine, though we might not want it to:
boost::variant<std::string, int> v; v = true; // bool -> int conversion is selected
and this code fails because of ambiguous overload resolution:
boost::variant<bool, long, unsigned long long, std::string> v; v = 100; // int -> long and int -> ull are equally good conversions
Another, slightly different example. This code does what you expect:
boost::variant<int, std::string> v; v = "The future is now!"; std::cout << v.which() << std::endl; std::cout << v << std::endl;
When tested with boost 1.58
and gcc 5.4.0
, it outputs:
1 The future is now!
However, when we change it slightly, it does something totally different:
boost::variant<bool, std::string> v; v = "The future is now!"; std::cout << v.which() << std::endl; std::cout << v << std::endl;
It now selects bool
, because
of implicit pointer-to-bool conversions:
0 1
strict_variant
addresses
such issues by modifying the overload resolution process. Before overload
resolution takes place, some candidates are excluded:
unsigned ->
signed
, and conversions between
any of the classes bool
,
integral, floating point, character, and some others.
For a prime use-case, you might be using the variant to represent value types
when binding C++ code to some scripting language implementation. Many scripting
languages support bool,
int, float, std::string
,
etc. as primitive values, but basically overload resolution is most likely
to become ambiguous in situations like this. We refine the overload resolution
process on the assumption that what you are trying to
do is store the value as faithfully as possible for later recovery, while
still permitting portable integer promotions and such.
For a complete description of the conversion rules, check out TODO LINK.
The second way in which strict_variant
differs from boost::variant
is the implementation approach
-- how exactly is the storage managed, and how is the never-empty guarantee
achieved.
Much digital ink has been spilled regarding the technical difficulties of implementing a general, never-empty variant in C++. (For a long list of references, see the footnotes of the design section.)
At least, it's clear that there is no "perfect" solution and there are instead many possible compromises, most of which are incomparable.
In the design section we'll discuss various options in detail, but to motivate our approach, we'd like to argue that an approach which requires as little bandwidth to explain to the programmer as possible is a good one.
To that end, let's try to design a general variant
from the ground up by reducing the problem to a simpler one.
The simpler problem is,
variant
which
is restricted to contain only value-types which are nothrow
move constructible.
It turns out that this problem is really much simpler -- there is no longer any issue with the "throwing, type-changing assignment". We can always vacate the storage and then move the new value in, since the move won't throw. Compared to the general problem, this kind of variant is straightforward to implement optimally. Basically everything can be done by "copy and swap" and similar idioms -- there are no "surprises" here really.
Now,
It turns out we can do it in a simple way using a little template:
template <typename T> using wrap_if_throwing_move_t = typename std::conditional<std::is_nothrow_move_constructible<T>::value, T, recursive_wrapper<T>>::type;
(The reader will hopefully recall recursive_wrapper
from boost::variant
.
A recursive_wrapper<T>
represents
a heap-allocated instance of T
.
It can be used to declare a variant which morally contains a T
even if T
is an incomplete type.)
Now, for the general form of the variant, we simply apply wrap_if_throwing_move_t
to each value-type, and defer to the simpler implementation.
(In the strict_variant
library,
the template class strict_variant::variant
is the "easy case" solution, and the template alias strict_variant::easy_variant
is the general-case solution.)
The point is that even if T
has a throwing move, recursive_wrapper<T>
can always be moved into storage without throwing, because it is just a pointer.
Some care must be taken -- throughout the variant
interface, it is important that recursive_wrapper
is transparently pierced for the user, not only for convenience now, but
for correctness -- the user should not be able to make the recursive_wrapper
empty.
But, with adequate care for such issues, you are now perfectly capable of
going and writing your own strict_variant
,
and you'll get the same thing at least in regards to all the "controversial"
parts of implementing a variant.
In fact, you could probably stop reading the documentation right now and go and use the variant, and hopefully wouldn't ever be surprised by it's behavior given what I just told you.
(This is not a trick -- the rest of the documentation does actually exist and is accurate to the best of my knowledge if you want to read it :p)
To illustrate the differences between the strict_variant
and boost::variant
implementations, consider the following
example:
using var_t = boost::variant<A, B>; var_t v{A()}; std::cout << "1" << std::endl; v = B(); std::cout << "2" << std::endl; v = A(); std::cout << "3" << std::endl;
where A
and B
have been configured to log all ctor
and dtor calls.
boost::variant
generates the following output:
A() A(A&&) ~A() 1 B() B(B&&) A(const A &) ~A() B(const B &) ~A() ~B() ~B() 2 A() A(A&&) B(const B &) ~B() A(const A &) ~B() ~A() ~A() 3 ~A()
while strict_variant
generates
A() A(A&&) ~A() 1 B() B(B&&) ~A() ~B() 2 A() A(A&&) ~B() ~A() 3 ~A()
For a programmer, this means the following:
boost::variant
has to make backup copies of
both A
and B
, while strict_variant
constructs the same number of objects as std::variant
or one of the rarely-empty variants. When debugging low-level problems,
it may become important to know about the extra object instances -- it's
not always "invisible" to the programmer.
v =
A();
and v
is a boost::variant
, this can throw both A
-exceptions and B
-exceptions.
With strict_variant
it
can only throw A
-exceptions.
But, these issues aside, I argue that strict_variant
adds value because the programmer can have a much simpler mental model of
how it works and what exactly can happen when they manipulate it.
For an extended comparison of this and several other possible implementation strategies, see the design page.
The design goals of strict_variant
are:
boost::variant
.