Standardizing Variant: Difficult Decisions | Just Software Solutions (original) (raw)
Standardizing Variant: Difficult Decisions
One of the papers proposed for the next version of the C++ Standard isN4542: Variant: a type safe union (v4). As you might guess from the (v4) in the title, this paper has been discussed several times by the committee, and revised in the light of discussions.
Boost has had a variant type for a long time, so it only seems natural to standardize it. However, there are a couple of design decisions made for boost::variant
which members of the committee were uncomfortable with, so the current paper has a couple of differences fromboost::variant
. The most notable of these is that boost::variant
has a "never empty" guarantee, whereas N4542 proposes a variant that can be empty.
Why do we need empty variants?
Let's assume for a second that our variant is never empty, as perboost::variant
, and consider the following code with two classes A
and B
:
variant<A,B> v1{A()};
variant<A,B> v2{B()};
v1=v2;
Before the assignment, v1
holds a value of type A
. After the assignmentv1=v2
, v1
has to hold a value of type B
, which is a copy of the value held in v2
. The assignment therefore has to destroy the old value of type A
and copy-construct a new value of type B
into the internal storage of v1
.
If the copy-construction of B
does not throw, then all is well. However, if the copy construction of B
does throw then we have a problem: we just destroyed our old value (of type A
), so we're in a bit of a predicament — the variant isn't allowed to be empty, but we don't have a value!
Can we fix it? Double buffering
In 2003 I wrotean article about this, proposing a solution involving double-buffering: the variant type could contain a buffer big enough to hold both A
and B
. Then, the assignment operator could copy-construct the new value into the empty space, and only destroy the old value if this succeeded. If an exception was thrown then the old value is still there, so we avoid the previous predicament.
This technique isn't without downsides though. Firstly, this can double the size of the variant, as we need enough storage for the two largest types in the variant. Secondly, it changes the order of operations: the old value is not destroyed until after the new one has been constructed, which can have surprising consequences if you are not expecting it.
The current implementation of boost::variant
avoids the first problem by constructing the secondary buffer on the fly. This means that assignment of variants now involves dynamic memory allocation, but does avoid the double-space requirement. However, there is no solution for the second problem: avoiding destroying the old value until after the new one has been constructed cannot be avoided while maintaining the never-empty guarantee in the face of throwing copy constructors.
Can we fix it? Require no-throw copy construction
Given that the problem only arises due to throwing copy constructors, we could easily avoid the problem by requiring that all types in the variant have a no-throw copy constructor. The assignment is then perfectly safe, as we can destroy the old value, and copy-construct the new one, without fear of an exception throwing a spanner in the works.
Unfortunately, this has a big downside: lots of useful types that people want to put in variants like std::string
, or std::vector
, have throwing copy constructors, since they must allocate memory, and people would now be unable to store them directly. Instead, people would have to usestd::shared_ptr<std::string>
or create a wrapper that stored the exception in the case that the copy constructor threw an exception.
template<typename T>
class value_or_exception{
private:
std::optional<T> value;
std::exception_ptr exception;
public:
value_or_exception(T const& v){
try{
value=v;
} catch(...) {
exception=std::current_exception();
}
}
value_or_exception(value_or_exception const& v){
try{
value=v.value;
exception=v.exception;
} catch(...) {
exception=std::current_exception();
}
return *this;
}
value_or_exception& operator=(T const& v){
try{
value=v;
exception=std::exception_ptr();
} catch(...) {
exception=std::current_exception();
}
return *this;
}
// more constructors and assignment operators
T& get(){
if(exception){
std::rethrow_exception(exception);
}
return *value;
}
};
Given such a template you could havevariant<int,value_or_exception<std::string>>
, since the copy constructor would not throw. However, this would make using the std::string
value that little bit harder due to the wrapper — access to it would require calling get()
on the value, in addition to the code required to retrieve it from the variant.
variant<int,value_or_exception<std::string>> v=get_variant_from_somewhere();
std::string& s=std::get<value_or_exception<std::string>>(v).get();
The code that retrieves the value then also needs to handle the case that the variant might be holding an exception, so get()
might throw.
Can we fix it? Tag types
One proposed solution is to add a special case if one of the variant types is a special tag type likeempty_variant_t
. e.g. variant<int,std::string,empty_variant_t
. In this case, if the copy constructor throws then the special empty_variant_t
type is stored in the variant instead of what used to be there, or what we tried to assign. This allows people who are OK with the variant being empty to use this special tag type as a marker — the variant is never strictly "empty", it just holds an instance of the special type in the case of an exception, which avoids the problems with out-of-order construction and additional storage. However, it leaves the problems for those that don't want to use the special tag type, and feels like a bit of a kludge.
Do we need to fix it?
Given the downsides, we have to ask: is any of this really any better than allowing an empty state?
If we allow our variant
to be empty then the code is simpler: we just write for the happy path in the main code. If the assignment throws then we will get an exception at that point, which we can handle, and potentially store a new value in the variant there. Also, when we try and retrieve the value then we might get an exception there if the variant is empty. However, if the expected scenario is that the exception will never actually get thrown, and if it does then we have a catastrophic failure anyway, then this can greatly simplify the code.
For example, in the case of variant<int,std::string>
, the only reason you'd get an exception from the std::string
copy constructor was due to insufficient memory. In many applications, running out of dynamic memory is exceedingly unlikely (the OS will just allocate swap space), and indicates an unrecoverable scenario, so we can get away with assuming it won't happen. If our application isn't one of these, we probably know it, and will already be writing code to carefully handle out-of-memory conditions.
Other exceptions might not be so easily ignorable, but in those cases you probably also have code designed to handle the scenario gracefully.
A variant with an "empty" state is a bit like a pointer in the sense that you have to check for NULL
before you use it, whereas a variant without an empty state is more like a reference in that you can rely on it having a value. I can see that any code that handles variants will therefore get filled with asserts and preconditions to check the non-emptiness of the variant.
Given the existence of an empty variant, I would rather that the various accessors such as get<T>()
and get<Index>()
threw an exception on the empty state, rather than just being ill-formed.
Default Construction
Another potentially contentious area is that of default construction: should a variant type be default constructible? The current proposal has variant<A,B>
being default-constructible if and only if A
(the first listed type) is default-constructible, in which case the default constructor default-constructs an instance of A
in the variant. This mimics the behaviour of the core language facility union
.
This means that variant<A,B>
and variant<B,A>
behave differently with respect to default construction. For starters, the default-constructed type is different, but also one may be default-constructible while the other is not. For some people this is a surprising result, and undesirable.
One alternative options is that default construction picks the first default-constructible type from the list, if there are any, but this still has the problem of different orderings behaving differently.
Given that variants can be empty, another alternative is to have the default constructed variant be empty. This avoids the problem of different orderings behaving differently, and will pick up many instances of people forgetting to initialize their variants, since they will now be empty rather than holding a default-constructed value.
My preference is for the third option: default constructed variants are empty.
Duplicate types
Should we allow variant<T,T>
? The current proposal allows it, and makes the values distinct. However, it comes with a price: you cannot simply construct avariant<T,T>
from a T
: instead you must use the special constructors that take an emplaced_index_t<I>
as the first parameter, to indicate which entry you wish to construct. Similarly, you can now no longer retrieve the value merely by specifying the type to retrieve: you must specify the index, as this is now significant.
I think this is unnecessary overhead for a seriously niche feature. If people want to have two entries of the same type, but with different meanings, in their variant then they should use the type system to make them different. It's trivial to write a tagged_type
template, so you can have tagged_type<T,struct SomeTag>
and tagged_type<T,struct OtherTag>
which are distinct types, and thus easily discriminated in the variant. Many people would argue that even this is not going far enough: you should embrace the Whole Value Idiom, and write a proper class for each distinct meaning.
Given that, I think it thus makes sense for variant<T,T>
to be ill-formed. I'm tempted to make it valid, and the same as variant<T>
, but too much of the interface depends on the index of the type in the type list. If I havevariant<T,U,T,T,U,int>
, what is the type index of the int
, or the T
for that matter? I'd rather not have to answer such questions, so it seems better to make it ill-formed.
What do you think?
What do you think about the proposed variant template? Do you agree with the design decisions? Do you have a strong opinion on the issues above, or some other aspect of the design?
Have your say in the comments below.
Posted by Anthony Williams
[/ cplusplus /] permanent link
Tags: cplusplus, standards, variant
Stumble It! | Submit to Reddit
| Submit to DZone
If you liked this post, why not subscribe to the RSS feed or Follow me on Twitter? You can also subscribe to this blog by email using the form on the left.
Design and Content Copyright © 2005-2025 Just Software Solutions Ltd. All rights reserved. | Privacy Policy