Embercode
✕
C++ Functions and ScopeLesson 17 of 17

40 min read

Callbacks and function pointers

Why this matters

Often a function does a fixed job, but one step of that job should change from one call to the next. A sort function needs to know which order to use. A loop that walks a list needs to know what to do with each item. A button needs to know what to do when someone clicks it.

In each case the caller should decide that one step, not the function itself. A callback is a function that you hand to another function so that the other function can call it later. This lesson shows the three ways to pass a callback in C++: a plain function pointer, a lambda that is passed as a function pointer, and std::function, which can hold almost any callable.

A callable is anything you can call with parentheses. That includes a function name, a lambda, and an object that has a call operator. Lambdas were covered in the lesson on lambdas, so this lesson uses them without explaining the syntax again.

The samples use the standard library and compile with g++ -std=c++20 -Wall. The output shown was produced by running them with g++ 15.2.0 on Linux.

A function can be passed like a value

Start with two small functions and one function that uses a callback.

#include <iostream>
 
int add_one(int n) {
    return n + 1;
}
 
int square(int n) {
    return n * n;
}
 
int apply_twice(int (*op)(int), int start) {
    return op(op(start));
}
 
int main() {
    std::cout << apply_twice(add_one, 5) << '\n';
    std::cout << apply_twice(square, 3) << '\n';
}

Output:

7
81

The parameter int (*op)(int) says that op is a function pointer. A function pointer is a variable that holds the address of a function. Read it in pieces. op is the name. The * says it is a pointer. The (int) at the end says the function it points to takes one int. The int before the parentheses says the function returns an int.

The parentheses around *op are required. They are the most common source of errors in this topic, and a later section shows what goes wrong without them.

Inside apply_twice, the line op(op(start)) calls the function that op points to. It does this twice. The first call gets start and returns a new value, and the second call takes that value and returns another one.

When the caller writes apply_twice(add_one, 5), the name add_one without parentheses refers to the function itself. C++ turns it into a function pointer because the parameter asks for one. The function add_one runs twice, so 5 becomes 6 and then 7.

The line apply_twice(square, 3) works the same way. 3 becomes 9, and 9 becomes 81.

Naming the pointer type

The pointer type is long to write each time. A using alias gives it a short name.

using IntOp = int (*)(int);
 
int apply_twice(IntOp op, int start) {
    return op(op(start));
}

IntOp means "a pointer to a function that takes an int and returns an int." The alias does not change how the code runs. It only makes the signature easier to read and easier to change in one place.

A lambda can be passed where a function pointer is expected

A lambda with an empty capture list, written [], does not use any variable from the code around it. Such a lambda can be converted to a plain function pointer. That means you can pass it straight to a function that expects a function pointer.

#include <iostream>
 
using IntOp = int (*)(int);
 
int apply_twice(IntOp op, int start) {
    return op(op(start));
}
 
int main() {
    IntOp triple = [](int n) { return n * 3; };
    std::cout << apply_twice(triple, 2) << '\n';
    std::cout << apply_twice([](int n) { return n - 4; }, 10) << '\n';
}

Output:

18
2

The first call starts at 2. The first step gives 2 times 3, which is 6. The second step gives 6 times 3, which is 18.

The second call starts at 10. The first step gives 10 minus 4, which is 6. The second step gives 6 minus 4, which is 2.

The variable triple has type IntOp. The assignment works because the lambda has no captures. A capture is a variable from the surrounding code that the lambda uses, written inside the square brackets. The next section shows what to do when a callback needs one.

When the callback needs data: std::function

A plain function pointer has nowhere to keep extra data. Suppose a callback should add up the length of every name it sees, and the total should live in main. A function pointer cannot do this unless the total is a global variable. Global variables make a program harder to follow, so they are a poor answer here.

std::function solves this. It is in the header <functional>. A std::function object can hold any callable with a matching signature. A signature is the list of parameter types and the return type. The lambda can capture variables, and the std::function keeps the lambda and its captured data together.

The type is written with the return type first and the parameter types in parentheses. For example, std::function<void(int, const std::string&)> holds a callable that returns nothing and takes an int and a string reference.

#include <functional>
#include <iostream>
#include <string>
#include <vector>
 
void for_each_item(const std::vector<std::string>& items,
                   const std::function<void(int, const std::string&)>& callback) {
    for (int i = 0; i < static_cast<int>(items.size()); ++i) {
        callback(i + 1, items[i]);
    }
}
 
int main() {
    std::vector<std::string> names = {"Asha", "Ravi", "Meera"};
    int total_chars = 0;
    for_each_item(names, [&total_chars](int position, const std::string& name) {
        std::cout << position << ". " << name << '\n';
        total_chars += static_cast<int>(name.size());
    });
    std::cout << "total characters: " << total_chars << '\n';
}

Output:

1. Asha
2. Ravi
3. Meera
total characters: 13

The function for_each_item does the looping. It does not know what to do with each name. It calls callback with the position and the name, and the caller decides the rest.

The lambda captures total_chars by reference, written [&total_chars]. The & means the lambda uses the original variable in main, not a copy. So each time the callback runs, it adds to the same total that main prints at the end. The number 13 is 4 plus 4 plus 5, the lengths of Asha, Ravi and Meera.

The callback parameter is a const reference to a std::function. Passing it by reference avoids copying the callable each time the function is called. The const says that for_each_item will not change the callback. This is the same reason an input vector is usually taken by const reference, which was covered in the lesson on read-only parameters.

Storing callbacks for later

A function pointer or a std::function can also be stored in a variable or a container and called later. This is how user interface code works. A button does not know in advance what will happen when it is clicked. The code that creates the button passes in a handler, and the button runs the handlers when the click happens.

#include <functional>
#include <iostream>
#include <utility>
#include <vector>
 
class Button {
public:
    void on_click(std::function<void()> handler) {
        handlers_.push_back(std::move(handler));
    }
 
    void click() const {
        for (const auto& handler : handlers_) {
            handler();
        }
    }
 
private:
    std::vector<std::function<void()>> handlers_;
};
 
int main() {
    Button save;
    int saves = 0;
    save.on_click([&saves] { ++saves; std::cout << "saved\n"; });
    save.on_click([] { std::cout << "log: save pressed\n"; });
 
    save.click();
    save.click();
    std::cout << "saves counted: " << saves << '\n';
}

Output:

saved
log: save pressed
saved
log: save pressed
saves counted: 2

The class Button keeps a list of handlers. Each call to on_click adds one handler to the list. Each call to click runs all of them in the order they were added.

The parameter of on_click is taken by value, which means the function gets its own copy of the handler. The line handlers_.push_back(std::move(handler)) moves that copy into the vector instead of copying it again. std::move tells the compiler that the local handler will not be used after this line, so its contents can be moved. The lesson on pointers and memory covers moves in more depth.

The first handler captures saves by reference. Each click adds 1 to the variable in main. The second handler captures nothing. It only prints a message.

Notice that the first handler depends on saves staying alive. If saves were destroyed before a click, the handler would write to memory that no longer exists.

Passing a callback as a template

A third way to accept a callback is a template parameter. A template lets the function accept any callable type without naming it. The compiler writes a separate version of the function for each callable type it sees.

#include <iostream>
 
int double_it(int n) {
    return n * 2;
}
 
template <typename F>
int apply_template(F op, int start) {
    return op(start);
}
 
int main() {
    std::cout << apply_template([](int n) { return n + 100; }, 1) << '\n';
    std::cout << apply_template(double_it, 21) << '\n';
}

Output:

101
42

Here F stands for the type of whatever is passed in. The lambda and the function double_it have different types, and the template works with both. Because the compiler knows the exact callable at each call, it can often replace the call with the body of the callable. This is faster than calling through a pointer. The code in this lesson does not measure the difference, so treat speed as a possibility rather than a result you can count on.

The cost is that the template body must be visible to every file that uses it, usually by placing it in a header file. Compiler error messages for templates are also longer.

Choosing between the three

Each tool fits a different situation. The table compares them.

ToolCan keep captured dataGood when
Function pointerNoA plain function or a lambda with no captures is passed to an older or C style interface
std::functionYesThe callback must be stored, kept in a container, or can change type
Template parameterYesThe callback is passed once to a function that runs in a tight loop

A std::function can hold a large callable, and storing one may use heap memory. Calling one also goes through an extra step of indirection, which means the program jumps through the stored object before it reaches the callable. For a single call in a short function, this is not a concern. For code that calls a callback millions of times, a template parameter is worth considering.

A function pointer is the smallest and simplest choice, but it cannot hold captured data. Use it when the callback is a fixed function, or when another library requires a plain function pointer.

Common mistakes

Passing a lambda with a capture to a function pointer

This is a common error when a beginner first uses function pointers. Here a lambda uses offset from the code around it.

#include <iostream>
 
using IntOp = int (*)(int);
 
int apply_twice(IntOp op, int start) {
    return op(op(start));
}
 
int main() {
    int offset = 10;
    std::cout << apply_twice([offset](int n) { return n + offset; }, 1) << '\n';
}

g++ refuses to compile it:

err1.cpp: In function ‘int main()’:
err1.cpp:11:30: error: cannot convert ‘main()::<lambda(int)>’ to ‘IntOp’ {aka ‘int (*)(int)’}

A function pointer holds only an address. It has no place to store offset. The fix is to change the parameter type to std::function<int(int)>, which can store the captured value.

Using a function with the wrong signature

The callback must match the parameter type exactly. Here half takes and returns a double, but apply_twice expects an int function.

#include <iostream>
 
using IntOp = int (*)(int);
 
int apply_twice(IntOp op, int start) {
    return op(op(start));
}
 
double half(double x) {
    return x / 2.0;
}
 
int main() {
    std::cout << apply_twice(half, 8) << '\n';
}

The error is:

err2.cpp:14:30: error: invalid conversion from ‘double (*)(double)’ to ‘IntOp’ {aka ‘int (*)(int)’} [-fpermissive]

The message shows both types. Read the two types and find the difference. Here the difference is double against int. The fix is to write a function with the matching types, or to change the alias.

Forgetting the parentheses in the pointer declaration

This is a subtle one. Inside main, the following line declares a function, not a pointer.

int *op(int);

It means "a function named op that takes an int and returns a pointer to int." The compiler accepts it and then fails when the program is linked, because no function named op is defined anywhere.

#include <iostream>
 
int add_one(int n) {
    return n + 1;
}
 
int main() {
    int *op(int);
    std::cout << op(add_one(1)) << '\n';
}

The linker reports (the first part of its output names the object file and is left out here):

err3.cpp:(.text+0x28): undefined reference to `op(int)'
collect2: error: ld returned 1 exit status

The fix is to put the star inside the parentheses, which makes a pointer variable: int (*op)(int) = add_one;.

Calling an empty std::function

A std::function can be empty, the same way a pointer can be null. Calling an empty one does not crash the program outright. It throws an exception of type std::bad_function_call.

#include <functional>
#include <iostream>
 
int main() {
    std::function<int(int)> op;
    try {
        std::cout << op(4) << '\n';
    } catch (const std::bad_function_call& e) {
        std::cout << "caught: " << e.what() << '\n';
    }
}

Output:

caught: bad_function_call

If the callback might be missing, test it before calling. The test if (op) is true when the std::function holds a callable, and false when it is empty. Without a try block or a check, the exception ends the program. Running the same call without the try block prints:

terminate called after throwing an instance of 'std::bad_function_call'
  what():  bad_function_call

The program then stops with exit code 134.

A plain function pointer that has not been set is a different case. It is not guaranteed to be null, and calling it is undefined behaviour. This means the program can crash or do anything else. Always set a function pointer to a real function or to nullptr before use, and check for nullptr before calling it.

A stored callback that outlives the variables it captured

A lambda that captures by reference keeps a link to the original variable. If the lambda is stored and called after that variable is gone, the link points at memory that no longer belongs to the variable.

#include <functional>
#include <iostream>
#include <vector>
 
std::vector<std::function<int()>> make_jobs() {
    std::vector<std::function<int()>> jobs;
    int base = 100;
    jobs.push_back([&base] { return base + 1; });
    return jobs;
}
 
int main() {
    auto jobs = make_jobs();
    std::cout << jobs[0]() << '\n';
}

The variable base lives only inside make_jobs. When the function returns, base is destroyed. Compiled with AddressSanitizer, a tool that checks memory use at run time, the program reports the error:

ERROR: AddressSanitizer: stack-use-after-return
READ of size 4 at 0x6db05d4f0030 thread T0
    #0 0x5b8c776294f4 in operator() err5.cpp:8

The output is trimmed. The first lines are the important part: the program read memory that had already been released. Without the sanitizer, the same mistake may print a wrong number or may appear to work, which makes it harder to find.

The fix is to capture by value. A copy of base is stored inside the lambda and lives as long as the lambda does.

jobs.push_back([base] { return base + 1; });

The fixed version prints 101.

Practice

Exercise 1

Write a function int apply_n_times(IntOp op, int start, int n) that calls op on start a total of n times and returns the result. Use it to apply a doubling function five times to the number 1.

Show the answer

using IntOp = int (*)(int);
 
int double_it(int n) {
    return n * 2;
}
 
int apply_n_times(IntOp op, int start, int n) {
    int value = start;
    for (int i = 0; i < n; ++i) {
        value = op(value);
    }
    return value;
}
 
// apply_n_times(double_it, 1, 5) returns 32

The loop runs five times, so the value goes 1, 2, 4, 8, 16, 32.

Exercise 2

Write a function void sort_numbers(std::vector<int>& values, const std::function<bool(int, int)>& before) that sorts values using std::sort and the callback before to decide order. Sort the list 5, 2, 9, 1 in descending order by passing a lambda.

Show the answer

#include <algorithm>
#include <functional>
#include <vector>
 
void sort_numbers(std::vector<int>& values, const std::function<bool(int, int)>& before) {
    std::sort(values.begin(), values.end(), before);
}
 
// in main:
// std::vector<int> numbers = {5, 2, 9, 1};
// sort_numbers(numbers, [](int a, int b) { return a > b; });
// The list becomes 9 5 2 1

The callback returns true when a should come before b. Using a > b puts larger numbers first.

Exercise 3

Write a function std::function<int()> make_counter() that returns a callable. Each time the callable is called, it returns the next number, starting at 1. Call it three times and print the results.

Show the answer

#include <functional>
#include <iostream>
 
std::function<int()> make_counter() {
    int count = 0;
    return [count]() mutable {
        ++count;
        return count;
    };
}
 
int main() {
    auto next = make_counter();
    std::cout << next() << ' ' << next() << ' ' << next() << '\n';
}

Output:

1 2 3

The lambda captures count by value, so the lambda owns its own copy. The mutable keyword allows the lambda to change that copy. Without mutable, the lambda cannot change a captured value, because its call operator is const by default.

Exercise 4

Explain why the following line does not compile, then change it so that it does.

int count = 5;
IntOp add_count = [count](int n) { return n + count; };

Show the answer

The lambda captures count, so it is not an empty-capture lambda. A function pointer cannot store captured data, so the conversion to IntOp is refused, the same error as in the first mistake above.

The fix is to store the callable in a std::function.

int count = 5;
std::function<int(int)> add_count = [count](int n) { return n + count; };

With this change, add_count(1) returns 6.

Exercise 5

For each situation below, pick a plain function pointer, a std::function, or a template parameter, and say why.

  1. A C library expects a function pointer for a comparison, and your comparison has no captured data.
  2. A settings object keeps a list of callbacks that run whenever a file is saved, and the callbacks are added at different times.
  3. A helper function runs a callback once for each of ten million numbers in a tight loop.

Show the answer

  1. A function pointer. The C library needs a plain function pointer, and the comparison needs no captured data, so a lambda with an empty capture list also works.
  2. A std::function. The callbacks must be stored in a container and can hold different captured data, which a function pointer cannot do.
  3. A template parameter. The callback is passed once and runs many times in a loop, so the compiler can see the exact callable and may inline it. A std::function would also work, but it adds an indirect call on each step.

Summary

  • A function pointer, written int (*op)(int), holds the address of a function, and a function name passed as an argument becomes one.
  • A lambda with an empty capture list converts to a function pointer, but a lambda that captures variables does not.
  • std::function from <functional> holds any callable with a matching signature, including lambdas that capture data, and it can be stored in variables and containers.
  • A template parameter accepts any callable type and avoids the cost of std::function when a callback is called many times.
  • An empty std::function throws std::bad_function_call when called, so check it first.
  • A callback that captures by reference must not outlive the variables it refers to, so capture by value when the callback is stored.
Check yourself: Callbacks (6 questions) →Practice this topic: coding problems for C++ Functions and Scope →

Stuck? Ask the tutor

Ask about anything in this lesson. Answers are short and use simple words.

Sign in to ask the tutor.

← Varargs and parameter packs