short

Converting between std::function and copyable_function nests them

· english· audience: working-cpp· AI-generated, reviewed by Filip Sajdak

C++ now has a small family of type-erased callable wrappers. std::function (C++11) copies. std::move_only_function (C++23) does not. std::copyable_function (C++23) is the fixed-up copyable one, with the const-correctness std::function got wrong. std::function_ref (C++26) borrows instead of owning.

Having four is defensible. The trap is what happens at the boundary between two of them, and it is not obvious from any of their documentation.

None of these wrappers recognises any of the others. When you convert a std::function into a std::copyable_function, the target type does not look inside to find the original lambda and re-erase it. It sees an arbitrary callable object that happens to be a std::function, and it stores a copy of that whole wrapper. Convert back and you wrap the wrapper. Do it in a loop and you have built a linked list, with one layer of indirection per conversion, all of it invisible in the type, which stays std::function<int(int)> throughout.

The demo times ten thousand calls through a fresh std::function, then does 200 round trips through std::copyable_function and times the identical calls again:

10k calls before : 19 us
10k calls after  : 8889 us
slowdown         : 467x

Timings vary between runs on shared infrastructure, so treat the exact multiplier as one representative run rather than a benchmark. The shape is the point: the cost grows with the number of conversions, and it is unbounded. Nothing warns, nothing in the type changes, and the checksums match, so the code stays correct while getting arbitrarily slower.

Where this actually happens

Nobody writes a 200-iteration conversion loop. The realistic version is architectural: a callback that crosses several layers, where each layer has its own opinion about which wrapper to use. The handler is stored as std::function in one subsystem, passed to another that standardised on copyable_function, registered back into a third. Three conversions per registration is nothing. Three conversions per registration inside a loop that re-registers on every frame, or per request, is a leak of indirection that grows for as long as the process runs.

Two rules avoid it entirely:

  • Pick one owning wrapper per codebase and put it in the interfaces that cross module boundaries. Which one matters less than the consistency.
  • Take callbacks by std::function_ref when you only invoke and do not store. There is nothing to convert, nothing to own, and no wrapper to nest.

If you suspect you already have this, it is easy to confirm: time the invocation, not the setup. A callable that gets slower over the life of the process, with no change in what it does, is this bug.


Sources: Arthur O’Dwyer, “Interconverting std::function with copyable_function · cppreference: std::copyable_function.