C++14 Init Capture: Moving unique_ptr into Lambdas, Reference Init and Evaluation Time

Key takeaways

Init capture ([name = expr], C++14) adds a closure member initialized from any expression. This article covers how its type is deduced, when the expression is evaluated, moving move-only objects into a lambda and the consequences (non-mutable body, std::function rejection), reference init capture, [*this] and C++20 pack init capture, with g++ 10.3 diagnostics.

C++11 lambdas can capture a variable by copy ([x]) or by reference ([&x]), and that is all. There is no way to move an object into the closure, and no way to store a value that is computed from something else. C++14’s init capture (also called generalized lambda capture) closes both gaps: [name = expression] declares a new member of the closure and initializes it from any expression.

The broader picture of capture modes, this capture and dangling references is in C++ lambda capture. This article goes deeper on init capture alone: the rules that decide what you actually get, and the places where it surprises people. All examples were compiled with g++ 10.3; outputs are what they printed.


The rules in one place

[x = expr]        // member x, type deduced like: auto x = expr;
[&r = expr]       // member r, a reference, like: auto& r = expr;
[...xs = args]    // C++20: one member per pack element (also [&...xs = args])

Three consequences follow from “like auto”:

  1. References and top-level const are dropped. [s = cs] where cs is a const std::string gives a plain std::string member, so a mutable lambda can modify its copy. A plain [cs] capture would keep the const.
  2. The name is new. The name on the left lives only inside the lambda body. The expression on the right is looked up in the enclosing scope, so [x = x + 1] is legal and means “a new x initialized from the outer x plus one”. The outer variable is untouched.
  3. The expression is evaluated once, when the lambda is created. Not on each call.
int counter = 0;
auto f = [snap = counter, &live = counter] {
    std::cout << "snap=" << snap << " live=" << live << "\n";
};
counter = 5;
f();

int x = 1;
auto sh = [x = x + 1] { return x; };
std::cout << "sh()=" << sh() << " outer x=" << x << "\n";
snap=0 live=5
sh()=2 outer x=1

Evaluation time matters most when the initializer has side effects or depends on time. [start = std::chrono::steady_clock::now()] records when the callback was created, which is correct for measuring “how long until this callback ran” and wrong if you meant “when it ran”. [id = next_id++] increments once per closure created, not once per call. If a lambda has several init captures, do not make them depend on each other’s side effects: each is initialized in the order the closure’s members are declared, and the standard leaves that order unspecified.


Moving objects into the closure

This is the use that justified adding init capture to the language:

void consume(std::unique_ptr<int> p) { std::cout << "consumed " << *p << "\n"; }

auto p = std::make_unique<int>(42);
auto once = [p = std::move(p)]() mutable {
    if (p) consume(std::move(p));
    else   std::cout << "already consumed\n";
};
std::cout << "outer p null after move-capture: " << (p == nullptr) << "\n";
once();
once();
outer p null after move-capture: 1
consumed 42
already consumed

Ownership moves into the closure at the moment the lambda is created, so the outer p is null from that line on, before the lambda is ever called. Code that still reads the outer p afterwards is a use-after-move. It usually shows up as a null dereference several lines below the capture, which is confusing because the capture line looks like it only “mentions” p.

The same pattern applies to anything expensive or move-only: a std::vector buffer handed to a worker thread, a socket or file handle, a std::promise. For large containers, [v = std::move(v)] avoids a full copy that a plain [v] capture would make.

Moving back out requires mutable

The closure’s operator() is const unless the lambda is declared mutable. Inside a non-mutable body, the captured p is a const std::unique_ptr<int>, and std::move on a const object produces const std::unique_ptr<int>&&, which cannot bind to the move constructor and falls back to the deleted copy constructor:

auto l = [p = std::move(p)] { consume(std::move(p)); };   // no mutable
error: use of deleted function 'std::unique_ptr<_Tp, _Dp>::unique_ptr(const std::unique_ptr<_Tp, _Dp>&) [with _Tp = int; _Dp = std::default_delete<int>]'

The message mentions the copy constructor even though the code says std::move, which is what makes it confusing the first time. Adding mutable fixes it. Keep in mind what “move out” implies: after the first call, the member is empty. A lambda that consumes its capture is a one-shot callable, and the if (p) guard above is how you make a second call harmless instead of a null dereference.


Move-only closures and std::function

A closure type is copyable only if all its members are. Capturing a unique_ptr makes the lambda move-only, and std::function requires a copyable target:

auto m = [p = std::move(p)] { return *p; };
std::function<int()> fn = std::move(m);   // error, even with std::move
error: use of deleted function 'main()::<lambda()>::<lambda>(const main()::<lambda()>&)'
error: use of deleted function 'std::unique_ptr<_Tp, _Dp>::unique_ptr(const std::unique_ptr<_Tp, _Dp>&) [with _Tp = int; _Dp = std::default_delete<int>]'

Moving the lambda into std::function does not help, because std::function itself is copyable and must be able to copy whatever it holds. This is the most common wall people hit with init capture, usually when pushing callbacks into a task queue declared as std::vector<std::function<void()>>. I have hit it in exactly that shape: a work queue that had always stored std::function, and the first task that needed to own a buffer instead of sharing it would not compile. The options, from cleanest to most pragmatic:

  • C++23 std::move_only_function is the direct replacement for queues and callback slots that never need to copy.

  • Keep the concrete type: accept the callable as a template parameter (template<class F> void post(F&& f)), or store it where the type is known. std::thread, std::async and std::packaged_task all accept move-only callables:

    auto buf = std::make_unique<std::vector<int>>(3, 7);
    std::thread t([buf = std::move(buf)] { std::cout << "thread sees " << buf->size() << " items\n"; });
  • Wrap it in a shared_ptr when you are stuck with std::function. The wrapper is copyable; copies share the one closure:

    auto task = [p = std::move(owned)] { return *p; };
    std::function<int()> fn =
        [sp = std::make_shared<decltype(task)>(std::move(task))] { return (*sp)(); };

    This costs an allocation and gives up unique ownership (every copy of fn refers to the same task), but it works on any C++14 compiler.

The reverse problem is quieter: a lambda with a copyable but large member, say [v = std::vector<int>(1'000'000)], is accepted by std::function, and every copy of that std::function deep-copies the vector. Passing callbacks around by value then copies megabytes without anything looking expensive in the source.


Reference init capture: [&r = expr]

[&r = expr] makes r a reference bound to the result of expr. Two uses stand out:

std::vector<int> data(1000);
auto byref = [&v = std::as_const(data)] { return v.size(); };   // read-only view
  • A const view of mutable state. std::as_const gives a const&, so the body cannot modify data even though the outer variable is non-const. Plain [&data] cannot express that.
  • A short alias for a nested member, e.g. [&cfg = app.settings().network], so the body does not have to spell out the path.

It binds like auto&, so it cannot bind to a temporary:

auto r = [&s = std::string("tmp")] { return s.size(); };
error: cannot capture 'std::__cxx11::basic_string<char>(((const char*)"tmp"), std::allocator<char>())' by reference

That rejection is helpful, because a reference to a temporary would dangle immediately. What the compiler cannot catch is the ordinary lifetime problem every reference capture has: [&r = local] inside a function that returns or stores the lambda leaves r referring to a destroyed object. Reference init capture is for lambdas that are called before the referenced object goes out of scope, such as algorithm predicates and synchronous callbacks. For anything that escapes, copy or move instead. More on that class of bug in dangling lambda captures.


Members, this and [*this]

Data members are not local variables, so [name] cannot capture them directly; the lambda captures this and reads this->name. Init capture is the precise tool when you only need a snapshot of one or two members:

struct Job {
    std::string name = "job-1";
    auto by_pointer() { return [this]  { return name; }; }   // refers to the live object
    auto by_copy()    { return [*this] { return name; }; }   // C++17: copies the whole Job
    auto by_member()  { return [n = name] { return n; }; }   // copies just the string
};

std::function<std::string()> a, b, c;
{
    Job j;
    a = j.by_copy(); b = j.by_member(); c = j.by_pointer();
    j.name = "renamed";
    std::cout << "inside scope: copy=" << a() << " member=" << b() << " pointer=" << c() << "\n";
}
std::cout << "after scope: copy=" << a() << " member=" << b() << "\n";
// calling c() here would read a destroyed Job
inside scope: copy=job-1 member=job-1 pointer=renamed
after scope: copy=job-1 member=job-1

[n = name] copies exactly what the body needs, is available since C++14, and makes the dependency visible in the capture list. [*this] copies the entire object, which is convenient but can be expensive and does not compile for non-copyable classes. For callbacks that need the live object and may outlive it, capture a weak_ptr: [weak = weak_from_this()] (with std::enable_shared_from_this), then lock() it in the body. Capturing [self = shared_from_this()] also works but keeps the object alive until the callback is destroyed, which turns into a leak if the object itself stores the callback.

The mistake I see most in async code is the in-between version: [this] captured in a callback registered with a timer or a network library, where the object is destroyed before the callback fires. The code worked for months because the object usually outlived the timer, and the crash only appears when shutdown order changes. Switching to [n = name] for data or a weak_ptr for the object makes the lifetime explicit instead of accidental.


Capturing parameter packs (C++20)

C++20 allows an init capture with a pack expansion, which is how you store forwarded arguments for later:

template<typename F, typename... Args>
auto defer(F f, Args&&... args) {
    return [f = std::move(f), ...args = std::forward<Args>(args)]() mutable {
        return f(args...);
    };
}

std::string msg = "hello";
auto d = defer([](const std::string& s, int n) { return s + " x" + std::to_string(n); }, msg, 3);
msg = "changed";
std::cout << d() << "\n";   // hello x3

Because init capture deduces like auto, each element is stored by value: lvalue arguments are copied, rvalue arguments are moved, which is usually what “call this later” needs. msg was copied at capture time, so the later change does not affect the result. (The forwarding half of this is covered in std::forward and perfect forwarding.) g++ 10 in C++17 mode accepts this with warning: pack init-capture only available with '-std=c++2a'; do not rely on that. The portable C++17 form stores a tuple:

return [f = std::move(f), tup = std::make_tuple(std::forward<Args>(args)...)]() mutable {
    return std::apply(f, tup);
};