划重点
01Rust和C++在处理代码生成和序列化方面存在差异,Rust通过 derive宏实现代码注入,而C++则使用注解和序列化库。
02Rust的derive宏接收被注解结构体的Token流作为输入,生成相应的Token流代码并注入到代码中。
03然而,C++中的注解语法显得更为复杂和冗长,而Rust的注解较为简洁,因为它们遵循不同于语言其余部分的语法规则。
04C++版本的serde库可能只需要一系列可作为注解的类型、parse_attrs_from()函数,以及几个小的辅助函数即可。
05尽管两者在实现方式上存在差异,但它们都旨在提高代码的自省能力,使得代码更加友好和可维护。
以上内容由腾讯混元大模型生成,仅供参考
【CSDN 编者按】随着编程语言的不断发展,Rust 和即将推出的 C++26 在代码生成领域的对比越来越受到开发者和研究者的关注。本文作者身为 C++ 标准委员会成员,将重点讨论 Rust 的过程宏并分析其工作原理,并基于此展示其是如何为 C++26 提出截然不同的解决方案的。
我很喜欢做的一件事,就是比较不同编程语言如何解决相同的问题,尤其是当这些语言采取了截然不同的方法时,我觉得这非常具有教育意义。在这篇文章中,我们将尝试把反射(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-printable)。不仅是因为调试打印在日常开发中非常有用,还因为在 Rust 中实现这一功能非常简单:
struct Point {
x: i32,
y: i32,
}
fn main() {
let p = 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 宏采用了非常不同的方式:它是一个函数,接收被注解结构体的 Token 流作为输入,生成相应的 Token 流代码并注入到代码中。实际上,这些注入的代码并不一定与输入直接相关。
在这种情况下,我们通过获取 Point 结构体的 Token 流输入,解析它,并使用解析结果生成我们需要的输出,从而绕过了缺乏自省的问题。我想这也算是一种“自省”——只不过它只能在特定情况下明确选择使用。
在上面的例子中,derive 宏生成了如下代码(我使用 cargo expand 得到的结果):
#[automatically_derived]
impl ::core::fmt::Debug for Point {
#[inline]
fn fmt(&self, f: &mut ::core::fmt::Formatter) -> ::core::fmt::Result {
::core::fmt::Formatter::debug_struct_field2_finish(
f,
"Point",
"x",
&self.x,
"y",
&&self.y,
)
}
}
这个代码看起来并不复杂,但关键在于 Rust 程序员无需手动编写这些模板代码。他们只需要学习如何写一行代码(实际上,连一整行都不需要):#[derive(Debug)]。这就是代码生成的强大之处。
即便如此,这个结果也很有趣。为什么对 self.x 使用 &self.x,而对 self.y 使用 &&self.y 呢?这与 Rust 无法进行自省功能有关。在 Rust 中,最后一个字段可以是不定长类型(unsized type)。不定长类型可以被打印,但是需要一个额外的间接层。derive 宏无法知道 y 是否是定长的(在这个例子中它是 i32,所以是定长的),所以为了支持两种情况,宏预先添加了这个额外的间接层。
在 C++ 中,如果想要提出的方案尽量贴近 Rust 的语法,可以这样实现:
struct [[=derive<Debug>]] Point {
int x;
int y;
};
int main() {
auto p = 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<Debug> 注解时启用,而这个注解本质上只是一个空值:
template <auto V> struct Derive { };
template <auto V> inline constexpr Derive<V> derive;
inline constexpr struct{} Debug;
template <class T> requires (has_annotation(^^T, derive<Debug>))
struct std::formatter<T> {
// ...
};
一旦我们有了这个基础,特化的实现就可以对类型 T 进行自省,获取我们需要的所有信息,以便展示:我们可以迭代所有非静态的数据成员,格式化它们的名称和值。一个简化的实现如下:
template <class T> requires (has_annotation(^^T, derive<Debug>))
struct std::formatter<T> {
constexpr auto parse(auto& ctx) { return ctx.begin(); }
auto format(T const& m, auto& ctx) const {
auto out = std::format_to(ctx.out(),
"{}", display_string_of(^^T));
*out++ = '{';
bool first = true;
[:expand(nonstatic_data_members_of(^^T)):] >> [&]<auto nsdm>{
if (not first) {
*out++ = ',';
*out++ = ' ';
}
first = false;
out = std::format_to(out,
".{}={}",
identifier_of(nsdm), m.[:nsdm:]);
};
*out++ = '}';
return out;
}
};
某种意义上来说,我们仍然是在生成代码——模板实际上就是 C++ 中的一种代码生成形式。但有趣的是,在这里我们通过非常不同的机制实现了相同的目标。
请注意,这就是完整的实现代码,可以看到代码量其实并不多。
JSON 序列化
在之前讨论的调试打印示例中,我们只是简单地按顺序打印所有成员。那么如果我们想做些更复杂的操作呢?在处理序列化时,有时字段的名称可能需要与原始的成员名不同。还有些情况,目标格式在编程语言中根本无法直接表达——比如字段名可能是语言中的关键字,或者字段名包含空格等等。
因此,Rust 的 serde 库提供了许多注解属性,可以添加到类型和成员上,以控制序列化逻辑。下面是一个简单的例子:
use serde::Serialize;
use serde_json;
#[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 的派生宏会为我们注入一个实现,其生成的代码如下:
#[doc(hidden)]
#[allow(non_upper_case_globals, unused_attributes, unused_qualifications)]
const _: () = {
#[allow(unused_extern_crates, clippy::useless_attribute)]
extern crate serde as _serde;
#[automatically_derived]
impl _serde::Serialize for Person {
fn serialize<__S>(
&self,
__serializer: __S,
) -> _serde::__private::Result<__S::Ok, __S::Error>
where
__S: _serde::Serializer,
{
let mut __serde_state = _serde::Serializer::serialize_struct(
__serializer,
"Person",
false as usize + 1 + 1,
)?;
_serde::ser::SerializeStruct::serialize_field(
&mut __serde_state,
"first name",
&self.first,
)?;
_serde::ser::SerializeStruct::serialize_field(
&mut __serde_state,
"last name",
&self.last,
)?;
_serde::ser::SerializeStruct::end(__serde_state)
}
}
};
在这里,你可以看到想要序列化的字段名(如 "first name" 和 "last name")与实际的成员绑定在一起。需要注意的是,false as usize + 1 + 1 是用来表示要序列化的字段数量的构造,这里的 2 显然是字段的数量。
如果我们要添加一个中间名,并且只有当它非空时才进行序列化,可以使用 skip_serializing_if 属性:
struct Person {
first: String,
middle: String,
last: String,
}
生成的代码如下,具体新增部分为第 18-19 行、第 26-37 行:
#[doc(hidden)]
#[allow(non_upper_case_globals, unused_attributes, unused_qualifications)]
const _: () = {
#[allow(unused_extern_crates, clippy::useless_attribute)]
extern crate serde as _serde;
#[automatically_derived]
impl _serde::Serialize for Person {
fn serialize<__S>(
&self,
__serializer: __S,
) -> _serde::__private::Result<__S::Ok, __S::Error>
where
__S: _serde::Serializer,
{
let mut __serde_state = _serde::Serializer::serialize_struct(
__serializer,
"Person",
false as usize + 1 + if String::is_empty(&self.middle) { 0 } else { 1 }
+ 1,
)?;
_serde::ser::SerializeStruct::serialize_field(
&mut __serde_state,
"first name",
&self.first,
)?;
if !String::is_empty(&self.middle) {
_serde::ser::SerializeStruct::serialize_field(
&mut __serde_state,
"middle name",
&self.middle,
)?;
} else {
_serde::ser::SerializeStruct::skip_field(
&mut __serde_state,
"middle name",
)?;
}
_serde::ser::SerializeStruct::serialize_field(
&mut __serde_state,
"last name",
&self.last,
)?;
_serde::ser::SerializeStruct::end(__serde_state)
}
}
};
但在 C++ 中,我们并没有像 serde 这样的库,它可以分离序列化的字段名和成员变量的名称,至少我目前不知道有这样的库。C++ 中,通常是 JSON 库处理 JSON 序列化,TOML 库处理 TOML 序列化等。也许这是因为 C++ 缺乏像 Rust 那样的语言支持,所以无法轻松实现这种序列化机制?
老实说,虽然在格式化方面 Rust 和 C++ 的实现有些相似,但在序列化的灵活性上 Rust 确实更具优势。尽管 C++ 中没有完全类似 serde 的库,但我们也可以使用类似 Boost.JSON 这样的库来实现序列化。
我们从支持 derive<Serialize> 和 rename 开始,这是为了让代码能够正常工作的全部需求:
struct [[=derive<serde::Serialize>]] Point {
int x, y;
};
struct [[=derive<serde::Serialize>]] Person {
[[=serde::rename("first name")]] std::string first;
[[=serde::rename("last name")]] std::string last;
};
int main() {
// 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 可以实现:
namespace serde {
inline constexpr struct{} Serialize{};
struct rename { char const* field; };
}
namespace boost::json {
template <class T>
requires (has_annotation(^^T, derive<serde::Serialize>))
void tag_invoke(value_from_tag const&, value& v, T const& t) {
auto& obj = v.emplace_object();
[:expand(nonstatic_data_members_of(^^T)):] >> [&]<auto M>{
constexpr auto field = annotation_of<serde::rename>(M)
.transform([](serde::rename r){
return std::string_view(r.field);
})
.value_or(identifier_of(M));
obj[field] = boost::json::value_from(t.[:M:]);
};
}
}
这段代码应该看起来很熟悉,因为它基本上也是在做格式化工作,只不过这里我们是将成员添加到一个 JSON 对象中,而不是打印一堆键值对。然后,我们没有自动使用非静态数据成员的标识符,而是先尝试检查是否有 rename 注解。annotation_of<T>() 为我们提供了一个 optional<T>,因此我们要么获取 rename 注解的字段名(及其底层字符串),要么回退到 identifier_of(M)。
在这里添加 skip_serializing_if 的支持并不需要太多额外的工作,这也很好地展示了 C++ 和 Rust 处理方式之间的区别。在 Rust 中,你提供一个字符串,它会被注入并在内部调用;而在 C++ 中,我们通常会直接提供一个可调用对象。
起初我以为这是因为 Rust 的属性语法不支持在这里使用可调用对象,但实际上似乎是因为 serde 在支持这一点之前就已经存在了。
我们需要为此添加一个新的注解类型:
namespace serde {
inline constexpr struct{} Serialize{};
struct rename { char const* field; };
template <class F> struct skip_serializing_if { F pred; };
}
然后稍微麻烦一点的部分是对它的解析,我们需要提取出某个 serde::skip_serializing_if 的特化类型注解。如果找到了,就尝试调用其 pred 成员函数,若该函数返回 true,就跳过该字段的序列化。
搜索过程如下所示(注意,我们需要使用 constexpr,因为需要拼接它以进行调用)。我确信这个部分可以通过更好的库 API 稍作清理(至少可以用一个 std::optional 来改进):
constexpr auto skip_if = []() -> std::meta::info {
auto res = std::meta::info();
for (auto A : annotations_of(M)) {
auto type = type_of(A);
if (has_template_arguments(type)
and template_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() and res != value_of(A)) {
throw "unexpected duplicate";
}
res = value_of(A);
}
}
return res;
}();
然后,如果我们有这样的注解,就调用它来确定是否需要跳过这个成员。这里需要用 if constexpr 语句,因为如果 skip_if 是空反射,我们无法对其进行拼接。除此之外,整体逻辑非常简单:如果有这样的注解,就调用它,如果返回 false,则跳过这个成员:
if constexpr (skip_if != std::meta::info()) {
if (std::invoke([:skip_if:].pred, t.[:M:])) {
return;
}
}
现在这段代码已经膨胀到了 51 行(新增部分为第 7 行、第 22-46 行):
template <auto V> struct Derive { };
template <auto V> inline constexpr Derive<V> derive;
namespace serde {
inline constexpr struct{} Serialize{};
struct rename { char const* field; };
template <class F> struct skip_serializing_if { F pred; };
}
namespace boost::json {
template <class T>
requires (has_annotation(^^T, derive<serde::Serialize>))
void tag_invoke(value_from_tag const&, value& v, T const& t) {
auto& obj = v.emplace_object();
[:expand(nonstatic_data_members_of(^^T)):] >> [&]<auto M>{
constexpr auto field = annotation_of<serde::rename>(M)
.transform([](serde::rename r){
return std::string_view(r.field);
})
.value_or(identifier_of(M));
constexpr auto skip_if = []() -> std::meta::info {
auto res = std::meta::info();
for (auto A : annotations_of(M)) {
auto type = type_of(A);
if (has_template_arguments(type)
and template_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() and res != value_of(A)) {
throw "unexpected duplicate";
}
res = value_of(A);
}
}
return res;
}();
if constexpr (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<rename>。但对于 skip_serializing_if 呢?我们还不知道该使用什么类型,所以这里先用 optional<info> 来进行类型擦除。也就是说,我们希望生成这样的类型:
struct attributes {
optional<rename> rename;
optional<info> skip_serializing_if;
};
这段代码使用了 std::meta::define_class(),这是 P2996 中唯一一个用于代码生成的 API。它功能不多,但对当前需求来说足够用了。注意,由于我们遍历了命名空间 serde 中的所有成员,需要确保排除 attributes——它当然也在这个命名空间中:
struct attributes;
consteval {
std::vector<std::meta::info> specs;
for (auto m : members_of(^^serde)) {
if (m == ^^attributes or not has_identifier(m)) {
continue;
}
auto underlying = 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 哪个非静态数据成员。我们暂时跳过这部分逻辑,直接进入如何利用这些工作成果:
namespace boost::json {
template <class T>
requires (has_annotation(^^T, derive<serde::Serialize>))
void tag_invoke(value_from_tag const&, value& v, T const& t) {
auto& obj = v.emplace_object();
[:expand(nonstatic_data_members_of(^^T)):] [&]<auto M>{
constexpr auto attrs = serde::parse_attrs_from<M>();
constexpr auto field = attrs.rename
.transform([](serde::rename r){
return std::string_view(r.field);
})
.value_or(identifier_of(M));
if constexpr (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 中的声明:
struct Person {
first: String,
middle: String,
last: String,
}
而这是我在 C++ 中的声明:
struct [[=derive<serde::Serialize>]] Person {
[[=serde::rename("first name")]]
std::string first;
[[=serde::rename("middle name")]]
[[=serde::skip_serializing_if(&std::string::empty)]]
std::string middle = "";
[[=serde::rename("last name")]]
std::string last;
};
可以看到,C++ 的注解语法显得更为复杂和冗长,而这大多是由于语法本身的问题。相较之下 Rust 的注解较为简洁,因为它们遵循不同于语言其余部分的语法规则 —— 比如 serde(rename = "first name") 在 Rust 中是无效的,这里也没有调用名为 serde 的函数。
这种差异带来的好处是,Rust 中的注解使用起来更加清晰自然,因为它真的就像是给选项赋值一样。例如,类似于 serde(rename = "first name") 这样的用法更像是传递配置参数,而不是在调用函数。这为使用者提供了灵活性,比如可以像这样使用属性:#[arg(short)] 或 #[arg(short = 'k')],前者使用了默认值,而后者显式指定了 'k'。
看到这里,你可能会有一种冲动,想要重用(非常特殊且具体的)属性语法,并允许在 C++ 中使用 using 关键字。但实际上,这样做并不会节省太多的输入:
struct [[=derive<serde::Serialize>]] Person {
// old version: 83 chars
[[=serde::rename("middle name"), =serde::skip_serializing_if(&std::string::empty)]]
std::string middle = "";
// new version: 82 chars
[[using serde: =rename("middle name"), =skip_serializing_if(&std::string::empty)]]
std::string middle = "";
};
相比之下,Rust 版本只有 74 个字符。虽然长度上的差异并不大,但至少它少于 80 个。
另一方面,要关注 Rust 为实现这一点付出了什么代价。在 C++ 注解设计中,注解本质上就是值。你需要学习的新语法很少,还可以很清楚地看到这里发生了什么。注解的内容并不是由库定义含义的咒语,而是实际的 C++ 值。如果你不知道 serde::skip_serializing_if 是什么意思,可以直接查看它的定义。
你可能会注意到,在讨论这些示例实现时,我没有提到如何从注解中解析出值——这是因为实际上我不需要做任何解析,编译器为我完成了这项工作!我唯一需要做的,就是从注解列表中提取我关心的注解,这并不涉及实际的解析过程。而 Rust 库则必须真正解析这些 Token 流,对于 serde 来说,这意味着接近 2000 行代码。
另一个有趣的事情是,尽管 Rust 和 C++ 最终以不同的方式实现了相似的功能,但它们并不完全相同。在 Rust 中,#[derive(Debug)] 会为类型注入适当的 impl Debug。而在 C++ 的注解方法中,我们并没有注入适当的 formatter 特化,只是添加了一个全局约束的版本。
这意味着,如果不做进一步处理,仅仅做一个小小的改动就可能导致歧义:
struct [[=derive<Debug>]] Point {
int x;
int y;
// let's just make this a range for seemingly no reason
auto begin() -> int*;
auto end() -> int*;
};
int main() {
auto p = Point{.x=1, .y=2};
std::println("p={}", p); // error: ambiguous
}
嗯,我需要做两个小小的改动。我原本的特化定义如下:
template <class T> requires (has_annotation(^^T, derive<Debug>))
struct std::formatter<T> { /* ... */ };
但如果我将其改为:
template <class T, class Char> requires (has_annotation(^^T, derive<Debug>))
struct std::formatter<T, Char> { /* ... */ };
那么它就可能与 C++23 新增的用于范围的 std::formatter 特化产生歧义。为了解决这个问题,可以禁用一个额外的变量模板(在链接中会被预处理掉):
template <class T> requires (has_annotation(^^T, derive<Debug>))
inline constexpr auto std::format_kind<T> = std::range_format::disabled;
这似乎有点令人意外——因为从概念上讲,C++ 的方法与 Rust 的方法是相同的,添加注解会注入一个非常特定且明确的特化,这不可能与其他内容产生歧义——但事实并非如此。因此,这种部分特化的歧义肯定会成为一个问题。或许在未来,我们可以想出一种方法,使诸如 [[=derive<Debug>]] 这样的注解能够真正注入一个特化来避免这个问题。
库设计
在 Rust 的 serde 库中,序列化是一个两阶段的过程。首先,类型作者选择参与序列化,这会生成一个类似于该类型即时表示的实现。然后,不同协议的作者可以有效地实现不同的后端。
例如在 Person 类型的 serde 实现中,Rust 会生成一个 Serialize 实现,该实现接受满足 serde::Serializer 的任意类型。然后我们对这个 serializer 进行一系列的序列化调用,这些调用会根据协议需求(比如 JSON、CBOR、YAML、TOML 等)执行相应的操作。
如果我们将这种实现方式转换为 C++,看起来可能会像这样(为避免陷入不相关的错误处理细节,这里假设这些函数在出现错误时抛出异常,而不是像 Rust 中那样返回 Result):
template <Serializer S>
auto serialize(Person const& p, S& serializer) -> void {
auto state = serializer.serialize_struct(
"Person",
2 + (p.middle.empty() ? 0 : 1));
state.serialize_field("first name", p.first);
if (not p.middle.empty()) {
state.serialize_field("middle name", p.middle);
} else {
state.skip_field("middle name", p.middle);
}
state.serialize_field("last name", p.last);
state.end();
}
这种设计允许解耦,非常不错。然而你可能注意到了,我之前展示的 C++ 实现根本没有这样做。并不是因为我懒,而是因为在有自省(introspection)的情况下,这样的操作完全没有必要。在 C++ 中,我们不需要生成这种中间表示,Boost.JSON 实现可直接从数据成员完成所有的序列化工作。
这不仅仅是代码量减少的问题,更重要的是根本不需要处理额外的抽象层。这个抽象层虽然不会消耗太多计算资源,也很容易被编译优化掉,但它本身就是不必要的。
接下来再考虑 skip_field 调用。对于很多序列化目标(例如 JSON),跳过某个字段的方法就是简单地不对其进行序列化。这也是为什么 skip_field 的默认实现什么都不做,serde_json 也没有覆盖这个函数。同样,考虑上面提到的字段数量计算。JSON 序列化器也不需要这样的值,因此它会忽略这个字段类型名称的值。
但在创建中间表示时,你需要创建一个足够丰富的表示来处理所有可能的序列化/反序列化目标。某些序列化目标可能需要预先知道字段数量,或者需要为跳过的字段预留位置。因此,serde 必须为此提供支持。
而在 C++ 中,我们根本不需要这样做。对于任何给定的目标,序列化器可以直接执行它所需的所有操作,因为它可以直接访问所有信息,不需要额外的抽象层。因此,C++ 版本的 serde 库可能只需要一系列可作为注解的类型、parse_attrs_from() 函数,以及几个小的辅助函数即可。
这并不是终点
最后,我想指出几种不同语言中的一些相关特性来结束这篇文章:
Rust 的过程宏(procedural macros)
Python 的装饰器(decorators)
Herb Sutter 的元类(metaclasses)提案
它们都有一个共同点:编写代码,然后将代码传递给一个函数,以生成新的代码。元类和装饰器实际上会替换原始代码,而 derive 宏只会注入新代码(尽管其他过程宏也可以替换代码)。
注解提案在大体上看起来与这些特性类似,但它是一个完全不同的机制,不应与它们混淆:注解并不会注入代码,它只是增强了类型的自省能力。但这并不是说注解没用!正如我所展示的那样,注解有望成为一个非常有用的工具,可以编写出以前在 C++ 中无法想象的用户友好型库 API。
但这仅仅是一个开始。