Using decltype with Trailing Return Types in C++
In C++, combining the decltype specifier with trailing
return type syntax allows developers to determine and enforce the exact
return type of a function template based on its input expressions.
Introduced in C++11, this pattern solves fundamental scoping limitations
where function parameters could not be referenced before their
declaration. This article explains how decltype operates
within trailing return types, why it is critical for preserving
references and cv-qualifiers, and its role in template metaprogramming
and SFINAE.
The Scoping Problem in Traditional Syntax
In traditional C++ function declarations, the return type precedes the function name and its parameter list:
template <typename T, typename U>
decltype(lhs + rhs) add(const T& lhs, const U& rhs); // Compilation ErrorThis code fails to compile because compilers parse declarations from
left to right. When the compiler encounters
decltype(lhs + rhs), the identifiers lhs and
rhs have not yet been declared, making them inaccessible
within that scope.
Using
decltype(std::declval<T>() + std::declval<U>())
was a workaround, but it introduced unnecessary verbosity and
complexity.
How Trailing Return Syntax Resolves Scope
Trailing return type syntax moves the return type to the end of the
function signature, using the auto keyword as a placeholder
at the beginning:
auto function_name(parameter_list) -> return_type;Because the parameters are parsed before the trailing return type, they exist in the local scope when the return type is evaluated:
template <typename T, typename U>
auto add(const T& lhs, const U& rhs) -> decltype(lhs + rhs) {
return lhs + rhs;
}In this structure, lhs and rhs are valid
identifiers inside decltype(...), enabling the compiler to
inspect the expression and deduce the precise resulting type.
Preserving Value Categories and Exact Types
A fundamental strength of decltype is that it inspects
the declared type or the value category of an expression without
evaluating it at runtime:
- Id-expressions: If the expression inside
decltypeis an unparenthesized variable or member access, it yields the exact declared type. - Lvalues: If an evaluated expression yields an
lvalue of type
T,decltypeyieldsT&. - Xvalues: If the expression yields an rvalue
reference,
decltypeyieldsT&&. - Prvalues: If the expression yields a temporary
object,
decltypeyieldsT.
Unlike regular auto deduction, which decays types by
stripping references and top-level
const/volatile qualifiers,
decltype preserves these modifiers completely. This is
vital when writing generic wrapper functions or container accessors:
template <typename Container>
auto get_element(Container& c, std::size_t index) -> decltype(c[index]) {
return c[index];
}If c[index] returns an lvalue reference (such as
int&), decltype(c[index]) evaluates to
int&, allowing callers to modify elements directly
through the returned reference.
Enabling SFINAE via Trailing Return Types
The expression inside decltype is part of the function
signature. If template substitution produces an invalid expression
inside decltype, substitution fails, but it does not
produce a hard compilation error. Instead, the overload is removed from
the overload set according to the SFINAE (Substitution Failure Is Not An
Error) rule:
template <typename T>
auto serialize(const T& obj) -> decltype(obj.serialize()) {
return obj.serialize();
}If a type T does not have a .serialize()
member function, the substitution fails gracefully, allowing the
compiler to search for other viable overloads.
Trailing Return vs. C++14 Return Type Deduction
C++14 introduced automatic return type deduction, allowing functions
to simply declare auto or decltype(auto) as
the return type without trailing syntax:
template <typename T, typename U>
auto add(const T& lhs, const U& rhs) {
return lhs + rhs; // Deduces decayed type
}
template <typename Container>
decltype(auto) get_element(Container& c, std::size_t index) {
return c[index]; // Preserves references
}While decltype(auto) simplifies standard
implementations, explicit trailing return types with
decltype remain necessary when:
- Applying SFINAE constraints in function signatures prior to C++20 concepts.
- Defining virtual member functions, where return types must be fully defined in the interface.
- Exposing the precise interface and return contract in header files without forcing the compiler to parse the function body upfront.