C++ Module 4 – Functions, files and scope
Function pointers, functors and std::function
Pass behaviour into C++ functions with templates, std::function, function objects and function pointers, and see what each one costs on two compilers.
What you will learn
- Write a function object that keeps state, and read it after an algorithm with std::ref
- Pass a callback with its data to a C-style interface through a function pointer and a void*
- Measure what std::function costs in allocations and in call time
- Choose between a template, std::function, a function pointer and the newer wrappers for each parameter
- Fix an event bus that breaks when a handler unsubscribes itself
Before you start
What you’ll learn · Before you start
What you will learn
- Write a function object that keeps state, and read it after an algorithm with std::ref
- Pass a callback with its data to a C-style interface through a function pointer and a void*
- Measure what std::function costs in allocations and in call time
- Choose between a template, std::function, a function pointer and the newer wrappers for each parameter
- Fix an event bus that breaks when a handler unsubscribes itself
Before you start
On this page
The same callback: Behaviour passed into a function for it to call: the comparison std::sort uses, a handler an event bus stores, the function a C library calls back. C++ takes one as a template parameter, a std::function or a function pointer., with_tax, cost 0.29 ns a call through a template parameter and 1.67 ns through std::function
in this lesson’s benchmark of 10 million calls, and storing one lambda in a std::function cost two heap allocations.
The type a function uses to accept behaviour decides that cost, and also whether it can keep the callable, hand it to
a C library or accept a lambda that owns a std::unique_ptr.
You will write one dispatcher three ways, give a function object: An object of a class that defines operator(), so it can be called like a function and can carry state in its members, as a counter of calls. Every lambda is one, of a class the compiler writes. Also called a functor. state, pass a callback with its data to a C-style
interface, count what std::function allocates and costs per call, check which newer wrappers each compiler offers,
and fix an event bus that breaks when a handler removes itself. Apple clang 21 compiled every program of this lesson, and GCC 16 most of them.
A function can take behaviour in three ways
A callback is behaviour passed into a function: the comparison std::sort uses, the
handler a button calls, the code a timer runs later. It comes as one of two kinds of callable. A
function pointer: A variable that holds the address of an ordinary function, such as int (*cmp)(const void*, const void*). It carries no state, which is why C interfaces pass a void* for the caller's data beside it. Only lambdas without captures convert to one. holds the address of an ordinary function; it carries no state and
is the only form C libraries understand. A function object, or functor, is an object
of a class with an operator(), so it can carry state, and every lambda is one. A function can accept them in three
ways. A template parameter accepts any callable at all and lets the compiler inline the call. std::function
stores any copyable callable behind one type, which is what you need to keep callbacks in a container, at the price of
an indirect call and sometimes a heap allocation. A function pointer parameter accepts only ordinary functions and
lambdas without captures.
The job of this section: run some code for every new order. The template version calls the callable at once; the
Dispatcher stores callables to call later; the C-style version passes a function pointer and a void* that carries
the caller’s data:
// dispatcher.cpp: one job, "tell someone about each new order", with the behaviour passed in three ways.
#include <cstdio>
#include <functional>
#include <string>
#include <vector>
struct Order {
std::string id;
long paise;
};
// 1. A template parameter: any callable, called right away, inlined by the compiler.
template <typename OnOrder>
void for_each_order(const std::vector<Order>& orders, OnOrder on_order) {
for (const Order& o : orders) on_order(o);
}
// 2. std::function: any copyable callable, stored now and called later.
class Dispatcher {
public:
void subscribe(std::function<void(const Order&)> handler) { handlers_.push_back(std::move(handler)); }
void publish(const Order& o) const {
for (const auto& h : handlers_) h(o);
}
private:
std::vector<std::function<void(const Order&)>> handlers_;
};
// 3. A function pointer plus a void* for the caller's data: the shape C libraries use.
using OrderCallback = void (*)(const Order* order, void* user_data);
void notify_c_style(const Order* orders, int count, OrderCallback callback, void* user_data) {
for (int i = 0; i < count; ++i) callback(&orders[i], user_data);
}
void add_to_total(const Order* order, void* user_data) {
*static_cast<long*>(user_data) += order->paise; // the caller's long, passed through as void*
}
int main() {
std::vector<Order> orders{{"A-1", 49900}, {"A-2", 12500}, {"A-3", 300000}};
long big = 0;
for_each_order(orders, [&big](const Order& o) { if (o.paise >= 100000) ++big; });
std::printf("template: %ld order(s) of 1000 rupees or more\n", big);
Dispatcher dispatcher;
dispatcher.subscribe([](const Order& o) { std::printf("std::function: email for %s\n", o.id.c_str()); });
int sms_sent = 0;
dispatcher.subscribe([&sms_sent](const Order&) { ++sms_sent; });
for (const Order& o : orders) dispatcher.publish(o);
std::printf("std::function: %d SMS sent\n", sms_sent);
long total = 0;
notify_c_style(orders.data(), static_cast<int>(orders.size()), add_to_total, &total);
std::printf("function pointer: total %ld paise\n", total);
} from session import run
run("""
clang++ -std=c++23 -Wall -Wextra -fsanitize=address,undefined dispatcher.cpp -o dispatcher && ./dispatcher
g++-16 -std=c++23 -Wall -Wextra dispatcher.cpp -o dispatcher && ./dispatcher
""") Output
$ clang++ -std=c++23 -Wall -Wextra -fsanitize=address,undefined dispatcher.cpp -o dispatcher && ./dispatcher template: 1 order(s) of 1000 rupees or more std::function: email for A-1 std::function: email for A-2 std::function: email for A-3 std::function: 3 SMS sent function pointer: total 362400 paise $ g++-16 -std=c++23 -Wall -Wextra dispatcher.cpp -o dispatcher && ./dispatcher template: 1 order(s) of 1000 rupees or more std::function: email for A-1 std::function: email for A-2 std::function: email for A-3 std::function: 3 SMS sent function pointer: total 362400 paise
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 dispatcher_session.py
All three saw the same three orders. Here is how the three forms compare, with the numbers this lesson measures further down:
| Criterion | Template parameter | std::function | Function pointer |
|---|---|---|---|
| Accepts | any callable | any copyable callable with a matching signature | ordinary functions and lambdas without captures |
| Can be stored | only inside a template | yes: a vector can hold them all | yes: an address |
| State | the callable's own | the callable's own | none: a void* travels beside it |
| Cost of a call | well under 1 ns: inlined | an indirect call, 1 to 2 ns | an indirect call, 1 to 2 ns |
| Heap allocation | never | for callables too big for its buffer | never |
| C code can call it | no | no | yes |
| When to choose | Called only during the call, as std::sort does | Kept for later, as by an event bus | A C library calls back |
The rule of thumb
A callable that your function only calls before it returns is a template parameter; one it keeps is a
std::function; one that crosses into C is a function pointer with a void* for the data.
Checkpoint
Check: Three ways to accept a callable
A function object carries state, and algorithms copy it
Before lambdas, a callable with state was written as a class. It still is when the state must be read afterwards, or when the same callable is used in several places:
// functor.cpp: a class with operator() is a function object, and it can carry state of its own.
#include <algorithm>
#include <functional>
#include <iostream>
#include <vector>
class Above { // a function object: "is this value above the limit?"
public:
explicit Above(int limit) : limit_(limit) {}
bool operator()(int value) {
++checked_;
return value > limit_;
}
int checked() const { return checked_; }
private:
int limit_;
int checked_ = 0;
};
struct Item {
int marks;
bool passed() const { return marks >= 40; }
};
int main() {
std::vector<int> marks{72, 38, 91, 40, 55};
Above above_60(60);
std::cout << std::count_if(marks.begin(), marks.end(), std::ref(above_60)) << " above 60, "
<< above_60.checked() << " checked\n"; // std::ref: count_if uses this object, not a copy
std::sort(marks.begin(), marks.end(), std::greater<>{}); // a standard function object
std::cout << "highest first: " << marks.front() << " ... " << marks.back() << '\n';
Item it{38};
auto member = &Item::passed; // a pointer to a member function
std::cout << std::boolalpha << "std::invoke: " << std::invoke(member, it) << '\n';
} Above counts how many values it was asked about. Step through count_if with above_60, first as it would run on a
copy, then with std::ref:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- A callable with state. Above above_60(60); is an object of a class with an operator(). Its data members are the limit, 60, and a count of the values it was asked about, 0 so far.
- Algorithms take a copy. Standard algorithms take their function object by value. Passed as plain above_60, count_if would get a copy of its own and ask that copy about the 5 marks.
- The count is lost. The copy is destroyed when count_if returns, and its count goes with it: above_60.checked() would still report 0.
- std::ref instead. std::ref(above_60) makes a small wrapper that refers to the object. count_if copies the wrapper, and every call goes to above_60 itself.
- Both numbers. Two marks, 72 and 91, are above 60, and the original saw all 5 checks: the program prints 2 above 60, 5 checked.
Algorithms take their callable by value, so count_if would count with a copy and above_60.checked() would
still be 0; passing std::ref(above_60) makes it use the object itself, and the output reports 5 checks. The standard
library also ships ready-made function objects such as std::greater<> and std::plus<>, and std::invoke calls
anything callable, including a pointer to a member function, with the object as its first argument. A lambda is simply
a function object whose class the compiler writes for you, which is why the C++ Core Guidelines say to pass operations
to algorithms as function objects, lambdas included (T.40).
std::bind, which came with C++11 like lambdas, builds a function object by fixing some arguments of another. A
lambda says the same thing more plainly, and it is stricter:
// bind_or_lambda.cpp: the same partial application written with std::bind and with a lambda.
#include <functional>
#include <iostream>
int price_with_tax(int paise, int percent) { return paise + paise * percent / 100; }
int main() {
using namespace std::placeholders; // _1, _2 ...
auto gst_bind = std::bind(price_with_tax, _1, 18);
auto gst_lambda = [](int paise) { return price_with_tax(paise, 18); };
std::cout << gst_bind(10000) << ' ' << gst_lambda(10000) << '\n';
// std::bind quietly ignores extra arguments; the lambda refuses them at compile time.
std::cout << gst_bind(10000, 999, "ignored") << '\n';
} from session import run
run("""
clang++ -std=c++23 -Wall -Wextra bind_or_lambda.cpp -o bind_or_lambda && ./bind_or_lambda
""") Output
$ clang++ -std=c++23 -Wall -Wextra bind_or_lambda.cpp -o bind_or_lambda && ./bind_or_lambda 11800 11800 11800
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 bind_session.py
The call gst_bind(10000, 999, "ignored") compiled and ran: a bound callable silently discards arguments it has no
placeholder for. The lambda would have rejected the extra arguments at compile time.
Checkpoint
Check: Function objects and std::ref
C callbacks take a function pointer and a void* for the data
C has no templates and no std::function, so a C library that calls back into your code takes a plain function
pointer. std::qsort is the standard example:
// c_sort.cpp: std::qsort from the C library takes a plain function pointer as its comparison.
#include <cstdio>
#include <cstdlib>
int ascending(const void* a, const void* b) {
int x = *static_cast<const int*>(a);
int y = *static_cast<const int*>(b);
return (x > y) - (x < y); // -1, 0 or 1, without the overflow of x - y
}
int main() {
int marks[]{72, 38, 91, 40, 55};
std::qsort(marks, 5, sizeof(int), ascending);
for (int m : marks) std::printf("%d ", m);
std::printf("\n");
// A lambda without captures converts to a function pointer, so it works too.
std::qsort(marks, 5, sizeof(int), [](const void* a, const void* b) {
int x = *static_cast<const int*>(a);
int y = *static_cast<const int*>(b);
return (y > x) - (y < x); // descending
});
for (int m : marks) std::printf("%d ", m);
std::printf("\n");
} // c_sort_capture.cpp: a lambda that captures has no function pointer to give, so qsort cannot take it.
#include <cstdlib>
int main() {
int marks[]{72, 38, 91};
bool descending = true;
std::qsort(marks, 3, sizeof(int), [descending](const void* a, const void* b) {
int x = *static_cast<const int*>(a);
int y = *static_cast<const int*>(b);
return descending ? (y > x) - (y < x) : (x > y) - (x < y);
});
} from session import run
run("""
clang++ -std=c++23 -Wall -Wextra c_sort.cpp -o c_sort && ./c_sort
clang++ -std=c++23 -fsyntax-only c_sort_capture.cpp 2>&1 | grep -E 'error|note: candidate function not viable'
clang++ -std=c++23 -Wall -Wextra functor.cpp -o functor && ./functor
g++-16 -std=c++23 -Wall -Wextra functor.cpp -o functor && ./functor
""") Output
$ clang++ -std=c++23 -Wall -Wextra c_sort.cpp -o c_sort && ./c_sort 38 40 55 72 91 91 72 55 40 38 $ clang++ -std=c++23 -fsyntax-only c_sort_capture.cpp 2>&1 | grep -E 'error|note: candidate function not viable' c_sort_capture.cpp:7:39: error: no viable conversion from '(lambda at c_sort_capture.cpp:7:39)' to 'int (* _Nonnull)(const void *, const void *)' 1 error generated. $ clang++ -std=c++23 -Wall -Wextra functor.cpp -o functor && ./functor 2 above 60, 5 checked highest first: 91 ... 38 std::invoke: false $ g++-16 -std=c++23 -Wall -Wextra functor.cpp -o functor && ./functor 2 above 60, 5 checked highest first: 91 ... 38 std::invoke: false
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 c_sort_session.py
A lambda without captures converts to a function pointer, so qsort accepts it. A lambda that captures has data to
carry and no plain function to point at, so the conversion fails at compile time. That is why C callback interfaces
pass a void* “user data” pointer alongside the function pointer, as notify_c_style does in the dispatcher. Step
through that call and watch how main’s total reaches a function with no state of its own:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- Two arguments for one job. main calls notify_c_style() with the orders, the address of the function add_to_total and &total as a void*. A function pointer carries no state, so the caller's data travels beside it.
- One call per order. For each of the 3 orders, notify_c_style() calls callback(&orders[i], user_data). It knows nothing about what the data is.
- The data comes back. add_to_total() casts user_data back to long* and adds the order's amount: 49900, then 12500, then 300000 paise.
- The total. After the third order total is 362400 paise, and main prints function pointer: total 362400 paise.
- A capturing lambda will not do. A lambda that captures has data of its own and no plain function to point at, so it does not convert. Clang refused the capturing comparison in c_sort_capture.cpp with no viable conversion.
Your state travels through the void*, and the callback casts it back to the type it really is, here long*. Nothing
checks that cast, so the function and the data must be passed together, by the same caller, every time.
Checkpoint
Check: Function pointers and void* data
std::function stores any callable, sometimes on the heap
std::function hides the callable’s real type, which is what lets one vector hold a lambda, a function object and a
function. To do that it keeps a small buffer inside itself, and when the callable does not fit there, it allocates
memory on the heap. This program counts every allocation:
// allocations.cpp: when does storing a callable in std::function allocate memory?
// The program counts every call of operator new while it builds each std::function.
#include <array>
#include <cstdio>
#include <cstdlib>
#include <functional>
#include <new>
#include <string>
static int allocations = 0;
void* operator new(std::size_t n) {
++allocations;
if (void* p = std::malloc(n)) return p;
throw std::bad_alloc{};
}
void operator delete(void* p) noexcept { std::free(p); }
void operator delete(void* p, std::size_t) noexcept { std::free(p); }
template <std::size_t N>
void store_capture_of() {
std::array<char, N> data{}; // the lambda copies N bytes
allocations = 0;
std::function<int()> f = [data] { return data[0]; };
std::printf("lambda capturing %2zu bytes: %d allocation(s)\n", N, allocations);
}
int plain() { return 1; }
int main() {
std::printf("sizeof(std::function<int()>) = %zu bytes\n", sizeof(std::function<int()>));
store_capture_of<8>();
store_capture_of<16>();
store_capture_of<17>();
store_capture_of<64>();
allocations = 0;
std::function<int()> from_pointer = plain;
std::printf("function pointer: %d allocation(s)\n", allocations);
std::string name = "a name too long for the short-string buffer";
allocations = 0;
std::function<std::size_t()> from_string = [name] { return name.size(); };
std::printf("lambda copying a long std::string: %d allocation(s)\n", allocations);
} from session import run
run("""
clang++ -std=c++23 -Wall -Wextra allocations.cpp -o allocations && ./allocations
g++-16 -std=c++23 -Wall -Wextra allocations.cpp -o allocations && ./allocations
""") Output
$ clang++ -std=c++23 -Wall -Wextra allocations.cpp -o allocations && ./allocations sizeof(std::function<int()>) = 32 bytes lambda capturing 8 bytes: 0 allocation(s) lambda capturing 16 bytes: 0 allocation(s) lambda capturing 17 bytes: 1 allocation(s) lambda capturing 64 bytes: 1 allocation(s) function pointer: 0 allocation(s) lambda copying a long std::string: 2 allocation(s) $ g++-16 -std=c++23 -Wall -Wextra allocations.cpp -o allocations && ./allocations sizeof(std::function<int()>) = 32 bytes lambda capturing 8 bytes: 0 allocation(s) lambda capturing 16 bytes: 0 allocation(s) lambda capturing 17 bytes: 1 allocation(s) lambda capturing 64 bytes: 1 allocation(s) function pointer: 0 allocation(s) lambda copying a long std::string: 2 allocation(s)
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 allocations_session.py
On both standard libraries a std::function is 32 bytes, and in these runs it held a small callable without
allocating: a function pointer, or a lambda with up to 16 bytes of simple captures such as two pointers or an int
and a double. Step through the callables of the program from small to large:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- The object itself. sizeof(std::function<int()>) printed 32 bytes with libc++ and with libstdc++. Whatever callable it stores, the std::function object stays this size.
- Small callables fit. A lambda that captures 8 bytes, and one that captures 16: 0 allocations each. So is a plain function pointer. In these runs the std::function kept them inside itself.
- One byte more. A lambda that captures 17 bytes cost 1 allocation, and so did one of 64 bytes: the closure no longer fits the room inside, so it is put on the heap.
- Two allocations. The lambda that copies a long std::string cost 2: one for the string's own copy of its characters, and one for the closure, 24 or 32 bytes, which is too big to keep inside.
- What it means. Building a std::function from a large callable costs a heap allocation, and so does copying it. A template parameter keeps the callable as it is and allocates nothing.
A 17-byte capture already cost an allocation. The lambda that copies a long std::string cost two: one for the
string’s copy and one because the closure, 24 or 32 bytes, does not fit the buffer.
Checkpoint
Check: What std::function stores
A template’s call can be inlined; a stored callable’s cannot
The cost of a call is the other half. This benchmark sums a small calculation over 10 million prices, calling it through each of the three forms in an optimised build:
// bench.cpp: the same callback called 10 million times through a template, a std::function and a function pointer.
#include <chrono>
#include <cstdio>
#include <functional>
#include <vector>
int with_tax(int paise) { return paise + paise * 18 / 100; }
template <typename F>
long long through_template(F f, const std::vector<int>& prices) {
long long sum = 0;
for (int p : prices) sum += f(p);
return sum;
}
[[gnu::noinline]] long long through_function(const std::function<int(int)>& f, const std::vector<int>& prices) {
long long sum = 0;
for (int p : prices) sum += f(p);
return sum;
}
[[gnu::noinline]] long long through_pointer(int (*f)(int), const std::vector<int>& prices) {
long long sum = 0;
for (int p : prices) sum += f(p);
return sum;
}
template <typename Run>
void time_it(const char* label, Run run, std::size_t calls) {
long long best = -1;
long long result = 0;
for (int round = 0; round < 7; ++round) { // the fastest of 7 rounds, to skip warm-up noise
auto start = std::chrono::steady_clock::now();
result = run();
auto ns = std::chrono::duration_cast<std::chrono::nanoseconds>(std::chrono::steady_clock::now() - start).count();
if (best < 0 || ns < best) best = ns;
}
std::printf("%-14s %5.2f ns per call (sum %lld)\n", label, static_cast<double>(best) / calls, result);
}
int main() {
std::vector<int> prices(10'000'000);
for (std::size_t i = 0; i < prices.size(); ++i) prices[i] = static_cast<int>(i * 7919 % 100'000);
int (*volatile chosen)(int) = with_tax; // volatile: the compiler cannot see which function it holds
std::function<int(int)> stored = with_tax;
time_it("template", [&] { return through_template([](int p) { return with_tax(p); }, prices); }, prices.size());
time_it("std::function", [&] { return through_function(stored, prices); }, prices.size());
time_it("pointer", [&] { return through_pointer(chosen, prices); }, prices.size());
} from session import run
# Optimised builds (-O2): the fastest of 7 rounds of 10 million calls each, per compiler.
run("""
clang++ -std=c++23 -O2 bench.cpp -o bench_clang
./bench_clang
g++-16 -std=c++23 -O2 bench.cpp -o bench_gcc
./bench_gcc
""") Output
$ clang++ -std=c++23 -O2 bench.cpp -o bench_clang $ ./bench_clang template 0.29 ns per call (sum 589989200000) std::function 1.67 ns per call (sum 589989200000) pointer 1.37 ns per call (sum 589989200000) $ g++-16 -std=c++23 -O2 bench.cpp -o bench_gcc $ ./bench_gcc template 0.38 ns per call (sum 589989200000) std::function 1.70 ns per call (sum 589989200000) pointer 1.36 ns per call (sum 589989200000)
This output changes from run to run: it measures time, which differs a little from run to run and a lot from one computer to another; the computer of this run is named below the output.
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 bench_session.py
Step through what the compiler knows about the callable in each of the three loops:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- The same work three times. All three loops add with_tax(p) over the same 10 million prices, and all three sums are 589989200000, so they do exactly the same work.
- A template sees the callable. In through_template, F is the lambda's own type, so the compiler knows which code runs. It can inline with_tax into the loop and optimise the loop as a whole: 0.29 ns per price with Clang.
- std::function hides it. through_function gets a std::function, which hides the callable's type. The loop must make a real call through it for every price: 1.67 ns.
- A pointer it cannot see through. through_pointer gets a function pointer read from a volatile variable, so the compiler cannot know its target either: a real call for every price, 1.37 ns.
- GCC agrees. GCC's build gave 0.38, 1.70 and 1.36 ns: the same picture. About a nanosecond a call matters in a loop over millions of items, and not at all for a handler called a few times a second.
The template version is several times faster per call, but not because calling is slow: the compiler could see the
callable, inline it into the loop and optimise the loop as a whole, for example by working on several prices at once.
Through std::function, or a pointer it cannot see through, the compiler must make a real call for every price. The
differences, about a nanosecond per call, matter in a loop over millions of items and not at all for a handler that
runs a few times per second.
Key idea
The cost of std::function is the call the compiler cannot see through, plus an allocation for a large callable:
pay it for callables you keep, not for those you only call during the call.
Checkpoint
Check: The cost of a call
Newer wrappers fill two gaps of std::function
std::function must be able to copy what it stores, so it rejects a lambda that owns a std::unique_ptr:
// move_only.cpp: std::function needs a copyable callable; a lambda that owns a unique_ptr is not copyable.
#include <functional>
#include <memory>
#include <string>
int main() {
auto receipt = std::make_unique<std::string>("receipt 4271");
std::function<void()> job = [r = std::move(receipt)] { (void)r; };
} Two newer wrappers fill the gaps. std::move_only_function (C++23) stores callables that can be moved but not
copied. std::function_ref (C++26) does not store anything: it refers to a callable that someone else owns, which
makes it a cheap parameter type for a callable used during the call, without a template. Both depend on the
standard library, so this file tests their feature-test macro: A macro such as __cpp_modules or __cpp_lib_modules whose value, a year and month such as 202207, says that a compiler or its library claims a feature. A toolchain may build a feature without defining its macro, or define the macro without the feature. More in “C++20 modules: export, import and partitions”:
// wrappers.cpp: the newer callable wrappers, used only where the library has them.
#include <version>
#include <functional>
#include <iostream>
#include <memory>
#include <string>
int main() {
auto receipt = std::make_unique<std::string>("receipt 4271");
#if defined(__cpp_lib_move_only_function)
// C++23: owns any callable, including move-only ones, and can be stored.
std::move_only_function<void()> job = [r = std::move(receipt)] { std::cout << "printing " << *r << '\n'; };
job();
#else
std::cout << "std::move_only_function: not in this library\n";
#endif
#if defined(__cpp_lib_function_ref)
// C++26: refers to a callable without owning or copying it; never store it beyond the callable's life.
auto shout = [](const std::string& s) { return s + "!"; };
std::function_ref<std::string(const std::string&)> ref = shout;
std::cout << "function_ref says " << ref("namaste") << '\n';
#else
std::cout << "std::function_ref: not in this library\n";
#endif
} from session import run
# GCC's errors for move_only.cpp quote library internals, so only their first error line is kept.
run("""
clang++ -std=c++23 -fsyntax-only move_only.cpp 2>&1 | grep -m 1 'error'
g++-16 -std=c++23 -fsyntax-only move_only.cpp 2>&1 | grep -m 1 'error'
clang++ -std=c++26 -Wall -Wextra wrappers.cpp -o wrappers && ./wrappers
g++-16 -std=c++23 -Wall -Wextra wrappers.cpp -o wrappers && ./wrappers
g++-16 -std=c++26 -Wall -Wextra wrappers.cpp -o wrappers && ./wrappers
""") Output
$ clang++ -std=c++23 -fsyntax-only move_only.cpp 2>&1 | grep -m 1 'error' /Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/c++/v1/__functional/function.h:161:59: error: call to implicitly-deleted copy constructor of '(lambda at move_only.cpp:8:33)' $ g++-16 -std=c++23 -fsyntax-only move_only.cpp 2>&1 | grep -m 1 'error' /opt/homebrew/Cellar/gcc/16.2.0/include/c++/16/bits/std_function.h:429:69: error: static assertion failed: std::function target must be copy-constructible $ clang++ -std=c++26 -Wall -Wextra wrappers.cpp -o wrappers && ./wrappers std::move_only_function: not in this library std::function_ref: not in this library $ g++-16 -std=c++23 -Wall -Wextra wrappers.cpp -o wrappers && ./wrappers printing receipt 4271 std::function_ref: not in this library $ g++-16 -std=c++26 -Wall -Wextra wrappers.cpp -o wrappers && ./wrappers printing receipt 4271 function_ref says namaste!
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 wrappers_session.py
Both compilers rejected move_only.cpp, in their own words. libc++, the library of Apple clang 21, has neither
wrapper, in C++23 or C++26 mode, and its C++23 status page does not mark move_only_function as done. GCC 16 has
std::move_only_function in C++23 mode and adds std::function_ref in C++26 mode. Code for both compilers therefore
needs the #if fallback shown, or a template parameter instead. Because a function_ref only refers to its callable,
never keep one beyond the life of the callable it refers to, exactly like a reference captured by a lambda. Walk
through the standard library’s callable helpers, edition by edition:
- C++11
std::function and std::bind
std::function stores any copyable callable behind one type; std::bind builds a callable by fixing some arguments of another. Both libraries of this lesson have them.
- C++17
std::invoke
std::invoke calls anything callable, a pointer to a member function included, with the object as its first argument (paper N4169), as functor.cpp does with Item::passed.
- C++23
std::move_only_function not in libc++
Stores callables that can be moved but not copied, such as a lambda that owns a std::unique_ptr (paper P0288R9). GCC 16's library has it; Apple clang 21's libc++ does not.
- C++26
std::function_ref not in libc++
Refers to a callable that someone else owns and stores nothing, a cheap parameter type for a callable used during the call (paper P0792R14). GCC 16 has it in C++26 mode; libc++ does not.
The timeline has 4 events, from C++11 to C++26.
Checkpoint
Check: move_only_function and function_ref
Three questions choose how a function takes a callable
The whole lesson comes down to three questions, asked in this order:
Choosing how a function accepts a callable
Text description of the diagram
The diagram asks three questions in order, each with its answer to the right.
- Will a C API call the callable? If yes, use a function pointer, plus a void* parameter that carries the caller's data.
- If not, is the callable kept after the call returns? If not, because the function only calls it during the call, as std::sort does, use a template parameter. It is the fastest choice, because the compiler can inline the call.
- If it is kept, is it always copyable? If yes, use std::function. If it may own something that cannot be copied, such as a std::unique_ptr, use std::move_only_function from C++23, where the standard library provides it.
The diagram gives the answer for the usual cases. For a function of your own, answer the same questions and one more, which standard library the code must build with, and the table marks the types that fit, with this lesson’s numbers:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| Parameter type | Cost of a call | Can be kept | C code can call it | Takes move-only lambdas | In Apple clang 21's libc++ |
|---|---|---|---|---|---|
| A template parameter The function receives: the callable itself, with its own type | Good 0.29 ns a call: inlined into the loop | Poor only inside another template | Poor C has no templates | Good takes any callable as it is | Good plain C++, every compiler |
| std::function The function receives: any copyable callable, behind one type | Mixed 1.67 ns a call, through a hidden type | Good a vector of them is the Dispatcher | Poor C cannot call a std::function | Poor rejected the lambda that owns a unique_ptr | Good in both libraries, since C++11 |
| Function pointer + void* The function receives: an ordinary function's address, and the data | Mixed 1.37 ns a call when the target is hidden | Good an address that copies freely | Good qsort took it, from a lambda without captures | Poor no captures at all | Good plain C and C++ |
| std::move_only_function The function receives: any callable, even a move-only one (C++23) | Mixed a call through a hidden type, as std::function | Good owns what it stores | Poor C cannot call it | Good made for them: printing receipt 4271 | Poor not in libc++: the #if fallback ran |
| std::function_ref The function receives: a reference to a callable (C++26) | Mixed a call through a hidden type; it allocates nothing | Poor it only refers: never keep it | Poor C cannot call it | Good refers to any callable | Poor only GCC 16, in C++26 mode |
How the answers decide
- “Will a C library call it?” Yes, a C API calls back: rules out A template parameter, std::function, std::move_only_function and std::function_ref. Only a function pointer crosses into C. The caller's data travels beside it as void*, as in notify_c_style().
- “Is the callable kept after your function returns, as by an event bus?” Yes, it is stored: rules out A template parameter. A template parameter can be kept only inside another template, so a class such as Dispatcher cannot store it.
- “Is the callable kept after your function returns, as by an event bus?” Yes, it is stored: rules out std::function_ref. A function_ref only refers to its callable, so a kept one dangles as soon as the callable is gone.
- “Is the callable kept after your function returns, as by an event bus?” No, only called during the call: adds a caveat to std::function and std::move_only_function. A callable that is only called during the call gains nothing from being stored: 1.67 ns a call against 0.29 ns, and sometimes an allocation.
- “May callers pass a lambda that owns something that cannot be copied, such as a std::unique_ptr?” Yes, move-only lambdas too: rules out std::function. std::function needs a copyable callable: both compilers rejected move_only.cpp.
- “May callers pass a lambda that owns something that cannot be copied, such as a std::unique_ptr?” Yes, move-only lambdas too: rules out Function pointer + void*. A function pointer takes no captures at all, so it cannot hold anything a lambda owns.
- “Must the code build with Apple clang 21 and its libc++?” Yes, macOS with libc++: rules out std::move_only_function and std::function_ref. Apple clang 21's libc++ has neither wrapper, in C++23 or C++26 mode: wrappers.cpp printed not in this library twice.
In short: a function pointer with a void* when C calls back, a template parameter when the callable is used only
during the call, and std::function when it is kept. A kept callable that may be move-only needs
std::move_only_function, which GCC 16’s library has and libc++ does not; on libc++, keep the #if fallback of
wrappers.cpp.
Checkpoint
Check: Choosing the parameter type
Practise: an event bus
Write an EventBus with three member functions. subscribe stores a handler, any callable that takes an event
name and an int value, and returns an id. unsubscribe removes the handler with that id. publish calls every
handler with an event. Store the handlers in a std::vector of std::function, so lambdas, function objects and
plain functions all fit. This test must print what the session below shows, ending in handlers 3:
// bus_test.cpp: the test. Three kinds of handler, then one that unsubscribes itself while it runs.
#include <iostream>
#include <string>
#ifdef NAIVE
# include "event_bus_naive.h"
#else
# include "event_bus.h"
#endif
struct Tally { // a function object that adds up the values it sees
int* total;
void operator()(const std::string&, int value) const { *total += value; }
};
void print_event(const std::string& event, int value) { // a plain function
std::cout << "log: " << event << ' ' << value << '\n';
}
int main() {
int total = 0; // declared before the bus, so it outlives every handler that refers to it
EventBus bus;
bus.subscribe(print_event);
bus.subscribe(Tally{&total});
int alert_id = bus.subscribe([](const std::string& event, int value) {
if (value > 100) std::cout << "alert: large " << event << '\n';
});
bus.publish("order", 40);
bus.publish("order", 250);
bus.unsubscribe(alert_id);
bus.publish("refund", 500);
std::cout << "total " << total << ", handlers " << bus.size() << '\n';
int once_id = 0;
once_id = bus.subscribe([&bus, &once_id](const std::string& event, int) {
std::cout << "once: " << event << '\n';
bus.unsubscribe(once_id); // removes itself while publish() is running
});
bus.subscribe([](const std::string& event, int) { std::cout << "last: " << event << '\n'; });
bus.publish("ping", 1);
bus.publish("pong", 2);
std::cout << "handlers " << bus.size() << '\n';
} The last part of the test is the hard case: a handler that unsubscribes itself while publish is running. Write
publish the obvious way first, a range-for over the vector, and run the test with -fsanitize=address. Then fix it.
event_bus.h (fixed) · event_bus.h
// event_bus.h: one possible EventBus. Handlers are stored as std::function, so the bus accepts
// lambdas, function objects and plain functions alike.
#ifndef EVENT_BUS_H
#define EVENT_BUS_H
#include <algorithm>
#include <functional>
#include <string>
#include <utility>
#include <vector>
class EventBus {
public:
using Handler = std::function<void(const std::string& event, int value)>;
// Stores the handler and returns an id that unsubscribe() accepts.
int subscribe(Handler handler) {
handlers_.emplace_back(next_id_, std::move(handler));
return next_id_++;
}
void unsubscribe(int id) {
std::erase_if(handlers_, [id](const auto& entry) { return entry.first == id; });
}
// Calls every handler subscribed when the call began. It loops over a copy of the list, so a
// handler may subscribe or unsubscribe during the call; the change applies from the next publish().
void publish(const std::string& event, int value) const {
const auto snapshot = handlers_;
for (const auto& [id, handler] : snapshot) handler(event, value);
}
std::size_t size() const { return handlers_.size(); }
private:
std::vector<std::pair<int, Handler>> handlers_;
int next_id_ = 1;
};
#endif // EVENT_BUS_H event_bus_naive.h
// event_bus_naive.h: the same EventBus with the obvious publish(): it loops over the live list.
// A handler that unsubscribes during the loop changes the vector the loop is walking.
#ifndef EVENT_BUS_NAIVE_H
#define EVENT_BUS_NAIVE_H
#include <algorithm>
#include <functional>
#include <string>
#include <utility>
#include <vector>
class EventBus {
public:
using Handler = std::function<void(const std::string& event, int value)>;
// Stores the handler and returns an id that unsubscribe() accepts.
int subscribe(Handler handler) {
handlers_.emplace_back(next_id_, std::move(handler));
return next_id_++;
}
void unsubscribe(int id) {
std::erase_if(handlers_, [id](const auto& entry) { return entry.first == id; });
}
void publish(const std::string& event, int value) const {
for (const auto& [id, handler] : handlers_) handler(event, value); // the live list
}
std::size_t size() const { return handlers_.size(); }
private:
std::vector<std::pair<int, Handler>> handlers_;
int next_id_ = 1;
};
#endif // EVENT_BUS_NAIVE_H from session import run
# The bus that loops over a copy of its list, with both compilers (Clang with the sanitizers);
# then the naive bus with the same test: the handler that removes itself breaks the loop.
run("""
clang++ -std=c++23 -Wall -Wextra -g -fsanitize=address,undefined bus_test.cpp -o bus_demo && ./bus_demo
g++-16 -std=c++23 -Wall -Wextra bus_test.cpp -o bus_demo && ./bus_demo | tail -n 1
clang++ -std=c++23 -Wall -Wextra -g -fsanitize=address -DNAIVE bus_test.cpp -o naive
./naive
""", max_lines=40) Output
$ clang++ -std=c++23 -Wall -Wextra -g -fsanitize=address,undefined bus_test.cpp -o bus_demo && ./bus_demo
log: order 40
log: order 250
alert: large order
log: refund 500
total 790, handlers 2
log: ping 1
once: ping
last: ping
log: pong 2
last: pong
handlers 3
$ g++-16 -std=c++23 -Wall -Wextra bus_test.cpp -o bus_demo && ./bus_demo | tail -n 1
handlers 3
$ clang++ -std=c++23 -Wall -Wextra -g -fsanitize=address -DNAIVE bus_test.cpp -o naive
$ ./naive
log: order 40
log: order 250
alert: large order
log: refund 500
total 790, handlers 2
log: ping 1
once: ping
=================================================================
==…==ERROR: AddressSanitizer: container-overflow on address 0x…
READ of size 8 at 0x… thread T0
#2 in EventBus::publish(std::__1::basic_string<char, std::__1::char_traits<char>, std::__1::allocator<char>> const&, int) const event_bus_naive.h:27
#3 in main bus_test.cpp:41
0x… is located 152 bytes inside of 160-byte region [0x…,0x…)
#12 in EventBus::subscribe(std::__1::function<void (std::__1::basic_string<char, std::__1::char_traits<char>, std::__1::allocator<char>> const&, int)>) event_bus_naive.h:18
#13 in main bus_test.cpp:25
SUMMARY: AddressSanitizer: container-overflow
[53 more lines of the report left out]
[exit status -6]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 bus_session.py
Step through publish("ping", 1) on the obvious bus and watch the slot the loop has just finished:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- Four handlers. Before publish("ping", 1), the bus holds four handlers: print_event, the Tally, the once handler and the last one. The alert handler was unsubscribed earlier.
- Slot 0. The obvious publish() loops over the live vector with a range-for. Slot 0, print_event, prints log: ping 1.
- Slot 1. Slot 1, the Tally, adds 1 to total and prints nothing.
- once removes itself. Slot 2 prints once: ping and calls bus.unsubscribe(once_id). erase_if removes slot 2, and the last handler moves down into slot 2, the slot the loop has just finished.
- One past the end. The loop goes on to slot 3, which is past the new end of the vector. AddressSanitizer stops the program with a container-overflow in publish(); last: ping is never printed.
- The fix. The fixed publish() loops over a copy of the vector. once's removal changes the vector but not the copy, so last: ping is printed, and the removal applies from the next publish(): last: pong, handlers 3.
In the obvious version, the “once” handler erased itself from the vector while the loop was walking it. The vector
shifted the last handler into the slot the loop had just finished, so “last: ping” never printed, and the loop then
read one slot past the new end of the vector, which AddressSanitizer reports as a container-overflow in
publish(). The fixed version loops over a copy of the list, so a handler may subscribe or unsubscribe during the
call and the change applies from the next publish. The copy costs one std::function copy per handler per event,
which is fine for a few handlers; a bus with thousands of handlers would instead record removals made during
publish and apply them after the loop.
With the bus fixed, test the eight ideas of this lesson. The questions begin with the one you are least sure of and explain every choice, the wrong ones included:
Practice that adapts to you
Practise this lesson
17 questions about 8 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Fresher · What is a function object (functor), and how is it different from a function pointer?
A function object is an object of a class that defines operator(), so it can be called like a function. Unlike a
function pointer, it can carry state in its data members, such as a limit to compare with or a count of calls, and
its type tells the compiler exactly which code runs, so a template that receives it can inline the call. A function
pointer holds only the address of an ordinary function: no state, an indirect call unless the compiler can see the
target, and the only form a C library accepts. Every lambda is a function object whose class the compiler writes,
and a lambda without captures can also convert to a function pointer.
Follow-up questions usually turn the definition into a decision. Choose your answers, then read what each option gets right or misses:
Checkpoint
Interview round: two follow-up questions
Key takeaways
- A function can accept behaviour as a template parameter, a
std::functionor a function pointer, and the choice decides whether it can keep the callable, whether C can call it and what each call costs. - Function objects carry state; algorithms copy them, so pass
std::refwhen you need the state afterwards, and prefer a lambda tostd::bind. - C callbacks are function pointers with a
void*for the caller’s data; only lambdas without captures convert to them. std::functionis 32 bytes on both libraries here, holds a small callable without allocating and puts a larger one on the heap, and each call through it is an indirect call of 1 to 2 ns on the machine of these runs.std::move_only_function(C++23) andstd::function_ref(C++26) exist in GCC 16’s library, not in Apple clang 21’s libc++; guard them with feature-test macros.- A callback that changes the list it was called from needs care: loop over a copy, or defer the change.
Terms in this lesson
- callback
- Behaviour passed into a function for it to call: the comparison std::sort uses, a handler an event bus stores, the function a C library calls back. C++ takes one as a template parameter, a std::function or a function pointer.
- function object
- An object of a class that defines operator(), so it can be called like a function and can carry state in its members, as a counter of calls. Every lambda is one, of a class the compiler writes. Also called a functor.
- function pointer
- A variable that holds the address of an ordinary function, such as int (*cmp)(const void*, const void*). It carries no state, which is why C interfaces pass a void* for the caller's data beside it. Only lambdas without captures convert to one.
References
- Working Draft, Programming Languages: C++ (N4950), 22.10 Function objects and 7.5.5 Lambda expressions (ISO/IEC JTC1/SC22/WG21)
- P0288R9: move_only_function (ISO/IEC JTC1/SC22/WG21)
- P0792R14: function_ref, a type-erased callable reference (ISO/IEC JTC1/SC22/WG21)
- libc++ C++23 Status (LLVM Project)
- The GNU C++ Library Manual: Status (GNU Project)
- C++ Core Guidelines (F.50, T.40) (Standard C++ Foundation)
- AddressSanitizer (LLVM Project)
Report a problem with this lesson
Kept only in this browser. Your Learn progress