std::function, std::invoke, and Callable Objects
Introduction
When writing an event system, we encountered a very practical problem: we needed to store various types of callbacks—ordinary functions, member functions, lambdas, functors—the list goes on. Function pointers can only point to static or global functions and cannot carry context. Using std::function directly to store lambdas is problematic because every lambda has a unique type, making it impossible to store them in the same container. std::function solves this problem by using type erasure to unify various callable objects into a single type. However, type erasure comes at a cost. The question then becomes: how significant is this cost? Is there a way to get the best of both worlds?
In this chapter, we start with the internal mechanism of std::function, move to std::invoke (the "universal invoker"), and finally discuss zero-overhead callback design patterns—finding a balance between type safety and performance.
Learning Objectives
- Understand the type erasure mechanism and SBO of
std::function- Master
std::invokefor uniformly calling callable objects- Learn to design zero-overhead callback systems using templates and lambdas
The Callable Object Family in C++
Before diving into specific mechanisms, let's categorize the forms "callable objects" take in C++. A callable object is anything that can be invoked using the obj(args) syntax (or obj.*ptr(args)). Ordinary functions and function pointers are the most basic—direct calls or indirect calls through pointers. Functors are class objects that overload operator(). Lambdas are anonymous functors generated by the compiler. Member function pointers point to class member functions and require an object instance for invocation. Additionally, there are objects wrapped by std::function and the results of std::mem_fn.
The problem is that the invocation syntax for these callable objects varies—ordinary functions are called directly, member functions require .* or ->*, while functors and lambdas are called like function objects. If you want to write a generic function to "uniformly invoke" these things, prior to C++17, you would need to write a pile of template specializations; with std::invoke, one function handles it all.
std::function—The Type-Erased Function Container
std::function is a generic function wrapper introduced in C++11, defined in the <functional> header. It can store, copy, and invoke any callable object that matches a specific signature. Its core capability is simple: unify different types of callable objects into a single type.
std::function<int(int)> func; // Can store any callable returning int and taking intType Erasure Mechanism
How does std::function fit different types into the same shell? The answer is type erasure. The simplified principle is this: std::function defines an abstract base class (Concept) internally that holds a pure virtual function operator(); then, for each specific callable type, it generates a derived class (Model) that implements operator(). std::function holds a pointer to Concept, and when invoked, it dispatches to the specific implementation via the virtual function.
We can simulate this process with code:
Expand (30 lines)Collapse
// Simplified std::function principle
template<typename Signature> class function;
template<typename R, typename... Args>
class function<R(Args...)> {
struct Concept { // Abstract interface
virtual R invoke(Args... args) = 0;
virtual ~Concept() = default;
};
template<typename T>
struct Model : Concept { // Concrete implementation
T callable;
Model(T&& c) : callable(std::forward<T>(c)) {}
R invoke(Args... args) override {
return callable(args...);
}
};
Concept* ptr; // Pointer to interface
public:
template<typename T>
function(T&& callable)
: ptr(new Model<T>(std::forward<T>(callable))) {}
R operator()(Args... args) {
return ptr->invoke(args...);
}
};From this pseudocode, we can see the three elements of type erasure: a unified abstract interface (Concept), a templated concrete implementation (Model), and a pointer to the interface (ptr). When stored, the type information is "erased"—the outside only sees function; when invoked, the type is restored through the virtual function table.
Small Object Optimization (SBO)
The simplified version above has an obvious problem: every construction uses new to allocate on the heap. For a small lambda capturing one or two integers, the cost of this heap allocation might be higher than the lambda itself. Therefore, actual std::function implementations use Small Object Optimization (SBO, also called SOO)—reserving a fixed-size buffer (usually 16-32 bytes) inside the std::function object. If the wrapped callable object is small enough, it is stored directly in this buffer without heap allocation.
// SBO principle
class function {
union {
void* heap_ptr; // Used for large objects
char buffer[32]; // Inline storage for small objects
};
// ... management metadata ...
};Let's test the SBO behavior of libstdc++. On GCC 15.2.1, sizeof(std::function<int(int)>) is 32 bytes. However, test results show that even a lambda capturing a single int (closure object is only 4 bytes) does not trigger heap allocation, while a lambda capturing 5 ints or one pointer does—indicating GCC 15.2's SBO implementation is quite conservative, possibly requiring extra space for the virtual table pointer and management metadata. libc++ (Clang) implementation may differ, and behavior varies by version.
Verification Code: function_sbo.cpp (GCC 15.2.1,
-O3)Important: SBO behavior varies significantly between compilers and versions. If your code is performance-sensitive, consider using template parameters or hand-written type erasure for predictable behavior.
Function Pointers—Zero Overhead but Functionally Limited
Before discussing zero-overhead alternatives, let's review function pointers. Function pointers are a mechanism inherited from C, pointing directly to code addresses—simple and efficient. Their size is that of a single pointer (8 bytes on 64-bit systems), and invocation is just a call instruction (jmp on some architectures), with no extra indirection layers.
Performance Test: In our tests (GCC 15.2.1,
-O3), function pointer invocation is about 30% slower (1.29x) than direct calls. This is because direct calls can be fully inlined into computation instructions, while function pointers still require indirectcall. However, in unoptimized code, both requirecallinstructions, so the difference is smaller.Verification Code: func_ptr_bench.cpp
void normal_func(int n) { /* ... */ }
void (*func_ptr)(int) = &normal_func;
// Direct call
normal_func(42); // Can be inlined
// Indirect call
func_ptr(42); // Cannot be inlined (usually)The biggest limitation of function pointers is the inability to carry context—they can only point to lambdas without captures (or ordinary functions, static member functions). Any lambda with captures cannot be converted to a function pointer. When you need to pass a this pointer or some state to a callback, function pointers are helpless.
// Valid: No capture
void (*fp)(int) = [](int x) { return x + 1; };
// Invalid: Has capture
// auto lambda = [y](int x) { return x + y; };
// void (*fp2)(int) = lambda; // Compilation error| Feature | Function Pointer | std::function |
|---|---|---|
| Size | 8 bytes (64-bit) | 32-64 bytes |
| Heap Allocation | None | Triggered outside SBO range |
| Indirection Layers | 1 (direct call) | 1 (vtable indirect) |
| Carries Context | No | Yes |
| Inline Friendly | Yes | Poor (hindered by type erasure) |
| Performance (vs. Direct) | ~1.3x | ~7-9x |
std::invoke—Unified Invocation Interface
std::invoke, introduced in C++17 and defined in <functional>, is a "universal invoker". Regardless of your callable object type—ordinary function, member function pointer, lambda, functor—std::invoke can call it with a single syntax. It implements the semantics of the INVOKE expression defined in the standard:
#include <functional>
struct Widget {
void print(int x) { /* ... */ }
};
int main() {
Widget w;
auto mem_fn_ptr = &Widget::print;
// Traditional syntax
(w.*mem_fn_ptr)(42); // Object pointer
(w.*mem_fn_ptr)(42); // Object reference
// std::invoke syntax
std::invoke(mem_fn_ptr, w, 42);
}Look at that member function call—the traditional syntax is (obj.*ptr)(args) or (ptr->*args), a syntax I have to look up every time I write it. With std::invoke, you only need std::invoke(ptr, obj, args...), which is much cleaner.
The Underlying Principle of invoke
The implementation principle of std::invoke isn't complex. The core is compile-time type judgment and dispatching. For ordinary callable objects (function pointers, lambdas, functors), it calls directly using operator(). For member function pointers, it selects the appropriate invocation syntax based on the object category (pointer, reference, smart pointer). For member variable pointers, it returns the corresponding member reference. All these judgments are completed at compile time with zero runtime overhead.
std::invoke_result_t
C++17 also provides std::invoke_result_t, which can obtain the return type of an std::invoke call at compile time. This tool is very practical when writing generic code:
template<typename F, typename... Args>
auto call_and_log(F&& f, Args&&... args) {
using Result = std::invoke_result_t<F, Args...>;
if constexpr (std::is_void_v<Result>) {
std::invoke(std::forward<F>(f), std::forward<Args>(args)...);
std::cout << "Returned void\n";
} else {
Result res = std::invoke(std::forward<F>(f), std::forward<Args>(args)...);
std::cout << "Returned: " << res << '\n';
return res;
}
}Performance of invoke
When using std::invoke in template code, the compiler sees the complete call chain and will inline it to the same extent as a direct call. We tested this: under -O3 optimization, std::invoke performance is identical to direct calls (within margin of error, potentially even slightly faster due to measurement error). This is because std::invoke is essentially just a thin compile-time dispatch wrapper that is completely inlined and eliminated after optimization.
Verification Code: invoke_bench.cpp
Assembly Verification: Generating assembly (
-S) shows that direct calls,std::invoke, function pointers, and lambdas all compile to exactly the same code—direct calculation and return, with nocallinstruction.
Of course, if you invoke via a callable object stored in std::function, the indirect overhead comes from std::function's type erasure, not std::invoke.
Zero-Overhead Callback Design—Template + Lambda
After understanding the sources of std::function overhead (type erasure, potential heap allocation, indirect calls), the question becomes: in many scenarios, the callback type is already determined at registration. Can we avoid type erasure?
The answer is yes. The simplest zero-overhead solution is to pass the lambda directly via template parameters—the compiler knows the complete closure type, and the call is fully inlined:
template<typename Callback>
void register_callback(Callback cb) {
// Compiler knows exact type of cb here
cb(42); // Fully inlined
}
int main() {
int capture = 10;
register_callback([capture](int x) {
return x + capture;
});
}The problem with this approach is that each different lambda type instantiates a different template function. You cannot put different types of callbacks into the same container. If your design确实 requires runtime polymorphism (e.g., storing various types of callbacks in an event queue), you must introduce some form of type erasure.
Manual Type Erasure: Function Pointer Table vs. Virtual Functions
If you need type erasure but want to avoid the full overhead of std::function, you can write a lightweight type-erasure container by hand. The core idea is to use a function pointer table instead of a virtual function table, and a fixed-size stack buffer instead of heap allocation:
Expand (23 lines)Collapse
class SmallFunction {
void* ptr; // Points to buffer or heap
void (*invoke_fn)(void*, int); // Function pointer table
char storage[32]; // Inline storage
public:
template<typename T>
SmallFunction(T&& cb) {
if (sizeof(T) <= sizeof(storage)) {
ptr = storage;
new (storage) T(std::forward<T>(cb));
} else {
// Handle heap allocation...
}
invoke_fn = [](void* p, int arg) {
return (*static_cast<T*>(p))(arg);
};
}
void operator()(int arg) {
invoke_fn(ptr, arg);
}
};This SmallFunction isn't as universal as std::function (no copy support, no allocators), but it satisfies the most common use cases: storing lambdas with captures, no heap allocation, single-layer indirection. In embedded or high-performance scenarios, this "good enough" design is often the most pragmatic choice.
Selection Guide
To summarize the trade-offs between callback storage schemes. Function pointers are suitable for scenarios without context, offering zero overhead but only pointing to captureless lambdas or ordinary functions. std::function is suitable for scenarios requiring runtime polymorphism, being general but with significant performance overhead—even if the object is within SBO range, the virtual table indirection hinders inlining, making it 7-9x slower than direct calls in tests. Template parameters are suitable for scenarios where types are known at compile time, offering complete zero overhead but inability to store in containers. Manual type erasure is suitable for scenarios requiring runtime polymorphism with performance requirements, involving slightly more code but controllable behavior.
Performance Data Source: callback_bench.cpp (GCC 15.2.1,
-O3, 100 million calls)
// Performance comparison summary
Direct call: 1.0x (baseline)
Template lambda: 1.0x (fully inlined)
Function pointer: 1.3x (indirect call)
std::function: 7.5x (type erasure overhead)Summary
In this chapter, we connected the storage and invocation mechanisms for callable objects in C++:
std::functionunifies various callable object types via type erasure, and SBO avoids heap allocation for small objects.- Function pointers offer zero overhead but cannot carry context, suitable for stateless callbacks.
std::invokeis a unified invocation interface for callable objects, offering zero overhead in template code.- The core idea of zero-overhead callbacks is "use templates instead of type erasure when possible; when type erasure is necessary, use function pointer tables instead of virtual functions."
- Choose the appropriate solution based on the trade-off between generality and performance in your specific scenario.