How std::invoke Unifies Calling in C++

Introduced in C++17, std::invoke provides a standardized mechanism to execute any callable entity using a single, unified interface. Before its introduction, generic programming in C++ required different syntaxes depending on whether you were calling a regular function, invoking a member function pointer, or accessing a member variable pointer. This article explores how std::invoke eliminates these syntactic disparities, how it operates behind the scenes, and how to apply it across different C++ callable types.

The Problem: Syntactic Divergence in C++

Prior to C++17, writing generic template code that accepted any callable object was cumbersome because C++ enforces distinct syntactical rules based on the callable's type:

  1. Free functions, functors, and lambdas use the standard function call syntax:

    func(arg1, arg2);
  2. Pointers to member functions require a class instance or pointer, dereferencing operators (.* or ->*), and explicit grouping parentheses due to operator precedence:

    (instance.*ptrToMemberFunc)(arg1, arg2);
    (instancePtr->*ptrToMemberFunc)(arg1, arg2);
  3. Pointers to data members access values without invocation parentheses:

    instance.*ptrToDataMember;
    instancePtr->*ptrToDataMember;

Because of these differences, generic code that took an arbitrary callable had to provide complex template specializations or utilize helper traits to handle member pointers properly.

How std::invoke Resolves the Disparity

Defined in the <functional> header, std::invoke implements the concept of INVOKE expressions defined in the C++ standard. It uses template deduction and SFINAE (or concepts in C++20) to inspect the first argument and determine the correct invocation syntax at compile time.

The general syntax is uniform:

std::invoke(callable, target_or_arg1, args...);

Depending on the type of callable, std::invoke automatically routes the arguments to the proper underlying language expression.

1. Free Functions, Function Pointers, and Lambdas

For standard callables, std::invoke passes the trailing arguments directly to the function call operator:

#include <iostream>
#include <functional>

void printSum(int a, int b) {
    std::cout << a + b << '\n';
}

int main() {
    auto lambda = [](int x) { return x * 2; };

    std::invoke(printSum, 10, 20);           // Equivalent to: printSum(10, 20)
    int result = std::invoke(lambda, 5);     // Equivalent to: lambda(5)
}

2. Pointers to Member Functions

When the first argument is a pointer to a member function, std::invoke treats the second argument as the class instance and the remaining arguments as the function parameters. It automatically handles instances passed by reference, by value, by pointer, or via smart pointers:

#include <iostream>
#include <functional>
#include <memory>

struct Calculator {
    void add(int a) const {
        std::cout << "Value: " << a << '\n';
    }
};

int main() {
    Calculator calc;
    auto calcPtr = std::make_unique<Calculator>();

    // Using an object reference
    std::invoke(&Calculator::add, calc, 42);

    // Using a smart pointer (automatically dereferenced)
    std::invoke(&Calculator::add, calcPtr, 42);
}

This removes the need to manually write (calc.*&Calculator::add)(42) or check if the target is a raw pointer or smart pointer.

3. Pointers to Data Members

In modern C++, pointers to data members are categorized as callables. Invoking a pointer to a member variable retrieves a reference to that member from the target instance:

#include <iostream>
#include <functional>

struct User {
    std::string name;
};

int main() {
    User user{"Alice"};

    // Accessing member variable
    std::cout << std::invoke(&User::name, user) << '\n'; // Equivalent to: user.name

    // Modifying member variable through returned reference
    std::invoke(&User::name, user) = "Bob";
    std::cout << user.name << '\n';                       // Outputs: Bob
}

Generic Programming Applications

The greatest utility of std::invoke lies in template and library design. A generic utility such as a thread pool, an asynchronous wrapper, or an algorithm can accept any callable without writing specialized overloads:

template <typename Callable, typename... Args>
decltype(auto) execute(Callable&& callable, Args&&... args) {
    // Executes correctly whether Callable is a free function, lambda, or member pointer
    return std::invoke(std::forward<Callable>(callable), std::forward<Args>(args)...);
}

Along with std::invoke, C++17 introduced accompanying type traits:

  • std::invoke_result<F, Args...> (and its alias std::invoke_result_t): Determines the return type of the invocation at compile time.
  • std::is_invocable<F, Args...>: Tests whether a callable type can be invoked with the specified argument types.

Together, these utilities ensure that any callable object in C++ can be examined, checked, and executed through a predictable and consistent interface.