在 C++ 的编程世界里,探索新特性总是充满惊喜。今天,咱们就一起深入研究一下如何利用 C++26 中备受期待的反射特性,来实现顺序无关的关键字参数,这一过程不仅能让我们感受到 C++ 的强大魅力,还能为日常编程带来更多便利和乐趣。
在开始之前,先给大家介绍一下相关背景。多年来,命名参数、带标签参数或者说关键字参数的提议层出不穷,像 n4172、p1229、p0671 等提案,都在尝试解决这个问题,但可惜的是,这些提案都没有被最终采纳。所以,在 C++ 中实现类似功能,我们得另辟蹊径。
在 C++20 引入指定初始化器之前,实现类似关键字参数的功能并不容易。而现在,指定初始化器为聚合类型提供了一种新的初始化语法,比如Point{.x=42, .y=7}
。在函数调用时,我们也可以利用这种语法,像foo({.x=2, .y=2})
这样传递参数。虽然这种方式需要额外的花括号和成员名前缀,但在语法上已经很接近我们想要的效果了。不过它也有不少缺点,比如每次都要为一组关键字参数定义一个额外的类型;使用std::optional
来表示可选参数时,就无法传递更多的关键字参数;而且参数的顺序必须和聚合类型中成员的声明顺序一致,尽管可以跳过某些成员。
为了让bar(12, x = 10)
这样的语法在 C++ 中生效,我们可以借助辅助对象来实现。具体做法是,定义一个包装器,让x
成为某个类型的对象,并重载其operator=
,以便后续能通过名称来获取值。代码实现如下:
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)};
}
};
然后,我们可以定义一些辅助变量,像constexpr inline Arg<"name"> name;
。为了让函数能接受这样的关键字参数,函数的参数也需要进行相应的包装,比如void foo(TypedArg<int, "x"> x, TypedArg<int, "y"> y);
。使用时,通过一元*
运算符来获取包装的值,例如:
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);
}
为了支持顺序无关的参数传递,函数可以将关键字参数作为参数包来接收,然后利用std::get
根据类型来提取所需的关键字参数。代码如下:
template <typename Needle, typename... Ts>
constexpr decltype(auto) pick(Ts&&... args) {
return *std::get<Needle>(std::make_tuple(std::forward<Ts>(args)...));
}
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);
}
同样的技巧也可以用来实现可选参数,让pick
在参数包中没有所需类型的参数时返回默认值即可。
为了减少定义辅助变量的繁琐过程,我们可以使用变量模板arg
,代码如下:
template <util::fixed_string Name>
constexpr inline Arg<Name> arg{};
这样,我们就可以像foo(arg<"x"> = 2, arg<"y"> = 42);
这样调用函数了。此外,还可以通过用户定义字面量运算符模板来进一步简化语法,比如:
template<util::fixed_string Name>
constexpr Arg<Name> operator ""_arg() {
return {};
}
使用时就可以写成foo("x"_arg = 2, "y"_arg = 42);
。
前面的方法虽然在一定程度上实现了关键字参数的功能,但还是不够完美,那么 C++26 的反射特性能否带来更好的解决方案呢?答案是肯定的!借助反射,我们可以注入带有命名非静态数据成员的新类类型,将关键字参数收集到一个命名元组中。这样,接收函数就能通过成员访问语法或特定的获取函数来安全地访问所需的关键字参数。例如:
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));
}
对于可选关键字参数,可以使用erl::get_or
,或者结合erl::get
与erl::has_arg
,甚至通过内联的requires
表达式来访问,示例代码如下:
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>"));
}
在调用函数时,需要使用make_args
宏来创建关键字参数元组,它的使用方式如下:
// 可选关键字参数
foo(make_args(y = 42, x = 2));
foo(make_args(x = 2));
// 位置参数和关键字参数混合
bar(12, make_args(x = 10));
// 引用
int const baz = 24;
bar(12, make_args(&x = baz));
// 简写
int x = 2;
foo(make_args(x, y=23));
这里关键字参数的顺序是无关紧要的,还支持简写,是不是很方便呢?
lambda 捕获在很多方面都很适合实现关键字参数的功能,比如它的捕获顺序无关紧要,类型可以自动推导,而且 lambda 会引入一个类类型,每个捕获对应一个非静态数据成员。但可惜的是,lambda 闭包通常不能通过结构化绑定来分解(除非使用 GCC),并且 lambda 捕获在闭包外部也不能直接访问。不过,C++26 的反射特性允许我们反射私有成员,这为解决问题提供了可能。
在本文中,由于主要围绕 P2996 展开,所以没有使用 P1306 中提议的扩展语句,而是使用了expand
辅助函数。它的语法可能看起来有点奇怪,但用习惯了就好。expand
函数的实现大概是这样的:
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{};
}
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);
}
通过expand
函数,我们可以打印出 lambda 闭包类型的一些信息。不过,目前通过反射获取的 lambda 捕获成员都是没有名字的,这可有点麻烦。
为了解决 lambda 捕获成员无名的问题,我们可以将捕获列表字符串化并解析,从而恢复成员名。这就需要引入make_args
宏,代码如下:
namespace kwargs {
template <fixed_string str, typename T>
auto from_lambda(T&& captures) {
// ...
}
}
#define make_args(...) ::kwargs::from_lambda<#__VA_ARGS__>([__VA_ARGS__] {})
虽然解析 C++ 代码并不容易,尤其是 lambda 捕获列表可能很复杂,但在命名参数的场景下,大部分复杂情况其实并不常见,所以我们可以限制解析器的范围。我们希望支持arg1 = 123, arg2 = ident
这样的捕获形式,同时也支持像x,y
这样的简写,它相当于x=x,y=y
。另外,捕获引用参数的情况也需要处理,比如&foo = bar
或&foo
。为此,我们定义了相应的语法规则:
capture-list ::= capture ("," capture)* ;
capture ::= ["&"] identifier [ "=" expression ] ;
在解析过程中,还需要处理表达式中可能包含逗号的情况,比如foo(1, 2)
、Foo{1, 2}
、foo[1, 2]
。为了避免在解析时提前停止,我们定义了skip_to
函数来跳过这些情况,同时还可以定义一个skip_whitespace
函数来跳过空白字符。
在实现解析器时,还需要拒绝一些对我们的用例没有意义的捕获,比如捕获this
、默认捕获=
和&
、参数包...foo
等。通过这些步骤,我们就可以实现一个相对完整的捕获列表解析器。
有了捕获列表的解析结果,我们就可以注入一个聚合类类型,为 lambda 的每个捕获定义具有相应类型的命名成员。具体实现时,首先要解析字符串化的捕获列表,创建数据成员规范,将 lambda 闭包的每个非静态数据成员与其对应的名称关联起来,然后注入关键字参数容器类型。
为了让kwargs_t<T>
能更好地使用,我们还需要为它实现元组协议,包括为std::tuple_size
和std::tuple_element
提供特化,以及实现get
函数。此外,为了能通过名称获取关键字参数并在找不到时返回默认值,我们还引入了get_or
函数。
借助 P3096 函数参数反射,我们甚至可以包装任何函数,根据需要将调用中的关键字参数转换为位置参数。不过,这一过程也存在一些限制,比如非尾随参数包无法推导,我们可以借助 P2662 包索引来提取最后一个参数,判断它是否是关键字参数容器,从而解决这个问题。
在合并位置参数和关键字参数时,需要进行一些复杂的操作,包括扩展参数、提取剩余参数等。同时,为了提高代码的健壮性,我们还可以添加诊断功能,检测并报告位置参数重复作为关键字参数、缺少必需参数等错误情况。
除了函数参数,fmt
库中的格式化字符串也支持命名参数。我们也可以利用erl::kwargs_t
来实现类似的功能。具体实现时,需要先将格式化字符串转换为std::format
能理解的格式,然后包装std::format_string
,最后定义相应的print
和format
函数。这样,我们就可以像erl::format("{bar}{foo}", make_args(bar=0, foo=42));
这样使用命名参数进行格式化输出了。
通过这次对 C++26 反射实现关键字参数的探索,我们不仅看到了 C++ 新特性的强大威力,也体验到了在编程中不断探索和创新的乐趣。虽然实现过程有些复杂,但最终的成果能为我们的编程工作带来极大的便利。希望大家也能在日常编程中多多尝试这些新特性,挖掘 C++ 更多的可能性。如果在实践过程中有任何问题或者想法,欢迎在评论区留言分享哦!
科技脉搏,每日跳动。
与敖行客 Allthinker一起,创造属于开发者的多彩世界。
- 智慧链接 思想协作 -