Skip to content

Instantly share code, notes, and snippets.

@ckwastra
Last active August 16, 2025 08:00
Show Gist options
  • Save ckwastra/b92858e99a55bc822471cfb42b5f0f2e to your computer and use it in GitHub Desktop.
Save ckwastra/b92858e99a55bc822471cfb42b5f0f2e to your computer and use it in GitHub Desktop.

Corner cases in std::optional initialization

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).

Introduction

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.

std::optional<S> o5({})

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).

std::optional<S> o6{{}}

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.

std::optional<S> o7 = {{}}

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.

std::optional<S> o8({{}})

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 for o7. The initialization is ill-formed due to explicit 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.

Scalar types affect overload resolution

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 for std::optional<S> o5({}) above.

  • T is a scalar type: The forwarding assignment is made non-viable if (decayed)6 U is the same as T, which covers the case o = {}, 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 solution

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.

Conclusion

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

  1. This topic is discussed in detail in Barry's post: Getting in trouble with mixed construction.

  2. constexpr, noexcept, and other constructors are omitted here for brevity, as they are not discussed in this post.

  3. This is also confirmed in CWG1228. Clang and MSVC have not implemented this for fifteen years - perhaps never will.

  4. 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.

  5. The o = {} syntax is one of the design goals of std::optional; see N3793.

  6. decay_t (which performs array-to-pointer and function-to-pointer conversions) may not be necessary here; remove_cvref_t should suffice.

  7. Initializer lists are different from std::initializer_list; the latter is a standard library template whose specializations can be initialized using the former.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment