Creating a C++20 std::format callback lambda with perfect forwards

329 Views Asked by At

I am attempting to make a std::format callback lambda:

auto callback = lazy_format("Hello, my name is {}, my age is {}, my hobby is {}", "Arthur", 8, "Dinosaurs");

...

if (ask_about_themselves) {
    std::string response = callback();
}

And I'm running into trouble trying to figure out how to get the forwarding to work correctly with my one largest constraint:

I can't call std::vformat (forbidden for our team except in special cases, and I want other teammates to be able to write these without too much worrying).

My attempts have included all kinds of combinations of forwarding and mucking around with templates, with this initial design as my base:

template<typename... Args>
inline auto lazy_format(std::format_string<Args...> fmt, Args&&... args)
{
    return [fmt, ...args = std::forward<Args>(args)]() {
        std::format(fmt, std::forward<Args>(args)...);
    };
}

The big issues come from slight type differences between the std::format_string I capture and how the arguments are captured.

For example, I'm passing in a const char(&)[7] as my first argument, which gets stored in format_string's type, but that array reference info gets lost when capturing it in the lambda. So, I wind up trying to pass a const char* const into a const char(&)[7] parameter.

The closest I've come is with help from Antyer's solution, but it still seems to have a problem with copying const references instead of passing the reference to the lambda.

I want to preserve references where possible: https://godbolt.org/z/G38jMaEba

template<typename... Args>
inline auto lazy_format(std::format_string<const std::decay_t<Args>&...> fmt, Args&&... args)
{
    return [fmt=std::move(fmt), ...args=std::forward<Args>(args)]() {
        return std::format(fmt, args...);
    };
}
5

There are 5 best solutions below

0
On BEST ANSWER

You explicitly store lvalue references or values via std::tuple<Args...> and forward into original value categories when expanding its elements using std::apply

template<typename... Args>
inline auto lazy_format(std::format_string<Args...> fmt, Args&&... args)
{
    return [fmt, args_tuple=std::tuple<Args...>(std::forward<Args>(args)...)]() mutable {
      return std::apply(
        [fmt](auto&... args) { return std::format(fmt, std::forward<Args>(args)...); }, 
        args_tuple);
    };
}

Demo

0
On

An alternative, perhaps less convenient, solution to the 'arrays decaying to pointers' problem would be:

auto callback = lazy_format("Hello, my name is {}, my age is {}, my hobby is {}",
    std::string_view ("Arthur"), 8, std::string_view ("Dinosaurs"));

Also, doing it this way, you need to declare your lambda mutable. If you don't, you get the following error message from the compiler (I simplified the call to lazy_format a little to make things clearer):

auto callback = lazy_format("Hello, I am {}", 8);

...

prog.cc:10:52: error: binding reference of type 'std::remove_reference<int>::type&' {aka 'int&'} to 'const int' discards qualifiers
   10 |         return std::format(fmt, std::forward<Args>(args)...);

Someone smarter than me can probably explain why std::forward is returning a non-const reference here (which, in turn, cannot bind to args, because args is a const int).

0
On

First observation, infere the forwarding:

return [fmt, & ... args]()
{ std::format(fmt
, std::forward<Args>(args)...); };

Second observation, capture by std::reference_wrapper:

return [fmt, ... a = std::ref(args)]()
{ std::format(fmt
, std::forward<Args>(a.get())...); };

Third method, decay the format:

template<typename ... Args
inline auto lazy_format(std::format_string<std::decay_t<Args>...> fmt, Args&&... args)
{
return [fmt, args ...]()
{ std::format(fmt, args...); };

Best method: After capturing by value, use std::array instead of array, and std::string_view or std::string instead of character array:

using std::literals;
//"str"s->string{"str"};
//"str"sv->string_view{"str"};
auto lf1 = lazy_format("{}, {:s}", std::array{1,5,8}, "a+b+c"sv);
auto lf2 = lazy_format("{}, {:s}", std::array{1,5,8}, "a+b+c"s);

Be careful with the first 3 methods and std::string_view. Incurring references and views risks dangling them. You can also capture string literals as arrays and format as strings:

//Since C++23, "{:s}" formats ranges as string:
auto lf3 = lazy_format("{}, {:s}", std::array{1,5,8}, std::array{"a+b+c"});
0
On

Hot take:

auto callback = [&]{return std::format("...", ...);};

Why make only the std::format call lazy, when you can make the evaluation of all arguments lazy too.

This requires a tiny bit more effort to avoid dangling captures, but should be cheaper to construct on the happy path than the solution that copies all arguments.

0
On

This is based on the code specified in the last part of the question. Basically the issue is that, in some cases, we want to copy/move the parameter, in other cases we just want to bind the reference, and for the lambda for a given argument, we can only specify either capturing by value or by reference, but not conditionally do both. The link provided by @Jarod42 in the comments is helpful, but in this case we need even finer control. One way to achieve this is to write a custom wrapper which we can just capture by value and then let the wrapper class handle the logic of when to take a reference and when to copy/move the value. The following is a possible implementation.

//Default case, either copy or move
template <typename T>
struct CustomRef{
    std::decay_t<T> val;
    CustomRef(T x):val{std::forward<T>(x)}{}
    const std::decay_t<T>& get() const{
        return val;
    }
};

//Special case, when it is legal to form a reference and use it later. Usual warnings about dangling references apply.
template <typename T>
requires (std::is_lvalue_reference_v<T> && requires(const std::decay_t<T>* p, T val){p = &val;})
struct CustomRef<T>{
    T val;
    CustomRef(T x):val{x}{}
    const std::decay_t<T>& get() const{
        return val;
    }
};

For the code

template<typename... Args>
inline auto lazy_format(std::format_string<const std::decay_t<Args>&...> fmt, Args&&... args)
{
    return [fmt=std::move(fmt), ...args=CustomRef<Args>(std::forward<Args>(args))]() {
        return std::format(fmt, args.get()...);
    };
}

auto create_callback(const A& a)
{
    return lazy_format("Hello {}, my age is {}, my hobby is {}, my favorite color is {}",
        A(1), 5, "Dinosaurs", a);
}

The following result is given,

Created [A: 2]
Created [A: 1]
Moved [A: 1]
Moved [A: 1]
Destroyed [A: -1]
Destroyed [A: -1]
Hello [A: 1], my age is 5, my hobby is Dinosaurs, my favorite color is [A: 2]
Destroyed [A: 1]
Destroyed [A: 2]

which satisfies the requirement

    // [A: 1] should be moved
    // [A: 2] should not be moved or copied

The usual warnings about dangling references apply.

Demo: https://godbolt.org/z/fvWKP7YPP

Update: This answer provides a perfect solution for forwarding the type and value categories and should be used instead of the above for this particular problem. The wrapper for custom forwarding may still be useful in other cases.