Why C++ Ellipsis Accepts Deleted Move Constructors But auto Fails
The Curious Case of Deleted Move Constructors in C++
In C++, you might occasionally encounter compiler behaviors that seem to defy logic. One classic example involves passing an object with a deleted move constructor to a function. Depending on whether the function uses a C-style ellipsis (...) or a C++20 abbreviated template (auto), the compiler will either happily compile the code or throw a hard error.
Let's look at the code snippet that demonstrates this puzzle:
struct NoMove {
NoMove(NoMove&&) = delete;
};
NoMove&& DeclVal();
void F1(...);
void F2(auto);
using Type1 = decltype(F1(DeclVal())); // #1: This compiles!
using Type2 = decltype(F2(DeclVal())); // #2: This fails to compile!
Why does Type1 compile while Type2 fails? And why would both compile if DeclVal() returned a prvalue (NoMove) instead of an xvalue (NoMove&&)? Let's dive into the C++ standard to unpack exactly what is happening under the hood.
1. Why F2(auto) Fails: Template Deduction and Parameter Initialization
In C++20, void F2(auto) is an abbreviated function template. It is completely equivalent to:
template <typename T>
void F2(T);
When you call F2(DeclVal()), the compiler must perform template argument deduction:
DeclVal()returns an rvalue reference (an xvalue) of typeNoMove&&.- For a value parameter
T, the deduced type is stripped of references, soTis deduced asNoMove. - The function signature is instantiated as
void F2(NoMove).
To call F2(NoMove) with an argument of type NoMove&&, the compiler must initialize the function's parameter. This initialization requires overload resolution to find an appropriate constructor. Because the argument is an rvalue (specifically, an xvalue), overload resolution selects the move constructor: NoMove(NoMove&&).
However, because NoMove(NoMove&&) is explicitly = delete, the program is ill-formed. Even inside an unevaluated context like decltype, the compiler must still perform template instantiation and overload resolution. Since it resolves to a deleted function, compilation fails.
2. Why F1(...) Compiles: The Magic of C-Style Ellipsis
The C-style ellipsis (...) operates under entirely different rules. Unlike F2, F1(...) has no declared parameter types. Therefore, no parameter initialization occurs at the call site in the traditional C++ sense.
According to the C++ standard, when passing arguments to an ellipsis:
- The arguments undergo standard lvalue-to-rvalue, array-to-pointer, and function-to-pointer conversions.
- Passing a class type with a non-trivial copy/move constructor or destructor is conditionally-supported with implementation-defined semantics.
Crucially, because decltype(F1(DeclVal())) is an unevaluated operand, the compiler does not actually generate code to pass the argument. It only needs to determine the return type of F1, which is void. Since there is no parameter of type NoMove to initialize, the compiler never looks up or invokes NoMove's move constructor. Thus, the deleted constructor is never "designated," and the code compiles without issue.
3. Why Both Compile with a prvalue
The author of the question noted that if DeclVal() returned a prvalue (i.e., NoMove instead of NoMove&&), both lines would compile. Why is this?
This behavior is a direct result of guaranteed copy elision (mandated since C++17):
- When a prvalue is passed as an argument to a function parameter of the same type (like passing
NoMovetoF2(NoMove)), the prvalue directly initializes the parameter. - Under C++17 rules, this initialization does not involve temporary materialization. No copy or move constructor is called—or even selected—during this process.
Because the compiler bypasses constructor overload resolution entirely, the deleted move constructor is never triggered, allowing F2(DeclVal()) to compile successfully.
Summary Table
| Argument Category | Ellipsis F1(...) |
Abbreviated Template F2(auto) |
|---|---|---|
xvalue (NoMove&&) |
Compiles (No parameter initialization/overload resolution needed in unevaluated context) | Fails (Attempts to resolve deleted move constructor NoMove(NoMove&&)) |
prvalue (NoMove) |
Compiles (No constructor lookup needed) | Compiles (Guaranteed copy elision avoids constructor lookup entirely) |
Conclusion
This subtle difference highlights how C++ handles unevaluated contexts, template deduction, and C-style variadics. While auto forces the compiler to instantiate a typed function signature and resolve parameter initialization, the C-style ellipsis bypasses these type-safety checks entirely—especially when wrapped inside decltype.