Skip to content

OnceCallback Prerequisites (Part 2): std::invoke and the Uniform Calling Convention

Introduction

Suppose you are writing a callback system—just like the OnceCallback we are building. Your system needs to accept all sorts of "callable objects": plain function pointers, lambdas, functors (class objects that overload operator()), and even member function pointers. The problem is that the calling syntax for these callable objects varies. Plain functions are called directly with f(args...), but member function pointers must be written as (obj.*pmf)(args...). If your code handles ten different callable objects, do you really need to write ten if-else branches to handle each one separately?

std::invoke (C++17) was born to eliminate this fragmentation. It provides a uniform calling syntax, allowing all callable objects to be invoked in the exact same way. Inside OnceCallback, both bind_once and then() rely entirely on it to fulfill the requirement of "correctly invoking whatever callable object is passed in."

Learning Objectives

  • Understand why we need a uniform calling convention—the differences in calling syntax across various callable objects
  • Master the complete dispatch rules of std::invoke
  • Learn to use std::invoke_result_t to deduce the return type of an invocation at compile time

The Problem: Fragmented Calling Syntax for Callable Objects

There are at least four common callable objects in C++, each with a different calling syntax. Let's look at them one by one.

Plain Function Pointers

cpp
int add(int a, int b) { return a + b; }
int (*fp)(int, int) = &add;

int result = fp(3, 4);       // 直接调用
int result2 = (*fp)(3, 4);   // 解引用后调用(等价)

Lambdas / Functors

cpp
auto lam = [](int a, int b) { return a + b; };
int result = lam(3, 4);  // 通过 operator() 调用

struct Adder {
    int operator()(int a, int b) { return a + b; }
};
Adder fn;
int result2 = fn(3, 4);  // 同样通过 operator() 调用

Member Function Pointers

This is where the syntax starts getting weird. A member function pointer cannot be called directly like a plain function—you must have an object instance and use the .* or ->* operator to invoke it.

cpp
struct Calculator {
    int multiply(int a, int b) { return a * b; }
};

Calculator calc;
int (Calculator::*pmf)(int, int) = &Calculator::multiply;

// 必须用 .* 运算符
int result = (calc.*pmf)(3, 4);  // result == 12

Pointers to Data Members

Yes, C++ allows you to take a "pointer" to a data member—which is actually just an offset. You access it through the .* operator as well.

cpp
struct Point {
    double x, y;
};

Point p{1.0, 2.0};
double Point::*pmx = &Point::x;

double val = p.*pmx;  // val == 1.0

The problem is clear: if you are writing a template function that needs to invoke a "callable object of an unknown type," you cannot write a unified calling syntax—because you don't know whether it is a plain function or a member function pointer. std::invoke exists to solve this exact problem.


Dispatch Rules of std::invoke

The job of std::invoke(f, args...) is to select the correct calling syntax based on the specific types of f and args. The standard defines the following cases (referred to as the INVOKE expression in C++ standard terminology):

Case 1: Member Function Pointer + Object

When f is a pointer to a member function, and the first element of args is an object (or a reference to an object, or a pointer to an object), std::invoke expands to invoking the member function through that object.

cpp
struct Calculator {
    int multiply(int a, int b) { return a * b; }
};

Calculator calc;

// 通过引用
std::invoke(&Calculator::multiply, calc, 3, 4);        // (calc.*multiply)(3, 4)
// 通过指针
std::invoke(&Calculator::multiply, &calc, 3, 4);       // ((*ptr).*multiply)(3, 4)

Note the second case—when the first argument is a pointer (&calc), std::invoke automatically dereferences it. This behavior is crucial when bind_once binds a member function.

Case 2: Pointer to Data Member + Object

When f is a pointer to a data member, std::invoke expands to accessing that data member through the object.

cpp
struct Point { double x, y; };
Point p{1.0, 2.0};

double val = std::invoke(&Point::x, p);    // p.*&Point::x == p.x

Case 3: Other Callable Objects

When f is a function pointer, lambda, functor, or anything else that can be "called directly," std::invoke simply becomes f(args...).

cpp
std::invoke([](int a, int b) { return a + b; }, 3, 4);  // lambda(3, 4)

The Unified Interface

The key takeaway is that no matter which of the above cases f falls into, the calling syntax is always std::invoke(f, args...). In your template code, you do not need to know the exact type of fstd::invoke internally dispatches it to the correct calling syntax for you.


std::invoke_result_t: Deducing the Return Type at Compile Time

A unified calling syntax alone is not enough—sometimes you also need to know the return type of std::invoke(f, args...) at compile time. For example, in the implementation of then(), we need to deduce "what type is returned when we pass the return value of the previous callback to the next callback."

std::invoke_result_t<F, Args...> does exactly this. Given a callable object type F and argument types Args..., it computes the return type of std::invoke(f, args...) at compile time.

cpp
#include <type_traits>
#include <functional>

auto add(int a, int b) -> int { return a + b; }

// 编译期推导 add(1, 2) 的返回类型
using R = std::invoke_result_t<decltype(add), int, int>;
static_assert(std::is_same_v<R, int>);

// 对 lambda 也能推导
auto lam = [](double x) { return std::to_string(x); };
using R2 = std::invoke_result_t<decltype(lam), double>;
static_assert(std::is_same_v<R2, std::string>);

Usage in OnceCallback

The implementation of then() uses std::invoke_result_t to deduce the return type of the new callback in a chained invocation. Specifically, when then() accepts a subsequent callback next, it needs to know what type next(上一个回调的返回值) will return:

cpp
// 在 then() 的非 void 分支中
using NextRet = std::invoke_result_t<NextType, ReturnType>;
// NextRet 就是"把 ReturnType 类型的值传给 next,返回什么类型"

In the void branch, the subsequent callback takes no arguments:

cpp
// 在 then() 的 void 分支中
using NextRet = std::invoke_result_t<NextType>;
// next 不接受参数,直接调用

Specific Usage in the OnceCallback Source Code

Let's look at the actual source code to see the two usage scenarios of std::invoke in OnceCallback.

std::invoke in bind_once

cpp
// bind_once 的 lambda 内部
return std::invoke(
    std::move(f),
    std::move(bound)...,
    std::forward<decltype(call_args)>(call_args)...
);

Here, f could be any callable object—a plain lambda, a member function pointer, or even a pointer to a data member. If we used f(bound..., call_args...) directly instead of std::invoke, it would fail to compile when f is a member function pointer—because a member function pointer cannot be called directly with ().

std::invoke in then()

cpp
// then() 的非 void 分支
auto mid = std::move(self).run(std::forward<FuncArgs>(args)...);
return std::invoke(std::move(cont), std::move(mid));

cont (the subsequent callback) is a plain callable object (usually a lambda) in the design of then(), not a OnceCallback. So theoretically, calling it directly with cont(mid) would also work—and in most cases, it does. However, using std::invoke is a form of defensive programming: if someone passes in a member function pointer as the subsequent callback, the direct calling syntax will fail, but std::invoke will not. Uniformly using std::invoke guarantees correct behavior regardless of what callable object is passed in, without needing extra code to handle special types.


Pitfall Warning: The Lifetime Trap of Member Function Binding

std::invoke can uniformly handle member function pointers, but it does not manage object lifetimes for you. When you bind a member function in bind_once:

cpp
struct Calculator {
    int multiply(int a, int b) { return a * b; }
};

Calculator calc;
auto bound = bind_once<int(int)>(&Calculator::multiply, &calc, 5);

&calc is a raw pointer, and bind_once stores it in the lambda's capture list. If calc is destroyed before the callback is invoked, the lambda holds a dangling pointer. std::invoke would then access freed memory through that dangling pointer—undefined behavior (UB), most likely a segmentation fault.

Chromium uses base::Unretained to explicitly mark "I know this raw pointer's lifetime is safe," uses base::Owned to take ownership of the object, and uses base::WeakPtr to automatically cancel the callback when the object is destructed. Our simplified version does not provide these protection mechanisms for now—the safety responsibility lies with the caller. This is an important design trade-off, and we will revisit it in the hands-on chapters.


Summary

In this post, we clarified the origins and mechanics of std::invoke. The core motivation is that the calling syntax varies across callable objects—plain functions are called directly with f(args...), member function pointers require (obj.*pmf)(args...), and data member pointers require obj.*pmd. std::invoke unifies all of these into a single std::invoke(f, args...) syntax, and paired with std::invoke_result_t, we can deduce the return type of the invocation at compile time. In OnceCallback, both bind_once and then() rely on it to achieve a generic design where "we don't care about the specific type of the callable object, as long as it can be invoked."

In the next post, we will look at advanced lambda features—specifically the lambda init capture pack expansion introduced in C++20, which is the key to the concise implementation of bind_once.

References

Built with VitePress