Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 20 minutes
  • Examples run with Clang 21.0.0, GCC 16.2.0 and Python 3.14.8
  • By MySmartCoPilot
Free: 20 minutes

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 C++ · dispatcher.cpp
// 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);
}
The same orders through all three Python · dispatcher_session.py
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:

Three ways for a function to accept a callable
Criterion Template parameterstd::functionFunction pointer
Accepts any callableany copyable callable with a matching signatureordinary functions and lambdas without captures
Can be stored only inside a templateyes: a vector can hold them allyes: an address
State the callable's ownthe callable's ownnone: a void* travels beside it
Cost of a call well under 1 ns: inlinedan indirect call, 1 to 2 nsan indirect call, 1 to 2 ns
Heap allocation neverfor callables too big for its buffernever
C code can call it nonoyes
When to choose Called only during the call, as std::sort doesKept for later, as by an event busA 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

  1. Question 1 of 2 The Dispatcher keeps its handlers in a vector and calls them on every publish(). Which parameter type does subscribe() take?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: std::function<void(const Order&)>

    A callable that is kept for later, and that may be any lambda or function object, needs a type that can hold them all. That is std::function, at the price of an indirect call and sometimes an allocation.

  2. Question 2 of 2 The dispatcher published 3 orders to its two subscribers. What count did the SMS subscriber print?

    Type a number.

    Show the answer: Question 2 of 2

    Answer: 3 SMS

    Every publish() calls every stored handler, so the SMS lambda ran once per order and counted 3, as the line std::function: 3 SMS sent shows.

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 C++ · functor.cpp
// 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:

Interactive · step by step A function object with state, copied or referred to Step through count_if with the function object above_60. Watch which object counts the checks.
A function object with state, copied or referred to, step 5 of 5. Showing above_60 in main (limit 60, checked 5), std::ref(above_60), What main reads (2 above 60, 5 checked). In focus: What main reads.refers toabove_60 in mainlimit 60, checked 5std::ref(above_60)not madeWhat main reads2 above 60, 5 checked

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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 C++ · bind_or_lambda.cpp
// 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';
}
Both give 11800, and bind accepts nonsense Python · bind_session.py
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

  1. Question 1 of 2 functor.cpp passes std::ref(above_60) to std::count_if. What would above_60.checked() report without std::ref?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: 0, because count_if would have counted with its own copy of the object.

    Standard algorithms take their function object by value. The copy inside count_if would have counted the 5 checks, and it is destroyed when count_if returns, leaving the original at 0. std::ref passes a wrapper that refers to the original instead.

  2. Question 2 of 2 What did gst_bind(10000, 999, "ignored") do in bind_or_lambda.cpp?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: It compiled and returned 11800, ignoring the two arguments it has no placeholder for

    A lambda says the same as std::bind more plainly, and it refuses arguments it does not take. That is why bind is rarely needed since C++11.

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 C++ · c_sort.cpp
// 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 C++ · c_sort_capture.cpp
// 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);
    });
}
qsort, then a lambda that captures, then the function object Python · c_sort_session.py
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:

Interactive · step by step A C-style callback and the void* that carries the data Step through the call of notify_c_style(). Watch how main's total reaches a function that has no state of its own.
A C-style callback and the void* that carries the data, step 5 of 5. Showing main (total = 362400), notify_c_style(), add_to_total(), void* user_data (the address of total). In focus: add_to_total().pointer + &totalcallback(order, data)points at totalmaintotal = 362400notify_c_style()loops over 3 ordersadd_to_total()a plain functionvoid* user_datathe address of totalcaptures: no conversion to a pointer

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. The total. After the third order total is 362400 paise, and main prints function pointer: total 362400 paise.
  5. 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

  1. Question 1 of 2 Why does std::qsort reject a comparison lambda written as [descending](const void* a, const void* b) { … }?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: It captures a variable, so it has no plain function to convert to, and qsort takes a function pointer.

    A lambda without captures converts to a function pointer, which is why the descending sort in c_sort.cpp worked. Once it captures, the lambda carries data a plain function cannot hold, so there is no conversion. C callback APIs solve this with a separate void* user-data argument.

  2. Question 2 of 2 Why does notify_c_style() take a void* user_data beside the function pointer?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: A function pointer carries no state, so the caller's data, here &total, travels separately and the callback casts it back

    A C interface cannot know the caller's types, so it passes an untyped pointer back to the callback. The caller and the callback must agree on what it really points to, because nothing checks the cast.

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 C++ · allocations.cpp
// 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);
}
Allocations while building a std::function Python · allocations_session.py
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:

Interactive · step by step Where a std::function keeps its callable Step through the callables of allocations.cpp, from small to large. Watch which ones fit inside the std::function and which cost an allocation.
Where a std::function keeps its callable, step 5 of 5. Showing std::function<int()>, Kept inside (8 and 16 bytes, a pointer), On the heap (17 and 64 bytes: 1 each), A long std::string (2 allocations in all). In focus: std::function<int()>.small callableslarger onesits own charactersstd::function<int()>32 bytes on bothKept inside8 and 16 bytes, a pointerOn the heap17 and 64 bytes: 1 eachA long std::string2 allocations in all

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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

  1. Question 1 of 2 How many heap allocations did building a std::function from a lambda that captures 17 bytes cost, on both libraries?

    Type a number.

    A hint

    Compare with the lambda that captures 16 bytes.

    Show the answer: Question 1 of 2

    Answer: 1 allocation

    16 bytes of captures fitted inside the 32-byte std::function and cost nothing; 17 bytes no longer fitted, so the closure went to the heap: 1 allocation with libc++ and with libstdc++.

  2. Question 2 of 2 In the allocations session, how many heap allocations did building a std::function from a lambda that copies a long std::string cost?

    Type a number.

    Show the answer: Question 2 of 2

    Answer: 2

    One allocation copies the long string into the lambda, and one stores the closure, which at 24 or 32 bytes is too large for the room std::function keeps inside itself on these libraries.

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 C++ · bench.cpp
// 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());
}
10 million calls each, the fastest of 7 rounds Python · bench_session.py
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:

Interactive · step by step What the compiler can see in each loop of bench.cpp Step through the three ways bench.cpp calls with_tax for 10 million prices. Watch what the compiler knows about the callable in each.
What the compiler can see in each loop of bench.cpp, step 5 of 5. Showing 10 million prices, through_template (0.29 ns a call (Clang)), through_function (1.67 ns a call (Clang)), through_pointer (1.37 ns a call (Clang)). In focus: through_template, through_function, through_pointer.10 million pricessum += f(p) in each loopthrough_template0.29 ns a call (Clang)through_function1.67 ns a call (Clang)through_pointer1.37 ns a call (Clang)

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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

  1. Question 1 of 2 Why do all six lines of the benchmark print the same sum, 589989200000?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Every loop calls with_tax on the same 10 million prices; only the way of calling differs

    A benchmark that compares ways of calling must keep the work identical. The equal sums show it did, so the differences in nanoseconds come from the calls.

  2. Question 2 of 2 Why was the template version several times faster per call than the other two?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The compiler could see the callable, inline with_tax into the loop and optimise the loop as a whole

    Calling is not slow in itself. What the template gives is visibility: the compiler knows which code runs and can optimise the loop around it, for example by working on several prices at once.

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 C++ · move_only.cpp
// 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 C++ · wrappers.cpp
// 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
}
Which wrappers each library has Python · wrappers_session.py
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:

Timeline · 4 events in order The standard library's callable helpers, edition by edition Walk through the editions, and note which of these helpers each library of this lesson has.
  1. 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.

  2. 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.

  3. 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.

  4. 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

  1. Question 1 of 2 Why do both compilers reject std::function<void()> job = [r = std::move(receipt)] { (void)r; };?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: std::function must be able to copy what it stores, and a lambda that owns a unique_ptr cannot be copied

    std::function copies the callables it holds, so everything it stores must be copyable. std::move_only_function (C++23) lifts that requirement.

  2. Question 2 of 2 Which of these builds of wrappers.cpp found std::move_only_function?

    Choose every answer that is right.

    Show the answer: Question 2 of 2

    Answer:

    • GCC 16 in C++26 mode
    • GCC 16 in C++23 mode

    std::move_only_function is a C++23 feature. GCC 16's library has it from C++23 mode on and adds std::function_ref in C++26 mode. Apple clang 21's libc++ has neither, so the file's #if fallback ran.

Three questions choose how a function takes a callable

The whole lesson comes down to three questions, asked in this order:

C APIs take a function pointer; a callable used only during the call suits a template; a stored one needs std::function.1. Will a C APIcall it?2. If not, is it keptafter the call?3. If kept, is italways copyable?Yes: function pointer+ void* user dataNo: a templateparameter (inlined)Yes: std::functionNo: move_only_function

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.

  1. Will a C API call the callable? If yes, use a function pointer, plus a void* parameter that carries the caller's data.
  2. 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.
  3. 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:

Interactive · answer and compare Which parameter type fits your callable? Answer four questions about the callable your function takes. The table marks the types that fit and says why the others do not.
Will a C library call it?
Is the callable kept after your function returns, as by an event bus?
May callers pass a lambda that owns something that cannot be copied, such as a std::unique_ptr?
Must the code build with Apple clang 21 and its libc++?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

Five ways to accept a callable, against the five things that decide between them (numbers from this lesson's runs)
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

  1. Question 1 of 2 A function retry(int times, F action) calls action up to times times and returns. It never keeps the action. Which parameter type is the best default?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: A template parameter, so any callable is accepted and the call can be inlined.

    The function only calls the action during the call, so nothing needs storing. A template parameter accepts lambdas, function objects and functions alike and lets the compiler inline the call; std::function would add an indirect call and possibly an allocation for no benefit, and a function pointer would reject capturing lambdas.

  2. Question 2 of 2 A C library takes void (cb)(int event, void user_data) and a void* user_data. Your handler needs the C++ object that registered it. What do you pass?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: A plain function, or a lambda without captures, as cb, and the object's address as user_data, cast back inside

    When C calls back, the first question settles the type: a function pointer, with the caller's data in the void* that C interfaces provide for exactly this.

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 C++ · bus_test.cpp
// 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.

One possible solution, and the obvious version

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
The fixed bus, then the obvious one Python · bus_session.py
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:

Interactive · step by step A handler that removes itself while publish() runs Step through publish("ping", 1) on the naive bus. The boxes are the slots of the handlers vector; watch slot 2 when once removes itself.
A handler that removes itself while publish() runs, step 6 of 6. Showing slot 0: log, slot 1: tally, slot 2: once (once, removed later), slot 3: last (last: prints ping), The range-for (over a copy: safe), Printed so far (log: ping 1, once: ping). In focus: The range-for.slot 0: logprint_eventslot 1: tallyTally{&total}slot 2: onceonce, removed laterslot 3: lastlast: prints pingThe range-forover a copy: safePrinted so farlog: ping 1, once: ping

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. 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.
  2. Slot 0. The obvious publish() loops over the live vector with a range-for. Slot 0, print_event, prints log: ping 1.
  3. Slot 1. Slot 1, the Tally, adds 1 to total and prints nothing.
  4. 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.
  5. 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.
  6. 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.

  1. Question 1 of 17 Which of the three forms of this lesson is the only one a C library accepts?

    Choose one answer.

    Show the answer: Question 1 of 17

    Answer: A function pointer

    That is why C callback interfaces take a function pointer, and a void* beside it for the caller's data.

  2. Question 2 of 17 Why can the Dispatcher not keep its handlers as template parameters?

    Choose one answer.

    Show the answer: Question 2 of 17

    Answer: Each instantiation has one type, and a class that is not a template cannot keep callables of different types in one vector

    A template parameter is perfect for a callable used during the call; to keep several different callables, the type must be erased, which is what std::function does.

  3. Question 3 of 17 What makes Above in functor.cpp a function object?

    Choose one answer.

    Show the answer: Question 3 of 17

    Answer: Its operator(), which lets above_60(72) be called like a function

    A function object is an object of a class with an operator(). Every lambda is one, with a class the compiler writes.

  4. Question 4 of 17 With auto member = &Item::passed; and Item it{38};, what does std::invoke(member, it) return, if passed() checks marks >= 40?

    Choose one answer.

    Show the answer: Question 4 of 17

    Answer: false, by calling it.passed()

    std::invoke calls anything callable in one form: functions, function objects, and pointers to members with the object first. The program printed std::invoke: false.

  5. Question 5 of 17 Which kind of lambda can be passed to std::qsort?

    Choose one answer.

    Show the answer: Question 5 of 17

    Answer: A lambda without captures

    The descending sort of c_sort.cpp was a lambda without captures, and qsort accepted it like an ordinary function.

  6. Question 6 of 17 Inside add_to_total(), what must the callback do with user_data before it can use it?

    Choose one answer.

    Show the answer: Question 6 of 17

    Answer: Cast it back to the type it really points to, long*

    static_cast<long*>(user_data) gives back the caller's long. Nothing checks that cast, so the function and its data must always be passed together.

  7. Question 7 of 17 How many bytes was std::function<int()> on both standard libraries of this lesson?

    Type a number.

    Show the answer: Question 7 of 17

    Answer: 32 bytes

    sizeof(std::function<int()>) printed 32 bytes with libc++ and libstdc++. Callables that fit the room inside it cost no allocation.

  8. Question 8 of 17 A lambda captures two pointers, 16 bytes on these machines. Does building a std::function from it allocate on the libraries of this lesson?

    Choose one answer.

    Show the answer: Question 8 of 17

    Answer: No, because 16 bytes of simple captures fitted inside the std::function in these runs

    In these runs, callables of up to 16 bytes of simple captures were kept inside the std::function, and a 17-byte one went to the heap.

  9. Question 9 of 17 In the Clang build of bench.cpp, which form had the cheapest call?

    Choose one answer.

    Show the answer: Question 9 of 17

    Answer: The template, at 0.29 ns per call

    Clang's build measured 0.29, 1.67 and 1.37 ns, and GCC's 0.38, 1.70 and 1.36 ns: the same picture with both compilers.

  10. Question 10 of 17 A button handler runs a few times a second through std::function. Should you replace it with a template parameter for speed?

    Choose one answer.

    Show the answer: Question 10 of 17

    Answer: No, because about a nanosecond a call matters only over millions of calls, and a kept handler needs std::function anyway

    Choose by what the function does with the callable. The call cost matters in tight loops over many items, where the callable is used during the call anyway.

  11. Question 11 of 17 What must never happen to a std::function_ref?

    Choose one answer.

    Show the answer: Question 11 of 17

    Answer: It must not be kept beyond the life of the callable it refers to

    std::function_ref (C++26) stores nothing, which makes it cheap, and also why it dangles when the callable dies first.

  12. Question 12 of 17 Code must store a move-only callable and build with both compilers of this lesson. What do you write?

    Choose one answer.

    Show the answer: Question 12 of 17

    Answer: std::move_only_function behind #if defined(__cpp_lib_move_only_function), with a fallback for libc++

    A library feature that only one toolchain has is used behind its feature-test macro, with a fallback, and the fallback is tested too.

  13. Question 13 of 17 Which answers point to std::function as the parameter type?

    Choose one answer.

    Show the answer: Question 13 of 17

    Answer: The callable is kept after the function returns, and it is always copyable

    The questions settle it in order: C calls back, use a function pointer; used only during the call, a template; kept and copyable, std::function; kept and move-only, std::move_only_function where the library has it.

  14. Question 14 of 17 An event bus keeps handlers for the program's lifetime, some handlers own a std::unique_ptr, and the code builds with GCC 16 only. Which type holds the handlers?

    Choose one answer.

    Show the answer: Question 14 of 17

    Answer: std::move_only_function

    Kept and move-only points to std::move_only_function. With Apple clang 21 in the build as well, the #if fallback would be needed, because libc++ lacks it.

  15. Question 15 of 17 On the naive bus, why was last: ping never printed?

    Choose one answer.

    Show the answer: Question 15 of 17

    Answer: once erased itself, so last moved into the slot the loop had just finished, and the loop went on past the end

    Changing a vector while a range-for walks it moves elements under the loop. AddressSanitizer reported the read past the new end as a container-overflow.

  16. Question 16 of 17 What does the fixed publish() of event_bus.h loop over?

    Choose one answer.

    Show the answer: Question 16 of 17

    Answer: A copy of the handlers, taken when publish() starts

    Looping over a snapshot is the simple fix: a handler may subscribe or unsubscribe during the call, and the change applies from the next publish().

  17. Question 17 of 17 A bus has 10,000 handlers and publishes often. What does the fixed publish() cost, and what is the alternative?

    Choose one answer.

    Show the answer: Question 17 of 17

    Answer: A copy of 10,000 std::function objects per event; record the changes made during publish() and apply them after the loop

    The copy costs one std::function copy per handler per event, which is fine for a few handlers; with thousands, a bus records removals made during publish() and applies them after the loop.

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

  1. Question 1 of 2 The interviewer goes on: when would you still write a function object class rather than a lambda?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: When its state must be read afterwards or the same callable is used in several places, as Above's count of checks

    Lambdas cover most callables. A named class pays off when the state outlives the call and must be read, or when the same behaviour is reused in several places.

  2. Question 2 of 2 Then: can a call through a function pointer ever be inlined?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Yes, when the compiler can see which function the pointer holds; bench.cpp read the pointer from a volatile variable to prevent exactly that

    An indirect call is slow mainly because the compiler cannot see through it. Give it the target, through a template parameter or a known pointer, and it can optimise across the call.

Key takeaways

  • A function can accept behaviour as a template parameter, a std::function or 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::ref when you need the state afterwards, and prefer a lambda to std::bind.
  • C callbacks are function pointers with a void* for the caller’s data; only lambdas without captures convert to them.
  • std::function is 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) and std::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

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.