Unevaluated Contexts in C++
An unevaluated operand is an expression that the compiler analyzes for its type and static properties but never executes at runtime. No side effects occur, no functions are called, no objects are constructed. The standard defines these in [expr.context].
But what do we mean exactly when we say never executes at runtime, i.e. not actually executing? Consider a situation where you need to determine the return type of calling a member function on some type T, but you don’t have a value of T available, and you don’t even know if T is constructible. For example, suppose you want to write a type alias that extracts the return type of T::foo():
template <typename T>
using foo_result_t = decltype(/* somehow call T().foo() */);
The naive attempt would be:
template <typename T>
using foo_result_t = decltype(T().foo());
This fails if T has no default constructor. Maybe T’s constructor requires arguments you don’t know. Maybe T is abstract. Maybe T’s constructor is deleted. The T() subexpression is the problem: you need an instance of T to call .foo() on, but constructing one is either impossible or irrelevant to what you’re trying to do.
std::declval solves this. It gives you a way to “conjure”1 a reference to T without constructing anything:
template <typename T>
using foo_result_t = decltype(std::declval<T>().foo());
This compiles regardless of whether T is constructible, because declval<T>() never actually runs. And that is what “never executes at runtime” means: when the compiler sees decltype(std::declval<T>().foo()), it does not generate code to call declval<T>(), then call .foo() on the result. It only performs overload resolution and type deduction to determine what type the expression would produce if it were evaluated. The compiler figures out: “declval<T>() returns T&&, calling .foo() on a T&& yields int (or whatever), so the type is int”. So no function is called, no object is created, and no code is emitted; pretty cool.
The following are the unevaluated contexts in C++, as of C++23.
| Context | Since | What happens |
|---|---|---|
sizeof(expr) |
C++98 | Yields the size of the expression’s type. Expression not evaluated. |
typeid(expr) |
C++98 | Yields std::type_info. Unevaluated only for non-polymorphic types (see below). |
decltype(expr) |
C++11 | Yields the type and value category of the expression. Expression not evaluated. |
noexcept(expr) |
C++11 | Yields true if the expression is declared non-throwing. Expression not evaluated. |
requires-expression body |
C++20 | Checks whether expressions are well-formed. Not evaluated. |
In the example below, the expression text is parsed and type-checked, but nothing runs:
int x = 0;
sizeof(x++); // x is still 0
sizeof(printf("hello")); // nothing is printed
Naming an entity in an unevaluated context does not constitute an ODR-use (a use that requires a definition to exist). This means you can reference functions that are declared but never defined. This is exactly why std::declval from the example above works: it is declared as T&& declval() noexcept; but intentionally never defined, and that is fine because it only ever appears inside decltype.
// declared, never defined and that's fine
int classify(int);
double classify(double);
// only used inside decltype, so no linker error
using result_t = decltype(classify(42)); // result_t is int
typeid is the only unevaluated context with a conditional evaluation rule. When its operand is a glvalue of a polymorphic type (a class with at least one virtual function), the expression is evaluated at runtime to determine the dynamic type via the vtable:
struct Base { virtual ~Base() = default; };
struct Derived : Base {};
Derived d;
Base& ref = d;
typeid(ref); // evaluated at runtime and yields Derived's type_info
Base* p = nullptr;
typeid(*p); // evaluated and throws std::bad_typeid!
For non-polymorphic types, typeid uses the static type and the operand remains unevaluated:
struct Plain { int x; };
Plain* p = nullptr;
typeid(*p); // NOT evaluated, no null dereference, uses static type
References
- [expr.context], C++ standard working draft.
- std::declval, cppreference.
- typeid, cppreference.
-
I’m using the word conjure here just to mean it doesn’t produce an actual value or object, i.e.: “conjure” here just means it gives the compiler something to reason about, a hypothetical value of type
T&&, without any constructor being called or any memory being touched. The compiler seesdeclval<T>()and treats it as “okay, I have aT&&here” for the purpose of type deduction, even though nothing was created. ↩︎