Document Number: | P0110R0 |
Date: | 2015-09-25 |
Author: | Anthony
Williams Just Software Solutions Ltd |
variant<>
assignmentN4542 has drawn a lot of comments across the web, from me and others. The fundamental problem
from my perspective is the undefined behaviour that arises from the invalid state. My
initial reaction was to propose an empty state in its place, the key distinction
being that (a) the user could explicitly set the variant to be empty, and (b)
operations on empty variants either succeeded without problem (e.g. copying
an empty variant just created a second empty variant), or threw an exception
(e.g. calling get
on an empty variant), rather than having undefined
behaviour.
However, some people were strongly against the idea of an empty state, since they want to be
able to rely on their variants holding exactly one of the types in the list, and an empty state
is in many ways equivalent to an additional type in the list. Rather than
handling variant<int,string>
, code must now
handle variant<int,string,empty>
. Several people proposed that we
require variant<>
to implement the strong guarantee for
assignment, so either the assignment is successful, or the value is unchanged if an exception
was thrown. I then set out to see how efficiently we could manage this.
The difficulty with providing the strong guarantee is that we want variant
to
hold any kind of type, so operations might throw exceptions, or not be available at all. We
therefore need to identify the possible scenarios and ensure that the everything works OK.
Assumption: if the contained type is the same as the type being assigned, then we use the assignment operator of that type, just as in N4542.
For all the following, I'm going to assume we have a variant v
of
type variant<A,B,C,D>
that holds an instance of type A
, and we
are doing an operation that will construct an instance of type
B
. In each scenario I'll describe the properties
of A
, B
, C
and D
that I'm assuming for
that scenario.
If we're assigning to v
using an operation that creates a type B
using a noexcept
constructor, then it's all straightforward:
A
object (which can't throw as we
require noexcept
destructors).B
object (which can't throw as the constructor we're
using is noexcept
).B
(which can't throw).This is what we get if our type B
is a built-in type, or we're move-constructing
a type with a noexcept
move constructor like std::string
. e.g.
variant<int,std::string> v(42); v=std::string("hello");
If we're assigning to v
using an operation that creates a type B
,
but B
has no noexcept
constructors (e.g. any C++98 legacy type),
then step 2 above may throw. This would leave v
in an inconsistent state, so we
need to do something to avoid it.
B
is nothrow-move-constructibleIf std::nothrow_move_constructible<B>::value
is true
, then
things are almost as easy. Rather than constructing our new B
directly in the
variant, we can first construct an instance on the stack. If this throws then there isn't a
problem, but if it succeeds then we can move-construct it into the variant storage without any
exceptions, and we're home and dry:
B
on the stack. If this throws then v
is
unchanged.A
object (which can't throw as we
require noexcept
destructors).B
object in the variant from our local B
object
(which can't throw as the move constructor is noexcept
).B
(which can't throw).B
object on return from the function (which can't throw as
we require noexcept
destructors).This safely provides us with the strong guarantee at the cost of an extra move operation, but
what if B
isn't nothrow-move-constructible?
B
isn't nothrow-move-constructible, but A
, C
and D
areIf all the other types in the variant are nothrow-move-constructible, then it
doesn't matter whether or not B
is, because we can use safely save the old value,
and then restore it if our B
construction fails:
A
onto the stack (which can't throw
as A
is nothrow-move-constructible).A
object (which can't throw as we
require noexcept
destructors).B
object (which may throw).B
(which
can't throw), orA
from the stack back
into the variant storage (which can't throw as A
is nothrow-move-constructible), and rethrow the exception.A
object on return from the function (which can't throw as
we require noexcept
destructors).Though the execution sequence only cares about A
and B
, we can only
use this if C
and D
are also nothrow-move-constructible,
since we cannot tell at compile time which type is contained in the variant.
This again safely provides the strong guarantee at the cost of an extra move (and a second
extra move in the case of an exception), but what if we can't do that either
because A
is not nothrow-move-constructible?
A
or B
are nothrow-move-constructibleIf A
is non-movable, or has a move constructor that may throw then we cannot
touch the contained A
object in case things go terribly wrong, meaning we end up
with neither a B
nor an A
. We're trying to implement the strong
guarantee, so those are the only two possibilities here.
The solution is thus to provide a second buffer to hold our B
object, and
construct it before we destroy the old value, as follows:
B
object in the buffer. If this throws then v
is unchanged.A
object (which can't throw as we
require noexcept
destructors).B
in the buffer (which can't throw).The question is: where does the buffer live? One option is to keep it in the variant instance, thus making the variant larger, and the second is to dynamically allocate it, and store a pointer to the buffer in the variant itself.
The dynamically allocated buffer is unacceptable to many users, and the circumstances under which the secondary buffer is necessary are going to be increasingly rare, so I feel comfortable requiring that it be part of the variant. As we shall see below, this does not require doubling the size of the variant, as there are techniques that can minimize the buffer size required.
Case 1 is only detectable on a use-by-use basis, as types may have throwing constructors that
we don't use, and there is no type trait for "all constructors for this type
are noexcept
". All decisions about buffering therefore need to be done based on
the availability of noexcept
move constructors.
Based on our four scenarios above, we only need double-buffering in the variant if there are two or more types that are not nothrow-move-constructible. The question then is: how big a buffer do we need?
The answer is simple: we need a backup buffer that is big enough to hold the largest of the types that is not nothrow-move-constructible. We only need the buffer if the type being assigned is not nothrow-move-constructible, so we'll only double the required storage space if the largest type may throw when moved.
You may have noticed that the operation list for case 4 is the same 3 operations as case 1, just in a different order, whereas cases 2 and 3 have additional move operations. This means that double-buffering is more efficient than cases 2 and 3. Therefore we want to use it where we can, without compromising our minimal space requirements.
Thus, if our type B
will fit in our buffer, it is thus more efficient to use the
operations from case 4, even if case 2 would have applied. If double-buffering is enabled, we
will never hit case 3, because there will always be at least one of the other types that is
not nothrow-move-constructible. For large types that don't fit in the buffer, we can
still use case 2 if case 1 doesn't apply.
emplace
emplace
is subtly different. The implication of emplace
is that it
is a direct in-place construction. Doing anything else is therefore somewhat
disingenuous. This therefore rules out case 2 from the emplace
implementation,
and also rules out using assignment when the type is the same as the original. Supporting the
strong guarantee for emplace
would therefore require double-buffering in more
cases: if any types are not nothrow-move-constructible we would have to
double-buffer, as we cannot rely on being able to move the source out of the way.
For my implementation I have chosen to make emplace
provide only the basic
guarantee, and revert to an "empty" state if an exception is thrown.
I have an implementation of variant
that provides the strong guarantee for
assignment on bitbucket, under the BSD license. The code and tests are
available here.