It has been one year since my previous post: Initialization in C++: A corner case. In this post, I would like to share some corner cases (with implementation divergences) encountered while testing std::optional
's constructors. To maximize flexibility and usability, std::optional
provides a rich set of overloaded constructors, along with some heavy SFINAE logic to disambiguate them.1 The downside is that certain forms of initialization may behave unexpectedly. In the following sections, I will present several examples and explain how they are interpreted by various implementations and by the C++ standard. All references to the standard are to N4950 (the C++23 final draft).
First, let's write down the relevant constructors of std::optional
so we can refer to them later:2
optional(); // #1 default constructor
optional(nullopt_t); // #2 nullopt constructor
optional(const optional&); // #3 copy constructor
optional(optional&&); // #4 move constructor
template<class... Args>
explicit optional(in_place_t, Args&&...); // #5 in_place constructor
template<class U = remove_cv_t<T>>
explicit(!is_convertible_v<U, T>) optional(U&&); // #6 forwarding constructor
Our journey begins with:
#include <optional>
int main() {
struct S {};
std::optional<S> o1; // calls #1 default constructor
std::optional<S> o2{}; // calls #1 default constructor
std::optional<S> o3 = {}; // calls #1 default constructor
std::optional<S> o4(); // this is a function declaration
// Here comes the fun part:
std::optional<S> o5({});
std::optional<S> o6{{}};
std::optional<S> o7 = {{}};
std::optional<S> o8({{}});
return 0;
}
Note
Almost no one would (or should) write practical code like o5
to o8
above - it only confuses people. I wrote them in this post purely for fun and for educational purposes: to see how implementations handle these corner cases and to reveal a bit about how overload resolution works here. Always strive to make your code easy to read and understand. See also the section The solution below.
In the code above, the cases from o1
to o4
should be sufficiently clear, and I expect every C++ developer to be aware of their meanings. The rest of this post will focus on the cases from o5
to o8
. The following table summarizes the behavior of current implementations and the standard (CE):
Initialization | GCC 15.1 | Clang 20.1.0 / MSVC 19.43 | Standard |
---|---|---|---|
std::optional<S> o5({}) |
#1 default | #1 default | #1 default + #4 move |
std::optional<S> o6{{}} |
#5 in_place (error) |
#6 forwarding | #5 in_place (error) |
std::optional<S> o7 = {{}} |
#5 in_place (errors) |
#6 forwarding | #5 in_place (errors) |
std::optional<S> o8({{}}) |
#2 nullopt (error) |
#6 forwarding | Underspecified |
Now let's analyze these cases one by one.
This is a form of direct-non-list-initialization. In this case, the candidate functions are all the constructors of std::optional<S>
([over.match.ctor]/1). Among these constructors, we can identify the following viable functions and their corresponding implicit conversion sequences (ICS):
optional(const optional&): {} -> optional<S> -> const optional& (1)
optional(optional&&): {} -> optional<S> -> optional&& (2)
template<> explicit optional<>(in_place_t): {} -> in_place_t (3)
template<> explicit(false) optional<S>(S&&): {} -> S -> S&& (4)
The default constructor is not viable because it does not accept any arguments. The nullopt
constructor is not viable because the standard requires that we cannot form an ICS from {}
to nullopt_t
([optional.nullopt]/2).
Among the above viable constructors, we can conclude that the copy constructor and move constructor are better matches than the in_place
constructor and the forwarding constructor, since non-template constructors are considered better than template constructors ([over.match.best.general]/2.4). Between the copy constructor and the move constructor, we choose the latter because ICS (2) is better than ICS (1) ([over.ics.rank]/3.2.3). Therefore, we end up using the move constructor, and performs a default construction of optional<S>
followed by a move. All implementations agree on this behavior. The trailing move cannot be elided under the wording of the standard, although this is probably a defect (see also CWG2311).
This is a form of direct-list-initialization. The candidate functions in this case are, as with o5
, all the constructors of std::optional<S>
([over.match.list]/1.2). One difference in this case is that, when forming ICSs, we no longer consider the copy/move constructors ([over.best.ics.general]/4.5). This restriction is applied to prevent arbitrary nested initializer lists (e.g., S s{{}}
is ill-formed, while S s{S{}}
is fine). With that in mind, we can work out the following viable functions and ICSs:
template<> explicit optional<>(in_place_t): {} -> in_place_t (1)
template<> explicit(false) optional<S>(S&&): {} -> S -> S&& (2)
Between the in_place
constructor and the forwarding constructor, we choose the former since both are function templates and the former is more specialized than the latter ([over.match.best.general]/2.5). So we end up using the in_place
constructor.
But we're not done yet - the default constructor of in_place_t
is (rightfully) marked as explicit
. Thus, this initialization is actually ill-formed, since we cannot implicitly convert {}
to in_place_t
. GCC follows this interpretation and correctly produces the following error:
error: converting to 'std::in_place_t' from initializer list would use explicit constructor 'constexpr std::in_place_t::in_place_t()'
Clang's and MSVC's interpretation is somewhat different here: they take explicit
into consideration when trying to form ICSs, and a candidate function becomes non-viable when its associated ICSs cannot be formed due to the presence of explicit
. This is non-standard behavior. The standard's perspective on this matter is that, for copy-list-initialization, explicit
does not affect the forming of ICSs and thus does not affect overload resolution. In copy-list-initialization, it is only after the best viable function is chosen that the code can be ill-formed if that function is marked as explicit
.3 Clang and MSVC choose the forwarding constructor here, as it is the only viable function.
This is a form of copy-list-initialization. The handling of this case is basically the same as o6
above, with the additional limitation that the final selected constructor cannot be explicit
, as mentioned earlier. Note that two copy-list-initializations occur in the process: one converts {{}}
to std::optional<S>
, and the other converts {}
to in_place_t
as part of the former. Thus, GCC correctly produces an additional error in this case:
error: converting to 'std::optional<S>' from initializer list would use explicit constructor 'constexpr std::optional<_Tp>::optional(std::in_place_t, _Args&& ...) [with _Args = {}; _Tp = S]'
Clang and MSVC choose the forwarding constructor for the same reason as in o6
.
This is a form of direct-non-list initialization. As with o5
, we can write the following viable functions and ICSs:
optional(const optional&): {{}} -> optional<S> -> const optional& (1)
optional(optional&&): {{}} -> optional<S> -> optional&& (2)
Note that the conversion {{}} -> optional<S>
above is the same initialization performed for o7
. In the above viable functions, we haven't considered the nullopt
constructor. While the standard states that we can't form {} -> nullopt_t
, it says nothing about {{}} -> nullopt_t
. It just so happens that both libstdc++ and MS STL implement nullopt_t
in a way that makes this ICS valid.4 Let's see what happens in each case:
-
ICS
{{}} -> nullopt_t
is valid: We have the following additional viable function and ICS:optional(nullopt_t): {{}} -> nullopt_t (3)
In this case, the initialization is ambiguous among these three viable functions: none of them can be considered the best viable function.
-
ICS
{{}} -> nullopt_t
is invalid: In this case, we end up choosing the move constructor and proceed as we do foro7
. The initialization is ill-formed due toexplicit
constructors being used in copy-list-initializations.
GCC used to report this initialization as ambiguous, but after the resolution of PR109247, it implemented a (non-standard) tweak, resulting in the nullopt
constructor being preferred in this case (since the in_place
constructor used in ICSs (1) and (2) is explicit
). After choosing the nullopt
constructor, GCC immediately produces an error because that nullopt_t
constructor is also explicit
in libstdc++:
error: converting to 'std::nullopt_t' from initializer list would use explicit constructor 'constexpr std::nullopt_t::nullopt_t(_Construct)'
With libstdc++, Clang chooses the move constructor since it doesn't consider the nullopt
constructor viable due to its explicit
constructor. With libc++ (for which ICS {{}} -> nullopt_t
is invalid), Clang also chooses the move constructor. MSVC chooses the move constructor as well, due to the explicit
nullopt_t
constructor implemented in MS STL. Both compilers proceed to call the forwarding constructor as discussed in o7
.
If we change std::optional<S>
to std::optional<int>
in the original example, we get a different table:
Initialization | GCC 15.1 | Clang 20.1.0 / MSVC 19.43 | Standard |
---|---|---|---|
std::optional<int> o5({}) |
#6 forwarding | #6 forwarding | #6 forwarding |
std::optional<int> o6{{}} |
#6 forwarding | #6 forwarding | #6 forwarding |
std::optional<int> o7 = {{}} |
#6 forwarding | #6 forwarding | #6 forwarding |
std::optional<int> o8({{}}) |
Ambiguous (#2/#3/#4) | #6 forwarding | Underspecified |
The key factor here is that, with a scalar type such as int
, we get the following ICS for the forwarding constructor:
template<> explicit(false) optional<int>(int&&): {} -> int -> int&&
This is a standard conversion sequence, which is preferred over user-defined conversion sequences for other viable functions, as discussed above. Therefore, in the cases from o5
to o7
, the forwarding constructor is chosen for this reason. In o8
, the GCC tweak does not apply because the forwarding constructor in this case is not explicit
, so it falls back to being ambiguous (as expected).
Note
This problem is more manifest in std::optional
's overloaded operator=
s. Writing code like std::optional<T> o({})
is uncommon, but writing code like o = {}
is (or is expected to be) common.5 This is equivalent to o.operator=({})
. As with constructors, std::optional
has the following assignment operators:
optional& operator=(nullopt_t); // #1 nullopt assignment
optional& operator=(const optional&); // #2 copy assignment
optional& operator=(optional&&); // #3 move assignment
template<class U = T> optional& operator=(U&&); // #4 forwarding assignment
We expect o = {}
to always reset the std::optional<T>
object (i.e., call the move assignment above), regardless of whether T
is a scalar type. To ensure this, the forwarding assignment imposes the additional constraint conjunction_v<is_scalar<T>, is_same<T, decay_t<U>>> == false
([optional.assign]/14). This essentially means:
-
T
is not a scalar type: No special handling is needed since the move assignment will be preferred, as discussed forstd::optional<S> o5({})
above. -
T
is a scalar type: The forwarding assignment is made non-viable if (decayed)6U
is the same asT
, which covers the caseo = {}
, where the default type argument is used. This has the side effect that some cases normally accepted by the forwarding assignment will now go through the move assignment, but the practical effect is the same:std::optional<int> o; o = {}; // calls #3 move assignment o = 0; // calls #3 move assignment o = .0; // calls #4 forwarding assignment o = {0}; // calls #3 move assignment o = {.0}; // calls #3 move assignment
The source of the trouble outlined above is that an initializer list (like {}
) is special: it is not an expression and it has no type.7 This makes initializer lists broadly applicable to many types and can therefore lead to unexpected results when calling overloaded functions. We can turn initializer lists into plain expressions by prefixing them with the type we intend to initialize. For example:
std::optional<S> o5(S{});
std::optional<S> o6{S{}};
std::optional<S> o7 = {S{}};
std::optional<S> o8(std::optional<S>{S{}});
The intent in this code is arguably clearer, and the functions chosen are also what we would intuitively expect.
Be aware of (nested) initializer lists used in overloaded function calls. Things can quickly get messy in corner cases where implementations diverge and the standard doesn't specify behaviors. In such cases, to write readable and portable code, you should consider either avoiding such code or converting initializer lists into expressions early to narrow their possible meanings. For library designers, I suggest carefully crafting the overload set to prevent such cases. As usual, this is the tradeoff between high flexibility and low complexity.
Footnotes
-
This topic is discussed in detail in Barry's post: Getting in trouble with mixed construction. ↩
-
constexpr
,noexcept
, and other constructors are omitted here for brevity, as they are not discussed in this post. ↩ -
This is also confirmed in CWG1228. Clang and MSVC have not implemented this for fifteen years - perhaps never will. ↩
-
P3112 by Brian attempted to fix this issue at the standard level but failed due to the belief that this is more of a QoI issue. ↩
-
The
o = {}
syntax is one of the design goals ofstd::optional
; see N3793. ↩ -
decay_t
(which performs array-to-pointer and function-to-pointer conversions) may not be necessary here;remove_cvref_t
should suffice. ↩ -
Initializer lists are different from
std::initializer_list
; the latter is a standard library template whose specializations can be initialized using the former. ↩