CodeGym /Courses /C++ SELF /std::function — when you need it and what it costs

std::function — when you need it and what it costs

C++ SELF
Level 22, Lesson 3
Available

1. Why you need std::function at all

You usually reach for std::function not because you “want it to look nicer”, but because the compiler once again says: “No, you can’t do that.” And that’s fine: the compiler is not malicious, it just won’t guess what exact type you meant.

Imagine this situation: you wrote two different data-processing rules. They accept the same parameters and return the same type, but are written differently (for example, one is a lambda, the other is a regular function). To a human this is “the same interface”. In C++ these are different entities.

The key problem is this: each lambda has its own unique type, even if two lambdas look identical “in meaning”. Therefore a variable declared with auto can hold exactly one concrete lambda type, and you generally cannot “swap” it with another lambda.

A small demonstration of that “pain”:

#include <iostream>

int main() {
    auto f = [](int x) { return x + 1; };

    // f = [](int x) { return x * 2; }; // <- compilation error: different lambda type

    std::cout << f(10) << '\n'; // 11
}

Why is that? Because auto f = ... deduced the concrete lambda type (its closure type), and from then on f must remain exactly that type.

What std::function is and what problem it solves

When we say “I want to store something callable”, we actually want a container for behavior: “here is the signature — the implementation can vary”. That is exactly what std::function does.

std::function<R(Args...)> is a wrapper object that can hold different callable entities with the same signature: regular functions, lambdas (including those with captures), and other functors. Internally it hides the concrete type and gives you a uniform interface via operator().

The most important thing to understand at this stage: std::function is useful when you want a single variable type for different implementations of callables that share the same signature.

Basic syntax: declaration, assignment, invocation

The syntax for std::function may at first look a bit “bureaucratic”, but it is honest: you literally write the call contract.

You will almost always need:

  • #include <functional>
  • the signature notation: std::function<return(params...)>

A minimal example: storing “something that takes an int and returns an int”:

#include <functional>
#include <iostream>

int main() {
    std::function<int(int)> f = [](int x) { return x + 1; };

    std::cout << f(10) << '\n'; // 11

    f = [](int x) { return x * 2; };
    std::cout << f(10) << '\n'; // 20
}

This is the practical “magic”: the variable f is a single variable, has a single type (std::function<int(int)>), and we can change implementations at will.

std::function can be empty

std::function has an “empty state”: it may not hold any function. This is convenient (for example, “the callback is optional”), but dangerous if you forget to check.

#include <functional>
#include <iostream>

int main() {
    std::function<void()> action; // empty

    if (!action) {
        std::cout << "action is empty\n"; // action is empty
    }

    action = [] { std::cout << "run\n"; };

    if (action) {
        action(); // run
    }
}

If you invoke an empty std::function, you'll get a runtime error (typically an exception std::bad_function_call). Until we cover try/catch, your main protection is checking for emptiness: if (f) { f(...); }.

2. When to use it and what it costs

When std::function is actually needed

It’s easy to “fall in love” with std::function and start using it everywhere “just in case”. This adds flexibility but sometimes unnecessary complexity and overhead. It helps to distinguish common cases.

Choose behavior at runtime

For example, you have a setting: “sort by length” or “sort lexicographically”. You want to store the selected rule in a variable and apply it later.

With auto you hit the “different lambda types” problem. With std::function everything is fine:

#include <functional>
#include <iostream>
#include <string>

int main() {
    bool by_length = true;

    std::function<bool(const std::string&, const std::string&)> cmp;

    if (by_length) {
        cmp = [](const std::string& a, const std::string& b) { return a.size() < b.size(); };
    } else {
        cmp = [](const std::string& a, const std::string& b) { return a < b; };
    }

    std::cout << cmp("cat", "horse") << '\n'; // 1 (true)
}

Convenient to return a callable from a function

Sometimes you want to write a factory: “give me a rule/filter by parameter”. You can return a lambda directly, but you can’t write the lambda’s exact type in the signature without auto. std::function makes the return type explicit and convenient for the API.

#include <functional>

std::function<bool(int)> make_is_big(int limit) {
    return [limit](int x) { return x >= limit; };
}

The idea: return a “predicate” that captures limit.

When std::function is often unnecessary

If you write:

std::sort(v.begin(), v.end(), [](int a, int b){ return a < b; });

then you do not need to wrap the comparator in std::function. Algorithms accept lambdas directly, and the compiler usually optimizes those calls better.

A practical heuristic: if the callable lives “within one expression” and you don’t need to store it for later, std::function is usually unnecessary.

What does std::function cost

std::function is not “free polymorphism”. It solves the problem but charges a price.

Cost #1: hidden wrapper and indirect call

When a lambda is held in auto, the compiler knows its exact type and can often inline the call and perform more aggressive optimizations.

When a callable is hidden inside std::function, the call usually goes through an extra level of indirection (i.e., “call what’s inside the wrapper”).

Cost #2: possible heap allocations

Some callables are small (for example, a lambda without captures) and can be stored inside std::function without dynamic memory. But if a callable is large (for example, you captured a big object by value), std::function may allocate memory.

At this stage it’s more important not to remember the “exact rules” but to get the general idea: std::function can be heavier than it appears.

Cost #3: copying can be expensive

std::function is copyable, but copying may mean copying what’s inside. So in the role of a “small thing we constantly pass around”, std::function can unexpectedly become a “small weight”.

Mini table: auto vs function pointer vs std::function

What we store Example type Can capture variables? Can store different implementations in one variable? Comment
Lambda of a concrete type
auto f = []{...};
Yes No The lightest option, but the type is fixed
Function pointer
int (*pf)(int)
No Yes, but only functions/lambdas without captures Simple and fast, but no captures
std::function
std::function<int(int)>
Yes Yes The most flexible of the three, but more expensive

3. Practical example: a mini command dispatcher

Now let’s build a practical thing that demonstrates the point of std::function well: we will create a command table where each command is a name and a handler. Handlers will be different (for example, lambdas with captures), but we want to store them in one container.

Assume we have a simple task model:

#include <string>

struct Task {
    int id{};
    std::string title;
    bool done{};
};

Now define a “command” as a pair: name + handler. Let the handler accept the task list and a command argument (for example, the string after the command):

#include <functional>
#include <string>
#include <string_view>
#include <vector>

struct Command {
    std::string name;
    std::function<void(std::vector<Task>&, std::string_view)> run;
};

Note: this is exactly where std::function is handy. We want run to have the same type for all commands, even though implementations differ.

A simplest command: list

Let’s make a printing function (for now without formatting):

#include <iostream>
#include <vector>

void print_tasks(const std::vector<Task>& tasks) {
    for (const auto& t : tasks) {
        std::cout << (t.done ? "[x] " : "[ ] ") << t.id << ": " << t.title << '\n';
    }
}

Now create the list command with a captureless lambda:

Command make_list_command() {
    return Command{
        "list",
        [](std::vector<Task>& tasks, std::string_view) {
            print_tasks(tasks);
        }
    };
}

Command with a capture: add, where there is a “next id”

To add tasks we need a counter next_id. You can store it externally and capture it by reference. This is a good example, but remember the lifetime rule: next_id must outlive the command.

Command make_add_command(int& next_id) {
    return Command{
        "add",
        [&next_id](std::vector<Task>& tasks, std::string_view arg) {
            tasks.push_back(Task{next_id, std::string(arg), false});
            ++next_id;
        }
    };
}

Find a command and run it

Collect commands into a vector and find the needed one by name using std::find_if.

#include <algorithm>
#include <iostream>
#include <string>
#include <vector>

int main() {
    std::vector<Task> tasks;
    int next_id = 1;

    std::vector<Command> cmds;
    cmds.push_back(make_list_command());
    cmds.push_back(make_add_command(next_id));

    std::string cmd = "add";
    std::string arg = "Buy milk";

    auto it = std::find_if(cmds.begin(), cmds.end(),
                           [&](const Command& c) { return c.name == cmd; });

    if (it != cmds.end() && it->run) {
        it->run(tasks, arg);
    }

    print_tasks(tasks); // [ ] 1: Buy milk
}

An important detail: it->run is a std::function, and we check it for emptiness. In our code it is definitely not empty, but the habit is useful.

4. Common mistakes when working with std::function

Mistake #1: “Let's put std::function everywhere because it's universal”.
Universality is not always a benefit. If the callable doesn't need to be stored “for later”, if it is used once (especially inside std::sort/std::find_if/std::transform), then std::function often just adds overhead and makes the code harder to read. In such places a direct lambda is usually clearer: the rule is next to the usage.

Mistake #2: incorrect std::function signature.
A common situation: you write std::function<void(int)> and then try to assign a lambda that takes a const int& or returns bool. Formally these are different contracts. A practical habit: first describe in words “what it accepts and what it returns”, and only then write R(Args...).

Mistake #3: calling an empty std::function.
std::function can be “empty” — this is a valid state. But if you call such an object as a function, you'll get a runtime error (typically an exception that, if unhandled, will terminate the program). Until you learn try/catch, your main protection is the check if (f) { f(...); }.

Mistake #4: capturing by reference where lifetime isn't guaranteed.
It's easy to create a command that captured a reference to a local variable and then put the command into a container to be used later. If the variable has already “died”, behavior will be undefined. In our example with make_add_command(int& next_id) it's important that next_id lives longer than the commands. If unsure — capture by value or design state storage carefully.

Mistake #5: storing too heavy captures inside std::function without understanding the cost.
If you capture a large std::string or an entire std::vector by value inside a lambda, that capture will reside inside std::function. Copying/moving such a std::function becomes expensive, and it may also allocate memory. Capture by value only what you really want to fix as configuration, and try to keep it small.

1
Task
C++ SELF, level 22, lesson 3
Locked
Choosing a trick
Choosing a trick
1
Task
C++ SELF, level 22, lesson 3
Locked
Optional handler
Optional handler
1
Task
C++ SELF, level 22, lesson 3
Locked
Transformation Factory
Transformation Factory
1
Task
C++ SELF, level 22, lesson 3
Locked
Command dispatcher
Command dispatcher
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION