Why Does decltype(expr) Compile When expr Itself Fails to Compile in C++?
Have you ever encountered a situation in C++ where a specific expression fails to compile on its own, yet wrapping that exact same expression inside decltype() compiles perfectly? This seemingly paradoxical behavior is a common source of confusion, especially when writing template metaprogramming traits or SFINAE (Substitution Failure Is Not An Error) code.
In this article, we will dissect why this happens, look at the mechanics of C++ unevaluated contexts, and see how to write robust type traits for detecting template instantiations.
The Paradox: Evaluated vs. Unevaluated Contexts
Let's look at the classic example. We want to detect if a class is an instantiation of Vector<T> or derived from it. We set up two overloads of a helper function:
template <class T>
std::true_type DetectVector(const Vector<T>&);
std::false_type DetectVector(...);
We then test it with a non-copyable, non-movable class:
struct NoMove {
NoMove(NoMove&&) = delete;
};
NoMove&& DeclVal();
// 1. This fails to compile:
// DetectVector(DeclVal());
// 2. But this compiles perfectly!
using FalseType = decltype(DetectVector(DeclVal()));
static_assert(FalseType::value == false);
Why does the compiler complain about the deleted move constructor in the first case, but silently succeed in the second?
The Explanation
The key to understanding this behavior lies in two C++ rules: Unevaluated Contexts and the rules surrounding variadic arguments (...).
1. What is an Unevaluated Context?
In C++, operators like decltype, sizeof, noexcept, and typeid (in most cases) create an unevaluated context. When the compiler processes an expression inside an unevaluated context:
- It only determines the type or properties of the expression.
- It does not generate any executable code for the expression.
- No temporary objects are actually constructed, copied, moved, or destroyed.
Because no code is generated, the compiler only performs overload resolution to find which function would be called and what its return type is. It stops right there.
2. The Problem with Variadic Parameters (...) in Evaluated Contexts
When you call DetectVector(DeclVal()) in an evaluated context, overload resolution selects the variadic fallback DetectVector(...) because NoMove cannot be converted to const Vector<T>&.
However, passing a class type to a variadic function (...) requires the compiler to actually generate code to pass that object by value. According to the C++ standard, passing a non-trivially copyable class (or a class with a deleted copy/move constructor) to a variadic function is either conditionally-supported with implementation-defined behavior, or an outright compilation error. Because the compiler must generate code for this call in an evaluated context, it triggers a hard error.
3. Why decltype Bypasses This Error
In decltype(DetectVector(DeclVal())), the compiler does the following:
- Performs overload resolution. It matches
DetectVector(...). - Determines the return type of
DetectVector(...), which isstd::false_type. - Since it's an unevaluated context, the compiler's job is done. It never generates the assembly instructions to actually pass the
NoMoveobject into the variadic parameter list. Thus, the semantic constraints of actually passing a non-copyable object to...are never evaluated or violated.
A Modern, Robust Solution
While the SFINAE trick with decltype works, relying on variadic parameter fallbacks (...) for non-copyable types can sometimes feel like walking on thin ice. In modern C++, we can write cleaner, safer type traits.
Using C++20 Concepts
If you are using C++20, you can implement this detection cleanly using concepts and template specialization, avoiding variadic functions entirely:
#include <type_traits>
template <typename>
struct Vector {};
// Helper trait to check if a type is a template instantiation of Vector
template <typename T>
struct IsVectorHelper : std::false_type {};
template <typename T>
struct IsVectorHelper<Vector<T>> : std::true_type {};
// Concept to detect if a type is Vector or derived from it
template <typename T>
concept IsVector = requires(T& t) {
[]<typename U>(const Vector<U>&){}(t);
};
This approach uses a generic lambda inside a requires clause to safely test whether a reference to T can be implicitly converted to a reference to const Vector<U>& for some U, providing a clean, modern, and highly readable solution without any unevaluated-context surprises.