[2026] C++ Init-Capture — C++14 [x = expr], Move, and unique_ptr Patterns

[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:

  1. The left-hand name in the capture list (factor) is the closure member name.
  2. The right-hand expression (factor * 2) is evaluated immediately when the lambda is defined.
  3. 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:

  1. Conciseness: fewer temporaries.
  2. Clear intent: the capture list shows what value is stored.
  3. Less scope pollution: no extra names outside the lambda.
  4. Move semantics: supports move-only types like unique_ptr.

C++11 capture vs C++14 init-capture

C++11: default captures

SyntaxMeaning
[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::move into a capture, treat the outer name as empty unless you reset it.
  • Watch lifetimes for reference captures.
  • Add mutable when 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

FeatureC++11C++14C++17C++20
Basic [x], [&x]YesYesYesYes
Init-capture [x = expr]NoYesYesYes
[*this]NoNoYesYes
[=, this] warnings / fixesYesYes
Template lambdasNoYesYesYes
constexpr lambdaNoNoYesYes
[...]<typename T>NoNoNoYes

Summary and quick reference

TopicC++11C++14
Default capture[x], [&x], [=], [&]Same
Init-captureNot available[name = expr]
Move captureExtra locals[name = std::move(x)]
Transformed captureExtra 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

MistakeSymptomMitigation
Use after moveCrash, UBCheck nullptr, rename after move
Dangling referenceRandom faultsMove-by-value or shared_ptr
Missing mutableCompile errorAdd mutable
Unintended copySlowstd::move in init-capture
Thread safetyData racesatomics / mutexes

See also: Lambda capture, make_unique & make_shared, Custom deleters.



Further reading