If you have ever captured a reference variable by value in a C++ lambda, you might have run into a baffling behavior: decltype(x) still evaluates to a reference type (like int&), even though you explicitly captured it by copy. This seems to violate the very definition of a capture-by-value.

In this article, we will demystify this behavior by looking at how the C++ standard defines decltype, how lambda captures work under the hood, and how you can get the actual copied type when you need it.

The Mystery Snippet

Consider the following three functions:

#include <type_traits>

// Case 1: Captured by value, but decltype says it is a reference!
void foo() {
    int y = 0;
    int& x = y;
    [x]() {
        static_assert(std::is_same_v<decltype(x), int&>); // Passes!
    }();
}

// Case 2: Captured by value, decltype is a value type
void foo2() {
    int y = 0;
    int x = y;
    [x]() {
        static_assert(std::is_same_v<decltype(x), int>); // Passes!
    }();
}

// Case 3: Init-capture, decltype is a value type
void foo3() {
    int y = 0;
    int& x = y;
    [z = x]() {
        static_assert(std::is_same_v<decltype(z), int>); // Passes!
    }();
}

Why does decltype(x) yield int& in foo(), even though the lambda closure class stores a copy of x (which is an int)? Let's break down why this happens.

1. How decltype Treats Captured Variables

The core of the issue lies in how the C++ standard defines the decltype specifier. When you pass an unparenthesized name (an id-expression) to decltype, it yields the declared type of the entity named by that expression.

According to the C++ standard (specifically regarding lambda expressions and decltype):

  • Inside a lambda, using a captured variable name x refers to the variable in the outer scope, not the member variable of the compiler-generated closure class.
  • Therefore, decltype(x) yields the declared type of the outer variable x.

In foo(), the outer variable x is declared as int&. Thus, decltype(x) is int&. In foo2(), the outer variable x is declared as int, so decltype(x) is int.

2. Why Init-Capture (foo3) Behaves Differently

In foo3(), we use C++14's init-capture syntax: [z = x].

An init-capture does not simply capture an existing variable from the outer scope. Instead, it declares a brand-new variable (a member of the closure class) named z, initialized with the value of x. Because z is a completely new variable of type int, decltype(z) yields int.

3. How to Get the "Real" Copied Type

If you want to inspect the type of the expression as it exists inside the lambda (reflecting the copy), you have two main approaches:

Approach A: Use Parentheses

If you wrap the variable in parentheses, decltype((x)) no longer evaluates the declared type of the entity. Instead, it evaluates the type and value category of the expression (x).

Inside a non-mutable lambda, the member variables are treated as const lvalues. Therefore:

[x]() {
    // Evaluates to const int& because the lambda is non-mutable
    static_assert(std::is_same_v<decltype((x)), const int&>);
}();

[x]() mutable {
    // Evaluates to int& because the lambda is mutable
    static_assert(std::is_same_v<decltype((x)), int&>);
}();

Approach B: Use std::decay_t

If you simply want to strip references and cv-qualifiers to get the underlying value type, std::decay_t is the cleanest and most robust solution:

[x]() {
    using CopiedType = std::decay_t<decltype(x)>; 
    static_assert(std::is_same_v<CopiedType, int>); // Always passes!
}();

Summary

  • decltype(x) (unparenthesized) yields the type of the outer variable x, regardless of how it was captured.
  • decltype((x)) (parenthesized) yields the type of the expression, reflecting the constness and reference status inside the lambda.
  • Init-captures (like [z = x]) introduce a new variable, meaning decltype(z) will inspect the newly created member type directly.