Fun with C++26 reflection - Keyword Arguments
In this blog post, we’ll explore implementing order-independent keyword arguments for C++ through use of C++26’s proposed reflection features. I stumbled upon this technique while experimenting with reflection a few days ago and thought it might be worthwhile to share, as it nicely showcases just how powerful the proposed reflection features are.
An example implementation of the technique presented in this blog post can be found on GitHub. It can be used with Bloomberg’s experimental P2996 clang fork. If you enjoy these shenanigans, feel free to leave a star. :)
Prior art
Named, labeled or keyword arguments have been proposed many times over the years, but as EWG issue 150 notes: all of these attempts have failed. Here is several past proposals on the topic:
Since none of these proposals were accepted, we have to be somewhat creative to get similar functionality in C++. Naturally, there are various approaches to this problem. Below is a short overview of what you can already do without reflection.
Designated initializers
Let’s start with the simplest way to achieve keyword argument-like syntax. C++20 introduced designated initializers for aggregate types, which gives us the initialization syntax Point{.x=42, .y=7}
.
In a function call’s argument list the type can potentially be deduced, so we could write foo({.x=2, .y=2})
. While this requires extra curly braces and .
-prefixes for every member name, syntactically this is almost what we want.
Usage example (Run on Compiler Explorer):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
struct FooArgs {
int x;
int y;
};
void foo(FooArgs args) {
std::println("x: {} y: {}", args.x, args.y);
}
struct BarArgs {
int x;
};
void bar(int x, BarArgs args) {
std::println("x: {} y: {}", x, args.x);
}
int main() {
// optional keyword arguments
foo({.x = 2, .y = 42});
foo({.x = 2});
// positional arguments and keyword arguments
bar(12, {.x = 10});
}
Unfortunately this has various drawbacks:
- Extra Type Definitions - we need to define a type for every set of keyword arguments out-of-line.
- Optional Arguments - While
std::optional
can be used to express optionality of arguments, additional keyword arguments cannot be passed. - Order Sensitivity - Arguments must appear in the exact order their corresponding members were declared in the aggregate, although skipping members is still fine.
Helper objects
As it turns out, making the desired syntax bar(12, x = 10)
valid C++ is not actually that difficult. To do this, let x
be an object of some type with an operator=
overload that wraps the value in some way that later lets us retrieve it by name.
You might be able to see the problem here already - this needs to be done for every named argument.
Essentially, all you need to do at the library side of things is define a wrapper, like so:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
template <typename T, util::fixed_string Name>
struct TypedArg {
T value;
decltype(auto) operator*(this auto&& self) {
return std::forward<decltype(self)>(self).value;
}
};
template <util::fixed_string Name>
struct Arg {
template <typename T>
TypedArg<T, Name> operator=(T&& value) const{
return {std::forward<T>(value)};
}
};
Now we can introduce helpers like this:
1
constexpr inline Arg<"name"> name;
This could potentially be hidden behind a macro to reduce the possibility of messing up the repetition of the argument’s name, but you still need to carefully do this wherever keyword arguments shall be used.
To accept keyword arguments, functions must wrap their parameters as well.
1
2
3
void foo(int x, int y);
// becomes
void foo(TypedArg<int, "x"> x, TypedArg<int, "y"> y);
Once again, a macro could help here.
To access the wrapped value, we can use the unary *
operator:
1
2
3
4
5
6
7
8
void bar(int a, TypedArg<int, "x"> x) {
std::println("a: {} x: {}", a, *x);
}
constexpr inline Arg<"x"> x;
int main() {
bar(10, x = 4);
}
Order-independent arguments
To support order-independent arguments, functions can receive the keyword arguments as a pack. We can then pick out the desired keyword arguments by re-using std::get
’s ability to retrieve a tuple’s element by type.
1
2
3
4
template <typename Needle, typename... Ts>
constexpr decltype(auto) pick(Ts&&... args) {
return *std::get<Needle>(std::make_tuple(std::forward<Ts>(args)...));
}
Now, keyword arguments can be passed in any order (Run on Compiler Explorer):
1
2
3
4
5
6
7
8
9
10
11
12
13
void oof(auto... kwargs) {
auto x = pick<TypedArg<int, "x">>(kwargs...);
auto y = pick<TypedArg<int, "y">>(kwargs...);
std::println("x: {}, y: {}", x, y);
}
constexpr inline Arg<"x"> x;
constexpr inline Arg<"y"> y;
int main() {
oof(y=42, x=2);
oof(x=2, y=42);
}
The same technique can be used to implement optional arguments. To do this, simply let pick
return a default if none of the argument pack’s elements was of the desired type.
args
variable template
To further improve upon this, we want to eliminate the error-prone and somewhat unpleasant need to define helpers out-of-line first. Since they are all of the same type, we can instead use a single variable template arg
:
1
2
template <util::fixed_string Name>
constexpr inline Arg<Name> arg{};
This allows us to write code like this (Run on Compiler Explorer):
1
2
foo(arg<"x"> = 2, arg<"y"> = 42);
bar(12, arg<"x"> = 10);
However, that approach is still somewhat verbose. Also note that the space between >
and =
is required to avoid parsing issues.
User-defined literal operator template
A user-defined literal operator template can be used to further streamline the syntax:
1
2
3
4
template<util::fixed_string Name>
constexpr Arg<Name> operator ""_arg() {
return {};
}
Now, we can write:
1
2
3
4
5
6
// optional keyword arguments
foo("x"_arg = 2, "y"_arg = 42);
foo("x"_arg = 2);
// positional arguments and keyword arguments
bar(12, "x"_arg = 10);
This might be a little prettier than the previous example, but it’s still rather verbose. However, if we want the desired syntax back we could still write:
1
2
3
4
constexpr inline auto x = "x"_arg;
constexpr inline auto y = "y"_arg;
foo(x=2, y=3);
A reflective approach
So.. can we do any better with reflection?
While we might not be able to provide the desired syntax foo(3, x=5)
directly, reflection allows us to inject new class types with named non-static data members. For all intents and purposes the keyword arguments are therefore collected into a named tuple.
This means we can constrain the receiving function to express non-optionality of keyword arguments:
1
2
3
4
5
6
7
8
template <typename T>
requires requires(T kwargs) {
{ kwargs.x } -> std::convertible_to<int>;
{ kwargs.y } -> std::convertible_to<int>;
}
void foo(erl::kwargs_t<T> const& kwargs) {
std::println("x: {} y: {}", kwargs.x, erl::get<"y">(kwargs));
}
All keyword arguments that we require to exist can safely be accessed using member access syntax or erl::get
.
Optional keyword arguments can be accessed with erl::get_or
or a combination of erl::get
with erl::has_arg
or an inline requires expression:
1
2
3
4
5
6
7
8
9
10
11
12
template <typename T>
void foo(erl::kwargs_t<T> const& kwargs) {
if constexpr (erl::has_arg<T>("x")) {
std::println("x: {}", get<"x">(kwargs));
}
if constexpr (requires { kwargs.y; }) {
std::println("y: {}", kwargs.y);
}
std::println("z: {}", get_or<"z">(kwargs, "<unmatched>"));
}
To create the keyword argument tuple at the call site, we need to wrap all keyword arguments somehow - for this we will later introduce the macro make_args
. This is how it can be used:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// optional keyword arguments
foo(make_args(y = 42, x = 2));
foo(make_args(x = 2));
// positional arguments and keyword arguments
bar(12, make_args(x = 10));
// references
int const baz = 24;
bar(12, make_args(&x = baz));
// shorthand
int x = 2;
foo(make_args(x, y=23));
The order in which keyword arguments appear does not matter and we can use shorthands. If this reminds you of lambdas, you’re spot on.
Reflecting lambda closure types
Lambda captures are almost a perfect fit - their order does not matter, their type is deduced and lambdas introduce a class type with every capture corresponding to a non-static data member for us.
Unfortunately though lambda closures are neither decomposable through a structured binding (unless you use GCC) nor are lambda captures directly accessible outside of the lambda’s body.
Nevertheless, C++26 reflection allows us to reflect private members.
Expansion
Since this blog post is mostly about P2996, expansion statements (as proposed in P1306) are not used. In lieu of expansion statements, we will instead use the
expand
helper. The syntax might look a little weird at first, but you’ll get used to it.
1 2 3 4 5 6 7 8 9 10 //p1306 expansion statement template for (constexpr auto member : nonstatic_data_members_of(^^Type)) { // ... } // roughly equivalent to [:expand(nonstatic_data_members_of(^^Type)):] >> []<std::meta::info... Member> { // ... }The implementation of
expand
looks roughly like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 namespace impl { template <auto... Vs> struct Replicator { template <typename F> constexpr decltype(auto) operator>>(F fnc) const { return fnc.template operator()<Vs...>(); } }; template <auto... Vs> constexpr static Replicator<Vs...> replicator{}; } // namespace impl template <std::ranges::range R> consteval auto expand(R const& range) { std::vector<std::meta::info> args; for (auto item : range) { args.push_back(reflect_value(item)); } return substitute(^^impl::replicator, args); }This has the added benefit of giving us the items as a pack.
With expand
we can now print some information about a lambda closure type, so let’s do that.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int main() {
int baz = 42;
auto closure = [x=420, &bar = baz]{};
using closure_type = decltype(closure);
[:expand(nonstatic_data_members_of(^^closure_type)):] >> [&]<auto... members> {
(std::println("has identifier: {} - type: {:<5} - value: {}",
has_identifier(members),
display_string_of(type_of(members)),
closure.[:members:]), ...);
};
}
// Output:
// has identifier: false - type: int - value: 420
// has identifier: false - type: int & - value: 42
Good news, we can reuse Java memes we can get types of lambda captures and references are properly handled. Even better, we can splice in the respective member to access captures outside of the lambda (for now.. see P3587). Bad news though, the members are all unnamed.
Order of captures
Note that [expr.prim.lambda.capture]/10 makes the declaration order of the lambda closure’s members unspecified. While the following hackery with lambdas might work, it is not guaranteed.
Parsing the capture list
To work around the lack of member names, we can stringify the capture list and parse it to recover the member names from it. To do this, we introduce the macro make_args
.
1
2
3
4
5
6
7
8
namespace kwargs {
template <fixed_string str, typename T>
auto from_lambda(T&& captures) {
// ...
}
}
#define make_args(...) ::kwargs::from_lambda<#__VA_ARGS__>([__VA_ARGS__] {})
Let’s address the elephant in the room - parsing even a subset of C++ correctly is difficult. Lambda capture lists can be highly complex, but the vast majority of “odd” ones aren’t really meaningful in the context of named arguments. This allows us to limit the scope of our parser.
We obviously want to support captures of the form arg1 = 123, arg2 = ident
, but it would also be nice to allow for shorthands such as x,y
- which would be equivalent to x=x,y=y
in a lambda’s capture list.
Additionally, capturing arguments by reference is sometimes necessary. Our parser must therefore handle cases like &foo = bar
or &foo
without failing. This leaves us with two grammar rules:
1
2
capture-list ::= capture ("," capture)* ;
capture ::= ["&"] identifier [ "=" expression ] ;
Utilities
Unfortunately, parsing expressions is still unavoidable since they can contain commas, as in foo(1, 2)
, Foo{1, 2}
or foo[1, 2]
. However, since we do not actually need to understand the expressions, it’s sufficient to ensure we do not prematurely stop on a ,
inside unbalanced curly braces, parentheses or square brackets when skipping forward to the next capture.
Let’s start by defining a Parser
base class:
1
2
3
4
5
6
7
struct Parser {
std::string_view data;
std::size_t cursor{0};
[[nodiscard]] constexpr char current() const { return data[cursor]; }
[[nodiscard]] constexpr bool is_valid() const { return cursor < data.length(); }
};
The desired utility can now be implemented. Since it does not matter if the code we parse is syntactically valid, counting (]
the same way as ()
is fine.
1
2
3
4
5
6
7
8
9
10
11
12
13
constexpr void skip_to(std::same_as<char> auto... needles) {
int brace_count = 0;
while (is_valid()) {
if (char c = current(); brace_count == 0 && ((c == needles) || ...)) {
break;
} else if (c == '[' || c == '{' || c == '(') {
++brace_count;
} else if (c == ']' || c == '}' || c == ')') {
--brace_count;
}
++cursor;
}
}
Since whitespace is also often not significant, another utility function to skip whitespace - let’s call it skip_whitespace
- is also very useful. Since its implementation is trivial, it is omitted here.
Implementing the parser
At this point, it’s also advisable to reject various captures that would not make sense for our use case. These include:
Capture Kind | Reasoning |
---|---|
Capturing this | A member cannot be named this , so injecting the container would fail. |
Default captures = and & | Since the lambda’s body is empty, these would not capture anything. |
Packs ...foo | Every argument must have a name for keyword argument handling. |
With the aforementioned utilities, the actual capture list parser can now be implemented as follows:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
struct NameParser : Parser {
std::vector<std::string_view> names;
constexpr bool parse() {
while (is_valid()) {
skip_whitespace();
if (current() == '&') {
// might be captured by reference
++cursor;
skip_whitespace();
}
if (current() == '.') {
// pack captured, reject
return false;
}
auto start = cursor;
// find `=`, `,` or whitespace
skip_to('=', ',', ' ', '\r', '\n', '\t');
// retrieve the name
if (cursor - start == 0) {
// default capture or invalid name
return false;
}
auto name = data.substr(start, cursor - start);
if (name == "this" || name == "*this") {
// this captured, reject
return false;
}
names.push_back(name);
// skip ahead to next capture
// this won't move the cursor if the current character is already `,`
skip_to(',');
++cursor;
}
return true;
}
};
Constexpr Exceptions
P3068 proposes support for exceptions during constant evaluation. If accepted, we could make
names
a local variable and haveparse
return it. Invalid captures could then be rejected by throwing an exception.
Injecting the kwargs container type
Since we now know the names for the lambda’s unnamed members, we can inject an aggregate class type with named members of appropriate type for every capture of the lambda.
First, we need to parse the stringified capture list and create data member specifications that associate each non-static data member of the lambda closure with its corresponding name. This can then be used to inject the keyword argument container type.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
template <typename Impl>
struct [[nodiscard]] kwargs_t : Impl {
using type = Impl;
};
template <util::fixed_string Names, typename... Ts>
constexpr auto make(Ts&&... values) {
struct kwargs_impl;
consteval {
std::vector<std::meta::info> types{^^Ts...};
std::vector<std::meta::info> args;
auto parser = NameParser{Names.to_sv()};
if(!parser.parse()) {
// name list rejected or parsing error, abort
return;
}
// associate every argument with the corresponding name
// retrieved by parsing the capture list
for (auto [member, name] : std::views::zip(types, parser.names)) {
args.push_back(data_member_spec(member, {.name = name}));
}
define_aggregate(^^kwargs_impl, args);
};
// ensure injecting the class worked
static_assert(is_type(^^kwargs_impl), "Could not inject named argument class");
return kwargs_t<kwargs_impl>;
}
At this point make<"x,y">(123, "foo")
can already be used to make named arguments x=123
and y="foo"
without the use of lambdas.
For the lambda hackery to work, we need to reflect the lambda’s private non-static data members to produce an appropriate keyword argument container. Finally we need to extract these members from the lambda produced by the make_args
macro.
1
2
3
4
5
6
7
8
9
template <util::fixed_string Names, typename T>
auto from_lambda(T&& lambda) {
using fnc_t = std::remove_cvref_t<T>;
return [:meta::expand(nonstatic_data_members_of(^^fnc_t)):]
>> [&]<auto... member>() {
return make<Names>(std::forward<T>(lambda).[:member:]...);
};
}
As mentioned before, to simplify usage on the receiving side, kwargs_t<T>
should also implement the tuple protocol. This means we need to provide specializations for std::tuple_size
and std::tuple_element
. Also we’ll need to implement get
for kwargs_t<T>
.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
template <typename T>
struct std::tuple_size<kwargs_t<T>>
: public integral_constant<size_t,
nonstatic_data_members_of(^^std::remove_cvref_t<T>).size()>{};
template <std::size_t I, typename T>
struct std::tuple_element<I, kwargs_t<T>> {
using type = [:get_nth_field(^^T, I):];
};
template <std::size_t I, typename T>
constexpr auto get(kwargs_t<T> const& t) noexcept {
return t.[:get_nth_field(^^T, I):];
}
Additionally, we want to be able to retrieve keyword arguments by name. Since this might fail, it can be useful to return a default value when no member with the requested name is found. For this, we introduce get_or
.
1
2
3
4
5
6
7
8
9
10
11
12
13
template <fixed_string name, typename T>
constexpr auto get_or(kwargs_t<T> const& t) {
return t.[:get_nth_field(^^T, get_member_index(name.to_sv())):];
}
template <fixed_string name, typename T, typename R>
constexpr auto get_or(kwargs_t<T> const& t, R default_) {
if constexpr (get_member_index<T>(name.to_sv()) == -1UZ) {
return default_;
} else {
return t.[:get_nth_field(^^T, get_member_index<T>(name.to_sv())):];
}
}
Function parameter reflection
What if we could wrap any function and convert keyword arguments to positional arguments in calls to it as needed? Thanks to P3096 function parameter reflection, this would also be possible.
Essentially, we want to be able to write the following:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void foo_impl(int x, char c, double d) {
printf("x: %d c: %c d: %f\n", x, c, d);
}
constexpr inline erl::kwargs::Wrap<^^foo_impl> foo;
int main() {
foo(3, 'c', 2.2);
foo(3, 'c', make_args(d = 2.2));
foo(3, make_args(c = 'c', d = 2.2));
foo(make_args(c = 'c', x = 3, d = 2.2));
// error: Argument `d` missing.
// foo(3, make_args(c = 'c'));
// error: Positional argument `x` repeated as keyword argument.
// foo(3, make_args(x = 4));
}
Note that this only works with free functions and static member functions. Function templates and function objects are not supported.
Implementation
Unfortunately non-trailing packs are not deduced. If our keyword argument tuple were the first parameter this would be trivial, but realistically that does not look very nice and would also be inconsistent with our previous mixed usage.
Consider the following example (Run on Compiler Explorer):
1
2
3
4
5
6
7
8
9
10
template <typename... Args, typename T>
void foo(Args... args, T kwargs);
int main() {
foo<int>(1, 0); // OK
// Args... = <>, T = int
// error: no matching function for call to 'foo'
foo(1, 0);
}
P2347 solves this issue, for more information please refer to cor3ntin’s amazing blog post about it.
Pack indexing
To circumvent this limitation, we can leverage P2662 pack indexing to extract the last argument, which allows us to check if it is a keyword argument container.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
template <std::meta::info F>
requires (is_function(F))
struct Wrap {
template <typename... Args>
requires (sizeof...(Args) > 0)
static constexpr decltype(auto) operator()(Args&&... args) {
using T = std::remove_cvref_t<Args...[pos_only]>;
if constexpr (erl::is_kwargs<T>) {
// handle keyword arguments
// ...
} else {
// no keyword arguments
return [:F:](std::forward<Args>(args)...);
}
}
};
Combining positional and keyword arguments
To properly merge the received positional arguments with the given keyword arguments, we need to:
- Expand
args
except for the last element - Extract the remaining parameters of
F
from the last element ofargs
(the keyword argument container)
Expansion
For the following to work, one adjustment to the aforementioned
expand
helper must be made.
Replicator
’soperator>>
expands all elements of the range into the template argument list of a single call to the given lambda. Similarly we wantoperator>>=
to call the lambda once per element, passing one template argument at a time.
1 2 3 4 5 6 7 8 9 10 11 12 template <auto... Vs> struct Replicator { template <typename F> constexpr decltype(auto) operator>>(F fnc) const { return fnc.template operator()<Vs...>(); } template <typename F> constexpr void operator>>=(F fnc) const { (fnc.template operator()<Vs>(), ...); } };Additionally, we introduce a shorthand
sequence(N)
, which is equivalent toexpand(std::ranges::iota_view{0U, N})
.
To do this, we need two nested expansions. The first expansion must expand the reflected parameters of F
, except for the first sizeof...(Args) - 1
parameters. The second expansion shall expand an integer sequence from 0
to sizeof...(Args) - 1
.
1
2
3
4
5
6
7
8
9
10
11
12
13
static constexpr std::size_t pos_only = sizeof...(Args) - 1;
return [:meta::expand(parameters_of(F) | std::views::drop(pos_only)):]
>> [&]<auto... Params> {
return [:meta::sequence(pos_only):]
>> [&]<std::size_t... Idx> {
return [:F:](
/* positional arguments */
std::forward<Args...[Idx]>(args...[Idx])...,
/* keyword arguments */
get<meta::get_member_index<T>(identifier_of(Params))>(args...[pos_only])...);
};
};
Adding diagnostics
We also want to detect and report errors when:
- A positional argument is repeated as keyword argument
- A required argument was missing altogether
For this we can use expand
in combination with the >>=
operator to look at F
’s parameters one at a time. Also p2741 can be used at this point to provide very nice diagnostics.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
template <typename T, std::size_t PosOnly = 0>
static constexpr void check_args() {
[:erl::meta::expand(parameters_of(F) | std::views::take(PosOnly)):]
>>= [&]<auto Param> {
static_assert(!erl::meta::has_member<T>(identifier_of(Param)),
std::string{} + "In call to `" + identifier_of(F) + "`: "
"Positional argument `" + identifier_of(Param) + "` "
"repeated as keyword argument."
);
};
[:erl::meta::expand(parameters_of(F) | std::views::drop(PosOnly)):]
>>= [&]<auto Param> {
static_assert(erl::meta::has_member<T>(identifier_of(Param)),
"In call to `" + std::string(identifier_of(F)) + "`: "
"Argument `" + identifier_of(Param) + "` missing."
);
};
}
Bonus: Format strings with named arguments
Aside from positional arguments, the awesome fmt
library also allows for named arguments. You might be able to recognize the approach used in fmt
, here’s what the code would look like:
1
2
3
4
5
6
7
fmt::print("Hello, {name}! The answer is {number}. Goodbye, {name}.",
fmt::arg("name", "World"), fmt::arg("number", 42));
// alternatively
using namespace fmt::literals;
fmt::print("Hello, {name}! The answer is {number}. Goodbye, {name}.",
"name"_a="World", "number"_a=42);
Let’s implement similar functionality using erl::kwargs_t
.
Transforming the format string
Since std::format
does not yet support named arguments like fmt
, we need to transform the format string into a format that std::format
understands.
std::format
allows referring to arguments by position (e.g. std::format("{1}{0}", 0, 42)
). We can therefore expand all keyword arguments into the std::format
call’s argument list and transform the format string so it refers to them by position rather than by name.
The parser utilities we defined earlier can be re-used to transform the format string. Here’s an example implementation:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
struct FmtParser : Parser {
constexpr std::string transform(std::ranges::forward_range auto&& names) {
std::string out;
int brace_count = 0;
while (is_valid()) {
out += current();
if (current() == '{') {
++cursor;
if (current() == '{') {
// double curly braces means escaped curly braces
// => treat the content as text
auto start = cursor;
// skip to first unbalanced }
// this will match the outer {
skip_to('}');
out += data.substr(start, cursor - start);
continue;
}
// find name
auto start = cursor;
skip_to('}', ':');
auto name = data.substr(start, cursor - start);
// replace name
auto it = std::find(names.begin(), names.end(), name);
auto idx = std::distance(names.begin(), it);
out += itoa(idx);
out += current();
}
++cursor;
}
return out;
}
};
Wrapping std::format_string
At some point we will have to produce a std::format_string
. To avoid having to resort to runtime format checking, we can instead instantiate a template function that handles formatting for us. This function shall receive the format string as constant template argument.
So let’s first provide a replacement for the std::format_string
argument of format
- note that the constructor must be consteval
.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
template <typename Args>
struct NamedFormatString {
using format_type = std::string (*)(Args const&);
format_type format;
template <typename Tp>
requires std::convertible_to<Tp const&, std::string_view>
consteval explicit(false) NamedFormatString(Tp const& str) {
auto parser = FmtParser{str};
auto fmt = parser.transform(meta::get_member_names<typename Args::type>());
format = extract<format_type>(
substitute(^^format_impl, {meta::intern(fmt),^^Args}));
}
};
This will instantiate format_impl
with the transformed format string as constant template argument for us. In format_impl
we can retrieve the keyword arguments and simply delegate to std::format
.
1
2
3
4
5
6
7
template <util::fixed_string fmt, typename Args>
std::string format_impl(Args const& kwargs) {
return [:meta::sequence(std::tuple_size_v<Args>):]
>> [&]<std::size_t... Idx>() {
return std::format(fmt, get<Idx>(kwargs)...);
};
}
We can now define two versions of format
- one for named arguments and one to wrap the existing functionality of std::format
.
1
2
3
4
5
6
7
8
9
10
11
template <typename T>
requires(is_kwargs<T>)
void print(NamedFormatString<T> fmt, T const& kwargs) {
fmt.print(kwargs);
}
template <typename... Args>
requires(sizeof...(Args) != 1 || (!is_kwargs<std::remove_cvref_t<Args>> && ...))
void print(std::format_string<Args...> fmt, Args&&... args) {
std::print(fmt, std::forward<Args>(args)...);
}
Similarly, std::print
and std::println
can be wrapped using the same approach.
Usage
With this, we can now write:
1
2
3
erl::format("{bar}{foo}", make_args(bar=0, foo=42));
// instead of
std::format("{1}{0}", 0, 42)
Notice how the order in which arguments appear in make_args
does not matter.
Here’s a full example (Run on compiler explorer):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <vector>
#include <iostream>
#define KWARGS_FORMATTING 1
#include <kwargs.h>
int main() {
int x = 3;
std::vector<int> list = {1, 2, 3, 4};
erl::print("{} {}\n", 42, x);
erl::print("{1} {0}\n", x, 42);
erl::print("{x} {y}\n", make_args(x=42, y=x));
erl::print("x: {} list: {} : {}", x, list, "foo");
erl::print("x: {0} list: {2} : {1}", x, "foo", list);
erl::println("x: {x} list: {list} : {str}",
make_args(x=x, str="foo", list=list));
erl::println("x: {x} list: {list} : {str}",
make_args(x, str="foo", list));
std::cout << erl::format("{x} {y}", make_args(x, y=3)) << '\n';
}