| CSDN(ID:CSDNnews)
我很喜欢做的一件事,就是比较不同编程语言如何解决相同的问题,尤其是当这些语言采取了截然不同的方法时,我觉得这非常具有教育意义。在这篇文章中,我们将尝试把反射(reflection)这一颠覆性的语言特性引入到 C++26 标准中。从根本上来讲,反射可以分为两大部分:
1、自省(Introspection):在编译期间,能够对程序进行查询的能力。
2、代码生成(Code Generation):让程序自动生成新代码的能力。
针对 C++26 的 P2996 提案是一个处理自省问题的核心提案,它为未来扩展反射功能奠定了基础,涵盖多个方向的延展功能(例如 P3294 的代码生成设计)。然而,虽然自省功能本身非常有用,但它只解决了一半的问题——知名 C++ 技术专家 Andrei Alexandrescu 甚至在 CppCon大会上宣称,如果没有代码生成,自省几乎是“无用的”。
目前,C++ 确实有一种代码生成功能:C 宏(C Macros)。不过,这种机制非常原始,且存在许多局限。首先,C 宏缺乏严格的语法规则,甚至可能在不知情的情况下调用宏(标准库实现对此有保护措施)。其次,实现一些简单的逻辑(如迭代或条件判断)往往需要相当复杂的技巧。然而,尽管存在这些问题,在某些场景下,C 宏仍然是最好的解决方案——这也反映了我们迫切需要更完善的代码生成机制。
另一方面,Rust 虽然没有任何自省功能,但它拥有成熟的代码生成机制,特别是其声明式和过程宏。因此本文将重点讨论 Rust 的过程宏,尤其是派生宏(derive macro)。我们将通过两个示例展示派生宏如何解决问题,分析其工作原理,以及我们如何为 C++26 提出截然不同的解决方案。
不过,我不是专业 Rust 程序员,因此如果我在文中犯了错误,还请大家指正。更新一下,在发布这篇博客后,有人指出我在一些地方犯了错误,我已经进行了更正。这些错误包括:我曾提到 Rust 属性无法接受任意值(其实它是可以的,只是旧版本选择不这么做),以及有比我提到的更好的方式来解析属性(实际上大多数人都采用类似做法)。
结构体的美化打印(Pretty-Printing)
当你学会如何声明一个带有新成员的类型后,很可能会想让这个类型进行调试打印(debug-prin table)。不仅是因为调试打印在日常开发中非常有用,还因为在 Rust 中实现这一功能非常简单:
fn main( ) { letp = Point { x: 1, y: 2}; // prints: p=Point { x: 1, y: 2 }println!( "p={p:?}"); }
代码的第一行通过 #[derive(Debug)] 让 Point 结构体支持调试打印。它的作用是自动生成代码,使得可以打印类型名称以及所有成员的名称和值,并按顺序输出。
在我手头的《Rust 编程语言》书中,第 82 页就展示了如何声明一个 struct,第 89 页则展示了如何让它支持调试打印,这几乎是 Rust 学习过程中最早会遇到的功能之一。对于这个任务,Rust 还提供了另一个简便的方式:dbg!(p),不过这里我使用 println! 是为了更贴近未来在 C++ 中实现类似功能的方式。
由于这是编译时的注解(annotation),如果以后我为 Point 结构体添加了一个新字段(比如我决定将其扩展为三维结构体,添加一个 z 字段),调试打印的输出也会自动更新,以打印新字段的值。
总结来说就是:非常简单!
你可能会问,这究竟是如何实现的?是什么使得宏和 Debug 特性(trait)能够实现这种交互?正如我之前提到的,不同于我们为 C++26 提出的方案,Rust 没有任何形式的自省(introspection)功能,也没有机制可以查询结构体的成员并对其进行迭代。
相反,Rust 的 derive 宏采用了非常不同的方式:它是一个函数,接收被注解结构体的 T oken 流作为输入,生成相应的 Token 流代码并注入到代码中。实际上,这些注入的代码并不一定与输入直接相关。
在这种情况下,我们通过获取 Point 结构体的 Token 流输入,解析它,并使用解析结果生成我们需要的输出, 从而绕过了缺乏自省的问题。我想这也算是一种“自省”——只不过它只能在特定情况下明确选择使用。
在上面的例子中,derive 宏生成了如下代码(我使用 cargo expand 得到的结果):
这个代码看起来并不复杂,但关键在于 Rust 程序员无需手动编写这些模板代码。他们只需要学习如何写一行代码(实际上,连一整行都不需要):#[derive(Debug)]。这就是代码生成的强大之处。
即便如此,这个结果也很有趣。为什么对 self.x 使用 &self.x,而对 self.y 使用 &&self.y 呢?这与 Rust 无法进行自省功能有关。在 Rust 中,最后一个字段可以是不定长类型(unsized type)。不定长类型可以被打印, 但是需要一个额外的间接层。derive 宏无法知道 y 是否是定长的( 在这个例子中它是 i32,所以是定长的),所以为了支 持两种情况,宏预先添加了这个额外的间接层。
在 C++ 中,如果想要提出的方案尽量贴近 Rust 的语法,可以这样实现:
intmain{ autop = Point{.x= 1, .y= 2}; // prints p=Point{.x=1, .y=2}std::println( "p={}", p); }
从本质上讲,C++ 和 Rust 的格式化机制有一些相似之处。在 Rust 中,你必须为 Debug trait 提供一个 impl。而在 C++ 中,你需要特化 std::formatter(我们不区分 Debug 和 Display)。正如我之前展示的,Rust 的宏调用会为类型注入正确的 impl Debug 代码,而在 C++ 中,我们并 没有这样做。
我在这里使用的特性 叫做“注解”(annotation), 这个功能 将在 P3394 提案中提出, 首次由 Daveed Vandevoorde 在 CppCon 的闭幕演讲中披露。 这个提案的目标是 让你能够以一种 自省 可以观察到的方式标注声明。值得注意的是,这里并没有发生任何代码注入,我们只是稍微扩展了一下自省功能。
然而,鉴于 C++ 本身具备自省功能 (或者将随着 P2996 的提出而获得), 这已经足够完成我们的目标。 我们可以提前提供一个特化的 std::formatter, 该特化会在类型带有 derive
inlineconstexprstruct{ } Debug;
template< classT> requires( has_annotation(^^ T, derive
一旦我们有了这个基础,特化的实现就可以对类型 T进行自省,获取我们需要的所有信息,以便展示:我们可以迭代所有非静态的数据成员,格式化它们的名称和值。一个简化的实现如下:
autoformat(T const& m, auto& ctx) const{ autoout = std::format_to(ctx.out, "{}", display_string_of(^^T)); *out++ = '{';
boolfirst = true; [:expand(nonstatic_data_members_of(^^T)):] >> [&]< autonsdm>{ if( notfirst) { *out++ = ','; *out++ = ' '; }first = false;
out = std::format_to(out, ".{}={}", identifier_of(nsdm), m.[:nsdm:]);};
*out++ = '}'; returnout; }};
某种意义上 来说,我们仍然是在生成代码——模板实际上就是 C++ 中的一种代码生成形式。但有趣的是,在这里我们通过非常不同的机制实现了相同的目标。
请注意,这就是完整的实现代码,可以看到代码量其实并不多。
JSON 序列化
在之前讨论的调试打印示例中,我们只是简单地按顺序打印所有成员。那么如果我们想做些更复杂的操作呢?在处理序列化时, 有时字段的名称可能需要与原始的成员名不同。还有些情况,目标格式在编程语言中根本无法直接表达——比如字段名可能是语言中的关键字,或者字段名包含空格等等。
因此,Rust 的 serde 库提供了许多注解属性,可以添加到类型和成员上,以控制序列化逻辑。下面是一个简单的例子:
#[derive(Serialize)]struct Person {#[serde(rename = "first name")]first: String,
#[serde(rename = "last name")]last: String, }
fn main {let person = Person {first: "Peter".to_owned, last: "Dimov".to_owned, };let j = serde_json::to_string(&person).unwrap;
//prints { "first name": "Peter", "last name": "Dimov"} println!( "{}", j); }
类似于 Debug特性,Serialize的派生宏 会为我们注入一个实现,其生成的代码如下:
在这里,你可以看到想要序列化的字段名(如 "first name"和 "last name")与实际的成员绑定在一起。需要注意的是,false as usize + 1 + 1是用来表示要序列化的字段数量的构造,这里的 2显然是字段的数量。
如果我们要添加一个中间名,并且只有当它非空时才进行序列化,可以使用 skip_serializing_if 属性:
#[serde(rename = "middle name", skip_serializing_if = "String::is_empty")] middle: String,
#[serde(rename = "last name")] last: String,}
生成的代码如下,具体新增部分为第 18-19 行、 第 26-37 行:
但在 C++ 中,我们并没有像 serde这样的库,它可以分离 序列化的字段名和成员变量的名称, 至少我目前不知道有这样的库。C++ 中,通常是 JSON 库处理 JSON 序列化,TOML 库处理 TOML 序列化等。也许这是因为 C++ 缺乏像 Rust 那样的语言支持,所以无法轻松实现这种序列化机制?
老实说,虽然在格式化方面 Rust 和 C++的实现有些相似,但在序列化的灵活性上 Rust 确实更具优势。尽管 C++ 中没有完全类似 serde 的库,但我们也可以使用类似 Boost.JSON 这样的库来实现序列化。
我们从支持 derive
struct[[= derive
intmain{ // prints {"x":1,"y":2}std:: cout<< boost::json::value_from(Point{.x= 1, .y= 2}) << '\n'; // prints {"first name":"Peter","last name":"Dimov"}std:: cout<< boost::json::value_from(Person{.first= "Peter", .last= "Dimov"}) << '\n'; }
整段代码只有 21 行,如果我保持与之前相同的模板形式,那么基本通过 derive可以实现:
namespaceboost::json { template< classT> requires( has_annotation(^^ T, derive
obj[field] = boost::json::value_from(t.[:M:]);};}}
这段代码应该看起来很熟悉,因为它基本上也是在做格式化工作,只不过这里我们是将成员添加到一个 JSON 对象中,而不是打印一堆键值对。 然后, 我们没有自动使用非静态数据成员的标识符,而是先尝试检查是否有 rename注解。 annotation_of
在这里添加 skip_serializing_if 的支持并不需要太多额外的工作,这也很好地展示了 C++ 和 Rust 处理方式之间的区别。在 Rust 中,你提供一个字符串,它会被注入并在内部调用;而在 C++ 中,我们通常会 直接提供一个可调用对象。
起初我以为这是因为 Rust 的属性语法不支持在这里使用可调用对象,但实际上似乎是因为 serde 在支持这一点之前就已经存在了。
我们需要为此添加一个新的注解类型:
然后稍微麻烦一点的部分是对它的解析, 我们需要提取出某个 serde::skip_serializing_if的特化类型注解。如果找到了,就尝试调用其 pred 成员函数,若该函数返回 true,就跳过该字段的序列化。
搜索过程如下所示(注意,我们需要使用 constexpr,因为需要拼接它以进行调用)。我确信这个部分可以通过更好的库 API 稍作清理(至少可以用一个 std::optional 来改进):
res = value_of(A);}}
returnres; };
然后,如果我们有这样的注解,就调用它来确定是否需要跳过这个成员。这里需要用 if constexpr 语句,因为如果 skip_if是空反射, 我们无法对其进行拼接。除此之外,整体逻辑非常简单: 如果有这样的注解,就调用它,如果返回 false,则跳过这个成员:
现在这段代码已经膨胀到了 51 行(新增部分为第 7 行、第 22-46 行):
namespaceserde { inlineconstexprstruct{ } Serialize{}; structrename{ charconst* field; }; template< classF> structskip_serializing_if{ F pred; }; }
namespaceboost::json { template< classT> requires( has_annotation(^^ T, derive
constexprautoskip_if = [] -> std::meta::info { autores = std::meta::info; for( autoA : annotations_of(M)) { autotype = type_of(A); if(has_template_arguments(type) andtemplate_of(type) == ^^serde::skip_serializing_if) { // found a specialization// but check to make sure we haven't found// two different ones.if(res != std::meta::info andres != value_of(A)) { throw"unexpected duplicate"; }
res = value_of(A);}}
returnres; };
ifconstexpr(skip_if != std::meta::info) { if( std::invoke([:skip_if:].pred, t.[:M:])) { return; }}
obj[field] = boost::json::value_from(t.[:M:]);};}}
此时,我想到了解决此问题的另一种有趣方法。 只有两个属性的时候这样做可能没有必要,但如果我打算实现 serde的全部功能, 有一个不单独处理每个属性解析的策略 可能会更加合理。 那么,如果我们将所有属性收集到一个类类型中,再使用这个类类型会怎样呢?
让我们看看这会是什么样子。
首先,我们创建一个新的类类型——attributes。我们将编程定义它,给它一个每个属性都对应的成员,此时难点在于成员的类型。对于像 serde::rename 这样的属性,我们应该使用 optional
这段代码使用了 std::meta::define_class,这是 P2996 中唯一一个用于代码生成的 API。它功能不多,但对当前需求来说足够用了。注意,由于我们遍历了命名空间 serde中的所有成员,需要确保排除 attributes——它当然也在这个命名空间中:
autounderlying = is_type(m) ? m : ^^ std::meta::info; specs.push_back(data_member_spec(substitute(^^ std::optional, {underlying}), {.name=identifier_of(m)}));}
define_class(^^attributes, specs);};
然后我们可以编写一个解析函数,将非静态数据成员的属性写入 attributes 实例中。 这里最麻烦的部分就是找到写入 attributes 哪个非静态数据成员。我们暂时跳过这部分逻辑,直接进入如何 利用这些工作 成果:
constexpr auto field = attrs.rename.transform([](serde::rename r){returnstd::string_view(r.field); }).value_or(identifier_of(M));
ifconstexpr (attrs.skip_serializing_if) { if(std::invoke( [ :*attrs. skip_serializing_if:].pred, t.[ :M:])) {return; }}
obj[field] = boost::json::value_from(t.[ :M:]); };}}
当然,我们将最复杂的逻辑(解析注解)移到了一个函数中,而 这个函数我没有包含在上面的代码块中。如我所说,对于只有两个属性的情况,这样做可能有点大材小用。不过,这种方法意味着添加一个新属性只需在命名空间 serde 中声明一个新类或类模板,然后在实现中使用它即可。
Rust 属性 vs. C++ 注解
在对比 C++ 和 Rust 中的 serde 解决方案时,有两个方面引起了我的注意:语法和库 的设计。
语法
首先从语法差异来看,使用时的体验是我最先关注的点。以下是 我在 Rust 中的声明:
#[serde(rename = "middle name", skip_serializing_if = "String::is_empty")] middle: String,
#[serde(rename = "last name")] last: String,}
而这是我在 C++ 中的声明:
[[=serde::rename( "middle name")]] [[=serde::skip_serializing_if(& std:: string::empty)]] std:: stringmiddle = "";
[[=serde::rename( "last name")]] std:: stringlast; };
可以看到,C++ 的注解语法显得更为复杂和冗长,而这大多是由于语法本身的问题。相较之下 Rust 的 注解较为简洁, 因为它们遵循不同于语言其余部分的语法规则 —— 比如 serde(rename = "first name")在 Rust 中是无效的, 这里也没有调用名为 serde 的函数。
这种差异带来的好处是,Rust 中的注解使用起来更加清晰自然,因为它真的就像是给选项赋值一样。例如,类似于 serde(rename = "first name") 这样的用法更像是传递配置参数,而不是在调用函数。这为使用者提供了灵活性,比如可以像这样使用属性:#[arg(short)]或 #[arg(short = 'k')],前者使用了默认值,而后者显式指定了 'k'。
看到这里,你可能会有一种冲动,想要 重用(非常特殊且具体的)属性语法 ,并允许在 C++ 中使用 using 关键字。 但实际上, 这样做并不会节省太多的输入:
// new version: 82 chars[[ usingserde: =rename( "middle name"), =skip_serializing_if(& std:: string::empty)]] std:: stringmiddle = ""; };
相比之下,Rust 版本只有 74 个字符。虽然长度上的差异并不大,但至少它少于 80 个。
另一方面,要关注 Rust 为实现这一点付出了什么代价。在 C++ 注解设计中, 注解本质上就是值。你需要学习 的新语法很少,还可以很清楚地看到这里发生了什么。 注解的内容并不是由库定义含义的咒语,而是实际的 C++ 值。如果你不知道 serde::skip_serializing_if 是什么意思,可以直接查看它的定义。
你可能会注意到,在讨论这些示例实现时,我没有 提到如何 从注解中解析出值——这 是因为实际上我不需要做任何解析, 编译器为我完成了这项工作! 我唯一需要做的, 就是从注解列表中提取我关心的注解,这 并不涉及实际的解析过程。 而 Rust 库则必须真正解析这些 Token 流,对于 serde 来说,这意味着接近 2000 行代码。
另一个有趣的事情是, 尽管 Rust 和 C++ 最终以不同的方式实现了相似的功能,但它们并不完全相同。在 Rust 中,#[derive(Debug)] 会为类型注入适当的 impl Debug。而在 C++ 的注解方法中,我们并没有注入适当的 formatter 特化,只是添加了一个全局约束的版本。
这意味着,如果不做进一步处理,仅仅做一个 小小的改动就可能导致歧义:
// let's just make this a range for seemingly no reasonauto begin-> int* ; auto end-> int* ; };
intmain{ auto p = Point{.x= 1, .y= 2}; std::println( "p={}", p); // error: ambiguous}
嗯,我需要做两个 小小的改动。我原本的特化定义如下:
但如果我将其改为:
那么它就可能与 C++23 新增的用于范围的 std::formatter 特化产生歧义。为了解决这个问题,可以禁用一个额外的变量模板 (在链接中会被预处理掉):
这似乎有点令人意外——因为从概念上讲,C++ 的方法与 Rust 的方法是相同的,添加注解会注入一个非常特定且明确的 特化,这不可能与其他内容产生歧义—— 但事实并非如此。因此,这种部分特化的歧义肯定会成为一个问题。或许在未来,我们可以想出一种方法,使诸如 [[=derive
库设计
在 Rust 的 serde 库中,序列化是一个两阶段的过程。首先,类型作者选择参与序列化,这会生成一个类似于该类型即时表示的实现。然后,不同协议的作者可以有效地实现不同的后端。
例如在 Person 类型的 serde 实现中,Rust 会生成一个 Serialize 实现,该实现接受满足 serde::Serialize r的 任意类型。 然后我们对这个 serializer 进行一系列的序列化调用,这些调用会根据协议需求(比如 JSON、CBOR、YAML、TOML 等)执行相应的操作。
如果我们将这种实现 方式转换为 C++,看起来可能会像这样(为避免陷入不相关的错误处理细节,这里假设这些函数在出现错误时抛出异常,而不是像 Rust 中那样返回 Result):
这种设计允许解耦,非常不错。然而你可能注意到了,我之前展示的 C++ 实现根本没有这样做。并不是因为我懒, 而是因为在有自省(introspection) 的情况下,这样的操作完全没有必要。在 C++ 中,我们不需要生成这种中间表示,Boost.JSON 实现可直接从数据成员 完成所有的序列化工作。
这不仅仅是代码量减少的问题, 更重要的是根本不需要处理额外的抽象层。这个抽象层虽然不会消耗太多计算资源,也很容易被编译优化掉,但它本身就是不必要的。
接下来再考虑 skip_field 调用。对于很多序列化目标(例如 JSON), 跳过某个字段的方法就是简单地不对其进行序列化。这也是为什么 skip_field 的默认实现什么都不做,serde_json 也没有覆盖这个函数。 同样,考虑上面提到的字段数量计算。JSON 序列化器也不需要这样的值,因此它会忽略这个字段类型名称的值。
但在创建中间表示时,你需要创建 一个足够丰富的表示 来处理所有可能的序列化/反序列化目标。某些序列化目标可能需要预先知道字段数量,或者需要为跳过的字段预留位置。因此,serde 必须为此提供支持。
而在 C++ 中,我们根本不需要这样做。对于任何给定的目标,序列化器可以直接执行它 所需的所有操作,因为它 可 以直接访问所有信息,不需要额外的抽象层。因此,C++ 版本的 serde 库可能只需要一系列可作为注解的类型、parse_attrs_from 函数,以及 几个小的辅助函数即可 。
这并不是终点
最后,我想指出几种不同语言中的一些相关特性来结束这篇文章:
它们都有一个共同点:编写代码,然后将代码传递给一个函数,以生成新的代码。元类和装饰器实际上会替换原始代码,而 derive 宏只会注入新代码(尽管其他过程宏也可以替换代码)。
注解提案在大体上看起来与这些特性类似,但它是一个完全不同的机制,不应与它们混淆:注解并不会注入代码,它只是增强了类型的自省能力。但这并不是说注解没用! 正如我所展示的那样,注解有望成为一个非常有用的工具,可以编写出以 前在 C++ 中无法想象的用户友好型库 API。
但这仅仅是一个开始。