← Back to list

C++23 std::move_only_function — A Callable Wrapper That Doesn't Force Copy-ability

Why C++23 finally fixes move-only callbacks, task queues, and coroutine continuations that std::function couldn’t handle

Sagar in Towards Dev · 2026-04-14 03:37 · 7 claps · 8.0 min read
#programming #software-development #cpp #cpp23 #c-plus-plus-language
Open on Medium ↗
Wiki topics: 💻 · Programming 🎵 · Music & Audio

C++23 std::move_only_function — A Callable Wrapper That Doesn't Force Copy-ability

Why C++23 finally fixes move-only callbacks, task queues, and coroutine continuations that std::function couldn’t handle

**std::function has been a source of friction for me for years. Not because it's bad — it's genuinely useful — but because it has an awkward constraint: it requires that whatever you store in it is copyable**. That requirement has influenced and constrained callback API designs since C++11.

C++23 gives us **std::move_only_function**, and it's one of those features where you go "why didn't we have this from the start?"

The Problem: std::function Forces Copyability

Say you’ve got a unique resource — a database connection, a file handle, something wrapped in a **std::unique_ptr. You want to create a callback that **owns this resource:

#include <functional>
#include <memory>
#include <iostream>

void register_callback(std::function<void()> cb) {
    cb();
}

int main() {
    auto resource = std::make_unique<int>(42);

    auto callback = [res = std::move(resource)]() {
        std::cout << "Resource value: " << *res << "\n";
    };

    // This will NOT compile.
    register_callback(std::move(callback));
}
In file included from /usr/include/c++/13/functional:59,
                 from call.cpp:1:
/usr/include/c++/13/bits/std_function.h: In instantiation of ‘std::function<_Res(_ArgTypes ...)>::function(_Functor&&) [with _Functor = main()::<lambda()>; _Constraints = void; _Res = void; _ArgTypes = {}]’:
call.cpp:17:22:   required from here
/usr/include/c++/13/bits/std_function.h:439:69: 
error: static assertion failed: 
std::function target must be copy-constructible
  439 |           static_assert(is_copy_constructible<__decay_t<_Functor>>::value,
      |                                                                     ^~~~~
/usr/include/c++/13/bits/std_function.h:439:69: note: ‘std::integral_constant<bool, false>::value’ evaluates to false

Compiler error. The lambda captured a **unique_ptr by move, making it move-only. But `std::function** demands its stored callable is copy-constructible — even if you never actually copy thestd::function` itself.

The workarounds over the years — wrapping in **shared_ptr, writing custom move-only wrappers, reverting to C-style `void*`** callbacks — all worked, none were good. Every large codebase I've worked on had its own bespoke solution.

The Solution: std::move_only_function

C++23 introduces **std::move_only_function in `<functional>`. It's a type-erased callable wrapper that only requires the stored callable to be **movable, not copyable.

#include <functional>
#include <memory>
#include <iostream>

void register_callback(std::move_only_function<void()> cb) {
    cb();
}

int main() {
    auto resource = std::make_unique<int>(42);

    auto callback = [res = std::move(resource)]() {
        std::cout << "Resource value: " << *res << "\n";
    };

    // Compiles and works.
    register_callback(std::move(callback));
}

Drop-in replacement for the move-only case.

Ok the above example was a simple one.

If you’ve ever built a thread pool or task queue, you’ve hit this problem. Tasks are submitted, moved into a queue, popped, executed, destroyed. Nobody copies them. Yet **std::function** demands copyability anyway:

#include <functional>
#include <memory>
#include <queue>
#include <iostream>

class TaskQueue {
    std::queue<std::function<void()>> tasks_;  // ← the problem

public:
    void submit(std::function<void()> task) {
        tasks_.push(std::move(task));
    }

    void run_next() {
        if (tasks_.empty()) return;
        auto task = std::move(tasks_.front());
        tasks_.pop();
        task();
    }
};

int main() {
    TaskQueue q;

    auto db_conn = std::make_unique<int>(9001);
    // Won't compile — lambda is move-only, std::function refuses it
    q.submit([conn = std::move(db_conn)]() {
        std::cout << "Querying with connection " << *conn << "\n";
    });
}

The queue never copies anything. Every task moves in, moves out, runs once, gets destroyed. But **std::function doesn't care about what you actually do — it checks what you **could do, and rejects the move-only lambda at construction time.

Swap in move_only_function and the same code just works:

#include <functional>
#include <memory>
#include <queue>
#include <iostream>

class TaskQueue {
    std::queue<std::move_only_function<void()>> tasks_;  // ← fixed

public:
    void submit(std::move_only_function<void()> task) {
        tasks_.push(std::move(task));
    }

    void run_next() {
        if (tasks_.empty()) return;
        auto task = std::move(tasks_.front());
        tasks_.pop();
        task();
    }
};

int main() {
    TaskQueue q;

    auto db_conn = std::make_unique<int>(9001);
    // Compiles — move_only_function accepts move-only callables
    q.submit([conn = std::move(db_conn)]() {
        std::cout << "Querying with connection " << *conn << "\n";
    });

    auto file = std::make_unique<std::string>("/var/log/app.log");
    q.submit([f = std::move(file)]() {
        std::cout << "Writing to " << *f << "\n";
    });

    q.run_next();  // "Querying with connection 9001"
    q.run_next();  // "Writing to /var/log/app.log"
}

The API is identical. The only change is the type in the queue and the function signatures. No hacks, no **shared_ptr** indirection, no custom wrapper class maintained by that one person who left the team in 2019.

A Subtle Bug I Traced Back to Forced Copyability

Before I get into coroutines and const-correctness, let me tell you about a real failure pattern I’ve seen.

Side note: If you’re short on time, skip to the next section — otherwise, here’s a real production bug this pattern can cause.

We had an event system. Handlers were stored as **std::function<void(Event const&)> in a std::vector. One handler owned a logging session — a `unique_ptr<LogSession>** — that wrote structured events to disk. Becausestd::function` refused the move-only lambda, someone wrapped the session in a **shared_ptr** to make it work:

// The "fix" that caused the bug
auto session = std::make_shared<LogSession>("/var/log/events.log");

dispatcher.on("user.login", [session](Event const& e) {
    session->write(e);
});

Looked fine. Worked fine — for months. Then someone added a feature that cloned the handler list to create a “snapshot” of the dispatch table for replay purposes. Now two vectors held **std::function objects that shared the same `LogSession** viashared_ptr`. Both wrote to the same file. Interleaved. Corrupted output. No crash, no obvious error — just silently garbled logs that nobody noticed until an audit three weeks later.

The root cause wasn’t the **shared_ptr. It was that `std::function** *forced* shared ownership where exclusive ownership was the correct model. If the handler had been amove_only_function`, the snapshot code wouldn't have compiled — because you can't copy a **move_only_function**. The developer would have been forced to confront the ownership question at compile time instead of discovering it in production.

// With move_only_function — the snapshot code fails to compile
std::vector<std::move_only_function<void(Event const&)>> handlers_;

// Later, someone tries:
auto snapshot = handlers_;  // Compile error — can't copy move_only_function

// They're forced to think: do I need to move these? Deep-copy the state?
// Share explicitly? The type system made them ask the right question.

This is the kind of bug that **move_only_function** doesn't just fix — it prevents the conditions that lead to it.

Const-Correctness: Fixing a Semantic Mismatch

**std::function::operator() is always `const**, but it will happily call a **non-const**operator()` on the stored callable:

#include <functional>
#include <iostream>

int main() {
    int counter = 0;

    const std::function<void()> f = [counter]() mutable {
        std::cout << counter++ << "\n";
    };

    f();  // prints 0
    f();  // prints 1 — state changed through a const reference
}

This is a deliberate design choice, not a bug — **const on `std::function** means the ***wrapper* is const**, **not the *stored callable's state***. But it creates a gap between whatconst` appears to mean at the call site and what actually happens. Whether this bothers you depends on your codebase's const-correctness standards.

**std::move_only_function** makes the qualifier explicit in the type:

// Non-const: operator() can mutate the stored callable's state
std::move_only_function<void()>         mutable_fn;

// Const: operator() propagates const to the stored callable
std::move_only_function<void() const>   const_fn;

The Coroutine Connection

If you’re using C++20 coroutines, move_only_function is a strong default for continuation storage — though not the only option (custom intrusive continuations, function_ref for non-owning cases, and allocation-free trampolines all have their place depending on your constraints).

Let’s separate two things clearly, because they get conflated a lot:

The coroutine handlestd::coroutine_handle is trivially copyable. It's just a pointer to the coroutine frame. Nothing move-only about it.

The continuation wrapper — this is the lambda (or callable) you build around the handle, often capturing owned state alongside it. This is the thing you type-erase and store in your scheduler. Since resuming a coroutine twice is undefined behavior, this wrapper benefits from single-owner semantics.

**move_only_function* models that second thing well. It doesn't prevent all misuse — you could still stash the raw handle separately and resume it twice — but it makes accidental double-ownership of the wrapper* a compile error instead of a runtime surprise.

Here’s a complete example — a minimal coroutine task system with move_only_function as the continuation backbone:

#include <coroutine>
#include <functional>
#include <iostream>
#include <memory>
#include <queue>
#include <utility>

struct Task {
    struct promise_type {
        Task get_return_object() {
            return Task{
                std::coroutine_handle<promise_type>::from_promise(*this)
            };
        }
        std::suspend_never initial_suspend() { return {}; }
        std::suspend_never final_suspend() noexcept { return {}; }
        void return_void() {}
        void unhandled_exception() { std::terminate(); }
    };
    std::coroutine_handle<promise_type> handle;
};

class Scheduler {
    std::queue<std::move_only_function<void()>> ready_queue_;

public:
    static Scheduler& instance() {
        static Scheduler s;
        return s;
    }

    void schedule(std::move_only_function<void()> work) {
        ready_queue_.push(std::move(work));
    }

    void run() {
        while (!ready_queue_.empty()) {
            auto task = std::move(ready_queue_.front());
            ready_queue_.pop();
            task();
        }
    }
};

// Awaitable that defers to the scheduler
struct Defer {
    bool await_ready() const noexcept { return false; }

    void await_suspend(std::coroutine_handle<> h) {
        Scheduler::instance().schedule([h]() { h.resume(); });
    }

    void await_resume() const noexcept {}
};

// Awaitable that owns a move-only resource
struct AsyncQuery {
    std::unique_ptr<std::string> connection;

    bool await_ready() const noexcept { return false; }

    void await_suspend(std::coroutine_handle<> h) {
        auto conn = std::move(connection);
        Scheduler::instance().schedule(
            [h, c = std::move(conn)]() mutable {
                std::cout << "  [db] queried via " << *c << "\n";
                h.resume();
            }
        );
    }

    void await_resume() const noexcept {}
};

Task do_work() {
    std::cout << "1. starting work\n";
    co_await Defer{};
    std::cout << "2. resumed after defer\n";

    auto conn = std::make_unique<std::string>("postgres://localhost/mydb");
    co_await AsyncQuery{std::move(conn)};
    std::cout << "3. query complete\n";

    co_await Defer{};
    std::cout << "4. all done\n";
}

int main() {
    do_work();
    Scheduler::instance().run();
}

Output:

1. starting work
2. resumed after defer
  [db] queried via postgres://localhost/mydb
3. query complete
4. all done

The flow through the system looks like this:

Interface and Design Differences

**move_only_function** doesn't need copy semantics in its interface or usage model, which gives implementations freedom to omit copy-related machinery internally. Whether that translates to measurable differences depends on your implementation and workload — the primary win is correctness and expressiveness.

**move_only_function also supports `noexcept** in the signature, whichstd::function` never could:

std::move_only_function<void() noexcept> safe_callback;
std::move_only_function<void() const noexcept> ultra_safe;

When to Use Which?

Rule of thumb: if you’re not sure you need to copy the callable, start with **move_only_function. It's the more restrictive default. You can always relax to `std::function` later if you genuinely need copy semantics**.

Closing Thoughts

std::move_only_function fills a gap that's been frustrating C++ developers for over a decade:

  1. Stores move-only callables — unique_ptr captures, non-copyable state, no workarounds needed.
  2. Natural fit for coroutine continuations — models single-owner semantics for the wrapper around coroutine_handle, making accidental misuse harder.
  3. Fixes the const-correctness mismatch — the qualifier is part of the type, explicit in the contract.
  4. Supports noexcept in the signature — expressible where it wasn't before.
  5. Doesn’t require copy semantics in its interface — cleaner contract overall.

If you’re building async infrastructure with C++20 coroutines and C++23 is available, **move_only_function is the natural choice for continuation storage. And if you're not using coroutines yet — it still solves the decade-old "unique_ptr in a callback**" problem.

Compiler support: GCC 12+, Clang 16+, MSVC 19.32+. Requires -std=c++23 or /std:c++latest.

If you’re serious about passing technical interviews, try PracHub. It helped me structure my preparation with advanced mock sessions. [**Check it out here**] and start practicing. (Disclosure: This is an affiliate link).

Found this article helpful ? Clap 👏 and follow me for more C++ and system programming content.


메타데이터
post_id
369e79e5baa0
slug
std-move-only-function-cpp23-callable-wrapper-no-copy-369e79e5baa0
url
https://towardsdev.com/std-move-only-function-cpp23-callable-wrapper-no-copy-369e79e5baa0
canonical_url
https://towardsdev.com/std-move-only-function-cpp23-callable-wrapper-no-copy-369e79e5baa0
author_url
https://medium.com/@sagarmadala
status
ok
fetched_at
2026-06-23 03:48:11