[2026] C++ Init-Capture — C++14 [x = expr], Move, and unique_ptr Patterns
이 글의 핵심
This article explains the difference between C++11 simple capture and C++14 init-capture ([x = expr]), move capture and passing unique_ptr, practical examples, and common mistakes (lifetime and duplicate names).
What is init-capture?
Since C++14, the lambda capture list may use name = expression. This is init-capture (also called generalized capture). It creates a member with the given name inside the closure object and initializes it with the result of the right-hand expression.
Basic syntax and mechanics
int factor = 10;
auto f = [factor = factor * 2]() { return factor; }; // member factor is initialized to 20
std::cout << f() << std::endl; // prints: 20
std::cout << factor << std::endl; // prints: 10 (outer variable unchanged)
How it works internally:
Init-capture makes the compiler generate a closure class roughly like this:
// Approximate closure class generated by the compiler
class __lambda_closure {
private:
int factor; // captured member variable
public:
__lambda_closure(int init_factor) : factor(init_factor) {}
auto operator()() const {
return factor; // use member variable
}
};
// Actual lambda construction
int factor = 10;
__lambda_closure f(factor * 2); // factor * 2 is evaluated and passed to the constructor
Important points:
- The left-hand name in the capture list (
factor) is the closure member name. - The right-hand expression (
factor * 2) is evaluated immediately when the lambda is defined. - The outer variable (
factor) and the captured member (factor) are different objects (same name, different entities).
Why init-capture matters
In C++11, you could only copy or reference outer names with [x] / [&x]—you could not initialize a capture from an arbitrary expression at capture time (you had to introduce a separate local variable).
Practical pain points:
// Problem 1: want a transformed value (C++11)
int celsius = 25;
int fahrenheit = celsius * 9 / 5 + 32; // extra local needed
auto print_temp = [fahrenheit]() {
std::cout << fahrenheit << "°F" << std::endl;
};
// Problem 2: repeated pattern across lambdas
int value = 100;
int doubled = value * 2;
auto f1 = [doubled]() { return doubled; };
int tripled = value * 3;
auto f2 = [tripled]() { return tripled; };
// C++14: more concise
auto print_temp = [f = celsius * 9 / 5 + 32]() {
std::cout << f << "°F" << std::endl;
};
auto f1 = [doubled = value * 2]() { return doubled; };
auto f2 = [tripled = value * 3]() { return tripled; };
Benefits in practice:
- Conciseness: fewer temporaries.
- Clear intent: the capture list shows what value is stored.
- Less scope pollution: no extra names outside the lambda.
- Move semantics: supports move-only types like
unique_ptr.
C++11 capture vs C++14 init-capture
C++11: default captures
| Syntax | Meaning |
|---|---|
[x] | Copy outer x |
[&x] | Reference outer x |
[=] | Default copy capture |
[&] | Default reference capture |
Limitation: to put only 2 * x into the closure, C++11 needs a temporary variable.
int x = 5;
int doubled = x * 2;
auto f = [doubled]() { return doubled; };
C++14: one line with init-capture
int x = 5;
auto f = [value = x * 2]() { return value; };
The name in the capture list is scoped inside the closure; the expression to the right of = is evaluated when the lambda is defined.
Summary
- C++11: only direct copy/reference of outer names.
- C++14: store the result of any expression under a new name (copy, move, or temporary construction).
Move capture patterns
Move-only resources (e.g. unique_ptr, thread, some handles) cannot use copy capture [ptr], and reference capture [&ptr] often causes lifetime bugs. Init-capture moves ownership into the closure.
Basic move capture
auto ptr = std::make_unique<int>(42);
auto work = [p = std::move(ptr)]() {
// p is a unique_ptr member; outer ptr is empty
std::cout << "value: " << *p << std::endl;
return *p;
};
work(); // OK
// Note: ptr is now nullptr
if (ptr == nullptr) {
std::cout << "ptr was moved and is empty" << std::endl;
}
// Using ptr again is undefined behavior!
// *ptr; // crash!
What happens inside:
// Conceptual closure class
class __lambda_move {
private:
std::unique_ptr<int> p; // holds moved unique_ptr
public:
__lambda_move(std::unique_ptr<int>&& init_p)
: p(std::move(init_p)) {}
auto operator()() const {
std::cout << "value: " << *p << std::endl;
return *p;
}
};
auto ptr = std::make_unique<int>(42);
__lambda_move work(std::move(ptr)); // ownership moves into the closure
Why move? (copy vs reference vs move)
// Problem 1: copy capture — compile error
auto ptr = std::make_unique<int>(42);
// auto bad = [ptr]() { return *ptr; };
// error: unique_ptr copy ctor is deleted
// Problem 2: reference capture — dangling risk
auto make_dangerous_lambda() {
auto ptr = std::make_unique<int>(42);
return [&ptr]() { return *ptr; }; // dangerous!
}
// ptr is destroyed when the function returns → dangling reference
// Fix: move capture — safe ownership transfer
auto make_safe_lambda() {
auto ptr = std::make_unique<int>(42);
return [p = std::move(ptr)]() { return *p; }; // safe
}
// the lambda owns ptr
Practical scenario: async work
// Bad: reference + async
void bad_async_example() {
std::vector<int> data = load_large_data(); // e.g. 1GB
// Danger: data is destroyed when the function returns
std::async(std::launch::async, [&data]() {
process_data(data); // dangling reference!
});
} // data destroyed → async task may crash
// Good: move capture
void good_async_example() {
std::vector<int> data = load_large_data();
std::async(std::launch::async, [data = std::move(data)]() {
process_data(data); // safe
});
} // original data is empty but the lambda owns the buffer
Naming: new name vs same name
Pattern 1: new name (often clearer)
auto ptr = std::make_unique<int>(42);
auto work = [p = std::move(ptr)]() { // p is a new name
return *p;
};
// Benefits:
// - Outer ptr vs captured p are clearly distinct
// - Reduces mistakes using ptr after the move
// - Easier code review
Pattern 2: same name (requires care)
auto ptr = std::make_unique<int>(42);
auto work = [ptr = std::move(ptr)]() { // same name ptr
return *ptr;
};
// Notes:
// - Left ptr: closure member
// - Right ptr: outer variable
// - Shadowing: outer ptr is nullptr after move but names look alike
Practical recommendations:
// 1. Short scopes: prefer a new name
{
auto ptr = std::make_unique<Resource>();
auto task = [res = std::move(ptr)]() {
res->use();
};
}
// 2. Multiple moves: use a prefix/suffix
{
auto conn = std::make_unique<Connection>();
auto cache = std::make_unique<Cache>();
auto worker = [
conn_ = std::move(conn),
cache_ = std::move(cache)
]() {
conn_->query();
cache_->store();
};
}
Move capture for large containers
// Example: large vector
std::vector<int> generate_data() {
std::vector<int> v(10'000'000); // ~40MB
std::iota(v.begin(), v.end(), 0);
return v;
}
void process_in_thread() {
auto data = generate_data();
// Bad: copy capture (~40MB copy)
// std::thread t([data]() {
// for (int x : data) { /* ... */ }
// });
// Good: move capture (pointer swap only)
std::thread t([vec = std::move(data)]() {
for (int x : vec) {
// use original buffer without an extra copy
}
});
t.detach();
// data is empty; the thread owns the buffer
}
Performance comparison:
#include <chrono>
void benchmark_copy_vs_move() {
std::vector<int> large_data(10'000'000);
// Copy capture (slow)
auto start = std::chrono::high_resolution_clock::now();
auto copy_lambda = [data = large_data]() { // copy
return data.size();
};
auto end = std::chrono::high_resolution_clock::now();
auto copy_time = std::chrono::duration_cast<std::chrono::microseconds>(end - start);
// Move capture (fast)
start = std::chrono::high_resolution_clock::now();
auto move_lambda = [data = std::move(large_data)]() { // move
return data.size();
};
end = std::chrono::high_resolution_clock::now();
auto move_time = std::chrono::duration_cast<std::chrono::microseconds>(end - start);
std::cout << "copy: " << copy_time.count() << " μs\n";
std::cout << "move: " << move_time.count() << " μs\n";
std::cout << "speedup: " << (copy_time.count() / move_time.count()) << "x\n";
}
This is common when passing lambdas to std::async or std::thread so large containers are not copied.
unique_ptr capture in depth
This is the usual pattern when the lambda should own the resource. unique_ptr is not copyable, so you must move it with init-capture.
Basic pattern
class Resource {
public:
Resource(int id) : id_(id) {
std::cout << "Resource " << id_ << " constructed\n";
}
~Resource() {
std::cout << "Resource " << id_ << " destroyed\n";
}
void process() {
std::cout << "Resource " << id_ << " processing\n";
}
private:
int id_;
};
void thread_example() {
auto resource = std::make_unique<Resource>(1);
std::thread t([res = std::move(resource)]() {
res->process();
// res is destroyed when the thread finishes
});
t.join();
if (resource == nullptr) {
std::cout << "original resource is empty\n";
}
}
// Output:
// Resource 1 constructed
// Resource 1 processing
// Resource 1 destroyed
// original resource is empty
Pitfall 1: do not use the source after move
void dangerous_pattern() {
auto ptr = std::make_unique<int>(42);
auto task = [p = std::move(ptr)]() {
return *p;
};
// Dangerous: ptr was already moved
// if (*ptr > 0) { } // undefined behavior
if (ptr != nullptr) {
std::cout << *ptr << std::endl;
} else {
std::cout << "ptr is empty after move\n";
}
}
Compiler warnings:
auto ptr = std::make_unique<int>(42);
auto task = [p = std::move(ptr)]() {
return *p;
};
auto value = *ptr; // warning: use-after-move
Pitfall 2: the lambda may be move-only
void copy_lambda_issue() {
auto ptr = std::make_unique<int>(42);
auto task = [p = std::move(ptr)]() {
return *p;
};
// Error: cannot copy the lambda
// auto task2 = task;
auto task2 = std::move(task); // OK
// task is now invalid
// task(); // undefined behavior
}
Interaction with std::function:
#include <functional>
void function_wrapper_issue() {
auto ptr = std::make_unique<int>(42);
// Error: std::function requires a copyable callable
// std::function<int()> fn = [p = std::move(ptr)]() {
// return *p;
// };
// Fix 1: shared_ptr
auto shared = std::make_shared<int>(42);
std::function<int()> fn1 = [p = shared]() {
return *p;
};
// Fix 2: unique_ptr inside shared_ptr
auto wrapped = std::make_shared<std::unique_ptr<int>>(
std::make_unique<int>(42)
);
std::function<int()> fn2 = [p = wrapped]() {
return **p;
};
}
Practical patterns: async work
#include <future>
#include <thread>
void async_pattern() {
auto data = std::make_unique<std::vector<int>>(1000000);
auto future = std::async(
std::launch::async,
[data = std::move(data)]() {
return std::accumulate(data->begin(), data->end(), 0LL);
}
);
auto result = future.get();
std::cout << "sum: " << result << std::endl;
}
void multiple_resources() {
auto conn = std::make_unique<Connection>();
auto cache = std::make_unique<Cache>();
auto logger = std::make_unique<Logger>();
std::thread worker([
conn = std::move(conn),
cache = std::move(cache),
logger = std::move(logger)
]() {
logger->log("start");
auto data = conn->fetch();
cache->store(data);
logger->log("done");
});
worker.detach();
}
auto create_task(bool use_cache) {
auto cache = use_cache ?
std::make_unique<Cache>() :
nullptr;
return [cache = std::move(cache)]() {
if (cache) {
cache->use();
} else {
// work without cache
}
};
}
unique_ptr vs shared_ptr guidelines
// unique_ptr: exclusive ownership (preferred default)
void use_unique_ptr() {
auto data = std::make_unique<Data>();
// Pros:
// - Minimal overhead (no refcount)
// - Clear ownership (single owner)
// - Move-only (accidental copies prevented)
auto task = [data = std::move(data)]() {
data->process();
};
// The lambda is the only owner
}
// shared_ptr: shared ownership (when you truly need it)
void use_shared_ptr() {
auto data = std::make_shared<Data>();
// Pros:
// - Shareable across several lambdas
// - Copyable callable (works with std::function)
// - Last reference drops the object
auto task1 = [data]() { data->process(); };
auto task2 = [data]() { data->process(); };
// Cons:
// - Refcount overhead (atomics)
// - Possible cycles
// - Ownership can become unclear
}
// Rules of thumb:
// 1. Prefer unique_ptr by default
// 2. Use shared_ptr when multiple owners are required
// 3. Use shared_ptr for async sharing across tasks
// 4. Move-only lambdas are OK if you do not need copies
// 5. Need std::function → often shared_ptr
Custom deleters
// File handle example
struct FileDeleter {
void operator()(FILE* f) const {
if (f) {
std::cout << "closing file\n";
fclose(f);
}
}
};
using FilePtr = std::unique_ptr<FILE, FileDeleter>;
void file_lambda_example() {
FilePtr file(fopen("data.txt", "r"), FileDeleter{});
auto task = [f = std::move(file)]() {
if (f) {
char buffer[256];
while (fgets(buffer, sizeof(buffer), f.get())) {
std::cout << buffer;
}
}
// FileDeleter runs when the lambda is destroyed
};
std::thread t(std::move(task));
t.join();
}
// Socket handle example (illustrative)
void socket_lambda_example() {
struct SocketDeleter {
void operator()(int* sock) const {
if (sock && *sock >= 0) {
close(*sock);
delete sock;
}
}
};
std::unique_ptr<int, SocketDeleter> socket(new int(create_socket()));
auto network_task = [sock = std::move(socket)]() {
send_data(*sock, "Hello");
// socket closes when the lambda ends
};
std::async(std::launch::async, std::move(network_task));
}
Performance considerations:
// unique_ptr: pointer-sized (8 bytes on 64-bit)
sizeof(std::unique_ptr<int>); // 8
// shared_ptr: object pointer + control block pointer (often 16 bytes)
sizeof(std::shared_ptr<int>); // 16
void benchmark_ptr_types() {
const int iterations = 10'000'000;
auto start = std::chrono::high_resolution_clock::now();
for (int i = 0; i < iterations; ++i) {
auto p = std::make_unique<int>(i);
auto task = [p = std::move(p)]() {};
}
auto end = std::chrono::high_resolution_clock::now();
auto unique_time = std::chrono::duration_cast<std::chrono::milliseconds>(end - start);
start = std::chrono::high_resolution_clock::now();
for (int i = 0; i < iterations; ++i) {
auto p = std::make_shared<int>(i);
auto task = [p]() {}; // copy → refcount bump
}
end = std::chrono::high_resolution_clock::now();
auto shared_time = std::chrono::duration_cast<std::chrono::milliseconds>(end - start);
std::cout << "unique_ptr: " << unique_time.count() << "ms\n";
std::cout << "shared_ptr: " << shared_time.count() << "ms\n";
}
Practical examples
Example 1: database connection and async work
#include <memory>
#include <thread>
class DatabaseConnection {
public:
DatabaseConnection(const std::string& conn_str)
: conn_str_(conn_str) {
std::cout << "DB connection created: " << conn_str_ << std::endl;
}
~DatabaseConnection() {
std::cout << "DB connection closed: " << conn_str_ << std::endl;
}
void execute_query(const std::string& query) {
std::cout << "running query: " << query << std::endl;
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
private:
std::string conn_str_;
};
void execute_async_query(
std::unique_ptr<DatabaseConnection> conn,
const std::string& query
) {
std::thread([
conn = std::move(conn),
query
]() {
try {
conn->execute_query(query);
std::cout << "query finished, handling results...\n";
} catch (const std::exception& e) {
std::cerr << "query failed: " << e.what() << std::endl;
}
}).detach();
}
void example_db_async() {
auto conn = std::make_unique<DatabaseConnection>("localhost:5432");
execute_async_query(
std::move(conn),
"SELECT * FROM users WHERE active = true"
);
std::cout << "main thread continues...\n";
std::this_thread::sleep_for(std::chrono::seconds(1));
}
Example 2: HTTP-style handlers with captured state
#include <optional>
#include <functional>
#include <unordered_map>
// Stub: replace with your authorization logic
int get_user_level(int user_id) { (void)user_id; return 5; }
class RequestContext {
public:
std::unordered_map<std::string, std::string> headers;
std::unordered_map<std::string, std::string> query_params;
std::string body;
std::optional<int> get_user_id() const {
auto it = headers.find("X-User-ID");
if (it != headers.end()) {
try {
return std::stoi(it->second);
} catch (...) {
return std::nullopt;
}
}
return std::nullopt;
}
};
auto make_auth_handler(std::optional<int> required_level) {
return [level = std::move(required_level)](const RequestContext& ctx) {
auto user_id = ctx.get_user_id();
if (!user_id) {
std::cout << "auth failed: missing user id\n";
return false;
}
if (level) {
int user_level = get_user_level(*user_id);
if (user_level < *level) {
std::cout << "insufficient privilege: need=" << *level
<< ", have=" << user_level << "\n";
return false;
}
}
std::cout << "auth ok: user " << *user_id << "\n";
return true;
};
}
auto make_rate_limiter(std::optional<int> max_requests) {
return [
limit = std::move(max_requests),
count = 0
](const RequestContext& ctx) mutable {
(void)ctx;
++count;
if (limit && count > *limit) {
std::cout << "rate limit exceeded: " << count << "/" << *limit << "\n";
return false;
}
std::cout << "request allowed: " << count;
if (limit) {
std::cout << "/" << *limit;
}
std::cout << "\n";
return true;
};
}
void example_http_handlers() {
auto admin_handler = make_auth_handler(10);
auto user_handler = make_auth_handler(std::nullopt);
auto limiter = make_rate_limiter(100);
RequestContext ctx;
ctx.headers["X-User-ID"] = "42";
if (limiter(ctx) && user_handler(ctx)) {
std::cout << "handling request...\n";
}
}
Example 3: logging with lazy initialization
#include <fstream>
#include <chrono>
#include <iomanip>
class Logger {
public:
explicit Logger(std::string filename)
: file_(std::make_unique<std::ofstream>(filename, std::ios::app)) {
if (!file_->is_open()) {
throw std::runtime_error("failed to open log file");
}
}
void log(const std::string& level, const std::string& message) {
auto now = std::chrono::system_clock::now();
auto time = std::chrono::system_clock::to_time_t(now);
*file_ << std::put_time(std::localtime(&time), "%Y-%m-%d %H:%M:%S")
<< " [" << level << "] " << message << std::endl;
}
private:
std::unique_ptr<std::ofstream> file_;
};
auto make_async_logger(std::string filename) {
return [
logger = std::make_unique<Logger>(std::move(filename))
](const std::string& level, const std::string& message) mutable {
logger->log(level, message);
};
}
void example_batch_logging() {
auto logger = make_async_logger("app.log");
std::vector<std::thread> workers;
for (int i = 0; i < 5; ++i) {
workers.emplace_back([
logger = std::move(logger),
worker_id = i
]() mutable {
for (int j = 0; j < 10; ++j) {
logger("INFO",
"Worker " + std::to_string(worker_id) +
" - job " + std::to_string(j));
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
});
logger = make_async_logger("app.log");
}
for (auto& t : workers) {
t.join();
}
}
Example 4: timer callbacks with captured state
#include <chrono>
#include <functional>
class Timer {
public:
template<typename Callback>
void schedule(std::chrono::milliseconds delay, Callback&& callback) {
std::thread([
cb = std::forward<Callback>(callback),
delay
]() {
std::this_thread::sleep_for(delay);
cb();
}).detach();
}
};
struct Config {
void apply() { std::cout << "Config::apply\n"; }
};
void example_timer_with_resources() {
Timer timer;
auto counter_callback = [count = 0]() mutable {
++count;
std::cout << "timer fired " << count << " times\n";
};
timer.schedule(std::chrono::milliseconds(100), counter_callback);
auto resource = std::make_unique<std::vector<int>>(1000);
timer.schedule(
std::chrono::milliseconds(200),
[data = std::move(resource)]() {
std::cout << "data size: " << data->size() << "\n";
}
);
std::string message = "Hello";
int retry_count = 0;
auto config = std::make_unique<Config>();
timer.schedule(
std::chrono::seconds(1),
[
msg = std::move(message),
retry = retry_count,
cfg = std::move(config)
]() {
std::cout << "message: " << msg << "\n";
std::cout << "retries: " << retry << "\n";
cfg->apply();
}
);
std::this_thread::sleep_for(std::chrono::seconds(2));
}
Example 5: C++11 vs C++14 migration
// Illustrative: assume load_large_data, preprocess, process, and Config exist in your TU.
void cpp11_style() {
std::vector<int> v = load_large_data();
std::vector<int> v_moved = std::move(v);
auto f = [v_moved]() {
return v_moved.size();
};
auto v_shared = std::make_shared<std::vector<int>>(load_large_data());
auto g = [v_shared]() {
return v_shared->size();
};
}
void cpp14_style() {
std::vector<int> v = load_large_data();
auto f = [vec = std::move(v)]() {
return vec.size();
};
auto g = [
data = preprocess(load_large_data()),
config = std::make_unique<Config>(),
timestamp = std::chrono::system_clock::now()
]() {
return process(data, *config, timestamp);
};
}
class LegacyService {
public:
// Assume: void process(std::vector<int>&);
std::function<void()> create_task_cpp11(std::vector<int> data) {
auto shared_data = std::make_shared<std::vector<int>>(std::move(data));
return [shared_data]() {
process(*shared_data);
};
}
auto create_task_cpp14(std::vector<int> data) {
return [data = std::move(data)]() {
process(data);
};
}
};
Example 6: error handling helpers
#include <system_error>
void risky_operation() { throw std::runtime_error("fail"); }
auto make_error_handler(std::string context) {
return [
ctx = std::move(context),
error_count = 0
](const std::exception& e) mutable {
++error_count;
std::cerr << "[" << ctx << "] error #" << error_count
<< ": " << e.what() << std::endl;
if (error_count > 3) {
throw std::runtime_error("error threshold exceeded");
}
};
}
auto make_retry_handler(int max_retries) {
return [
max = max_retries,
current = 0,
backoff = std::chrono::milliseconds(100)
]() mutable -> bool {
if (current >= max) {
return false;
}
++current;
std::cout << "retry " << current << "/" << max << "\n";
std::this_thread::sleep_for(backoff);
backoff *= 2;
return true;
};
}
void example_error_handling() {
auto error_handler = make_error_handler("DB work");
auto retry_handler = make_retry_handler(3);
while (retry_handler()) {
try {
risky_operation();
break;
} catch (const std::exception& e) {
error_handler(e);
}
}
}
[*this] / [=, *this] (C++17)
In a member function, *this capture stores a copy of the current object inside the lambda. That avoids the classic bug where you only capture this by reference and the object is destroyed before the lambda runs.
struct S {
int n = 0;
auto make_lambda() {
return [*this]() { return n; }; // S is copied into the closure
}
};
Like init-capture, this is a way to build a value snapshot.
Common mistakes and debugging
Mistake 1: use-after-move on the outer variable
void dangerous_use_after_move() {
auto ptr = std::make_unique<int>(42);
auto task = [p = std::move(ptr)]() {
return *p;
};
if (*ptr > 0) { // undefined behavior: ptr was moved from
std::cout << "value: " << *ptr << std::endl;
}
ptr.reset(new int(100)); // risky / implementation-defined interaction with moved-from state
}
Symptoms: segfault, access violation, AddressSanitizer heap-use-after-free, null dereference in the debugger.
Safer patterns:
void safe_move_pattern() {
auto ptr = std::make_unique<int>(42);
auto task = [p = std::move(ptr)]() {
return *p;
};
if (ptr != nullptr) {
// typically not taken after a full move
}
ptr = nullptr;
#ifdef _DEBUG
assert(ptr == nullptr);
#endif
}
Compiler flags (examples):
clang++ -Wconsumed -Wunused-value file.cpp
g++ -Wuse-after-move file.cpp
cl /W4 /analyze file.cpp
Mistake 2: mixing reference capture and init-capture semantics
void reference_vs_init_capture() {
int counter = 0;
auto ref_lambda = [&counter]() {
++counter;
};
ref_lambda();
std::cout << counter << std::endl; // 1
int value = 10;
auto init_lambda = [val = value]() mutable {
++val;
return val;
};
init_lambda(); // 11
std::cout << value << std::endl; // 10
}
Trap: “shared counter” with init-capture:
void counter_trap() {
int total = 0;
std::vector<std::function<void()>> tasks;
for (int i = 0; i < 5; ++i) {
tasks.push_back([total = total]() mutable {
++total;
std::cout << total << " ";
});
}
for (auto& task : tasks) {
task(); // prints 1 1 1 1 1 — separate members, not one shared counter
}
auto shared_total = std::make_shared<int>(0);
tasks.clear();
for (int i = 0; i < 5; ++i) {
tasks.push_back([total = shared_total]() {
++(*total);
std::cout << *total << " ";
});
}
for (auto& task : tasks) {
task(); // prints 1 2 3 4 5
}
}
Snapshot vs live view:
void snapshot_confusion() {
std::vector<int> vec = {1, 2, 3};
auto lambda = [size = vec.size()]() {
return size;
};
vec.push_back(4);
vec.push_back(5);
std::cout << lambda() << std::endl; // still 3
auto ref_lambda = [&vec]() {
return vec.size();
};
std::cout << ref_lambda() << std::endl; // 5
}
Mistake 3: invalid or confusing default capture mixes
void mixed_capture_errors() {
int x = 10;
int y = 20;
// auto bad1 = [=, x = x * 2]() { }; // error: duplicate capture of x
auto confusing = [=, z = y]() {
(void)x;
(void)y;
(void)z;
};
}
Clearer style:
void correct_mixed_capture() {
int x = 10;
int y = 20;
std::unique_ptr<int> ptr = std::make_unique<int>(30);
auto lambda1 = [
x,
y_doubled = y * 2,
p = std::move(ptr)
]() {
(void)x;
(void)y_doubled;
(void)p;
};
ptr = std::make_unique<int>(40);
auto lambda2 = [
&,
p = std::move(ptr)
]() {
(void)p;
};
}
Mistake 4: dangling references
auto make_dangerous_lambda() {
std::vector<int> local_data = {1, 2, 3, 4, 5};
return [&local_data]() {
return local_data.size();
};
}
auto make_dangerous_init_capture() {
int local = 42;
return [&ref = local]() {
return ref;
};
}
Safer options:
auto make_safe_lambda_value() {
std::vector<int> local_data = {1, 2, 3, 4, 5};
return [data = std::move(local_data)]() {
return data.size();
};
}
auto make_safe_lambda_shared() {
auto data = std::make_shared<std::vector<int>>(
std::vector<int>{1, 2, 3, 4, 5}
);
return [data]() {
return data->size();
};
}
Mistake 5: missing mutable
void missing_mutable() {
auto counter_ok = [n = 0]() mutable {
return ++n;
};
}
operator() is const unless the lambda is mutable, so mutating copy-captured members requires mutable. That does not make the closure thread-safe.
Mistake 6: hidden copy cost
void expensive_copy_mistake() {
std::vector<int> large_data(1'000'000);
auto bad = [data = large_data]() {
return data.size();
};
auto good = [data = std::move(large_data)]() {
return data.size();
};
std::string long_str(10'000, 'a');
auto bad_str = [s = long_str]() { return s.length(); };
auto good_str = [s = std::move(long_str)]() { return s.length(); };
}
Debugging checklist
- After
std::moveinto a capture, treat the outer name as empty unless you reset it. - Watch lifetimes for reference captures.
- Add
mutablewhen members must change. - Be explicit about copy vs move.
- Synchronize if multiple threads call the same mutable closure.
Performance considerations
Copy vs move benchmark
#include <chrono>
#include <vector>
#include <iostream>
void performance_comparison() {
const int SIZE = 10'000'000;
std::vector<int> data(SIZE, 42);
auto start = std::chrono::high_resolution_clock::now();
auto copy_lambda = [data]() { return data.size(); };
copy_lambda();
auto end = std::chrono::high_resolution_clock::now();
auto copy_time = std::chrono::duration_cast<std::chrono::microseconds>(end - start);
data = std::vector<int>(SIZE, 42);
start = std::chrono::high_resolution_clock::now();
auto move_lambda = [data = std::move(data)]() { return data.size(); };
move_lambda();
end = std::chrono::high_resolution_clock::now();
auto move_time = std::chrono::duration_cast<std::chrono::microseconds>(end - start);
std::cout << "copy capture: " << copy_time.count() << " μs\n";
std::cout << "move capture: " << move_time.count() << " μs\n";
}
Closure size (illustrative)
void memory_usage() {
auto unique_lambda = [p = std::make_unique<int>(42)]() {};
std::cout << "unique_ptr closure: " << sizeof(unique_lambda) << " bytes\n";
auto shared_lambda = [p = std::make_shared<int>(42)]() {};
std::cout << "shared_ptr closure: " << sizeof(shared_lambda) << " bytes\n";
std::vector<int> vec(1000);
auto value_lambda = [vec]() {};
std::cout << "vector by-value closure: " << sizeof(value_lambda) << " bytes\n";
auto ref_lambda = [&vec]() {};
std::cout << "vector by-reference closure: " << sizeof(ref_lambda) << " bytes\n";
}
Best practices and guidelines
1. Prefer explicit capture
auto good1 = [x, y]() { return x + y; };
auto good2 = [ptr = std::move(ptr)]() { ptr->use(); };
2. Naming
Use suffixes or distinct names (ptr_, conn) when it clarifies move semantics.
3. Type choice
Use unique_ptr for exclusive ownership, shared_ptr when sharing or std::function requires copyability, and move capture for large vectors.
4. Error handling
Keep RAII inside the closure so resources are released on exit paths.
Language version overview
| Feature | C++11 | C++14 | C++17 | C++20 |
|---|---|---|---|---|
Basic [x], [&x] | Yes | Yes | Yes | Yes |
Init-capture [x = expr] | No | Yes | Yes | Yes |
[*this] | No | No | Yes | Yes |
[=, this] warnings / fixes | — | — | Yes | Yes |
| Template lambdas | No | Yes | Yes | Yes |
constexpr lambda | No | No | Yes | Yes |
[...]<typename T> | No | No | No | Yes |
Summary and quick reference
| Topic | C++11 | C++14 |
|---|---|---|
| Default capture | [x], [&x], [=], [&] | Same |
| Init-capture | Not available | [name = expr] |
| Move capture | Extra locals | [name = std::move(x)] |
| Transformed capture | Extra locals | [doubled = x * 2] |
Syntax cheat sheet
[x]() // copy x
[&x]() // reference x
[x = expr]() // member initialized from expr
[x = std::move(y)]() // move into member
[x, &y, z = std::move(w)]() // mixed
[x = 0]() mutable { ++x; }
Decision flow (simplified)
Need a capture?
├─ Copyable small value? → [x]
├─ Move-only / large buffer? → [x = std::move(y)]
├─ Shared across owners? → shared_ptr + [p]
└─ Long-lived alias? → [&x] only if lifetime is clear
Mistake summary
| Mistake | Symptom | Mitigation |
|---|---|---|
| Use after move | Crash, UB | Check nullptr, rename after move |
| Dangling reference | Random faults | Move-by-value or shared_ptr |
| Missing mutable | Compile error | Add mutable |
| Unintended copy | Slow | std::move in init-capture |
| Thread safety | Data races | atomics / mutexes |
See also: Lambda capture, make_unique & make_shared, Custom deleters.