For the Love of Macros
— 焉知非鱼For the Love of Macros
我一直在重读 Ted Kaminski 关于软件设计的博客。我强烈推荐所有的文章,尤其是早期的文章(这是第一篇)。他设法提供了既不平凡又合理的设计建议(当然是主观判断),这是一个难得的标本!
无论如何,这一系列的见解之一是,当设计一个抽象的概念时,我们总是要面对权力和属性之间的内在权衡。我们使用一个特定的抽象能表达的越多,我们对使用它的代码能说的就越少。然而,我们人类对更多表达能力的偏爱并非与生俱来。这一点在编程语言社区中很明显,用户不停地要求提供新功能,而语言设计者却说不。
宏是一个在 “更强大"方面走得很远的语言功能。宏给了你一种在源代码上抽象的能力。作为交换,你放弃了(自动)推理表面语法的能力。作为一个具体的例子,重命名重构在具有强大宏系统的语言中并不能 100% 可靠地工作。
我确实认为,在理想的世界里,对于一个想要扩展到巨大项目的语言来说,这是一个错误的交易。当你增加了更多的程序员、更多的年限和更多的数百万行代码时,自动推理和转换源代码的能力就会变得越来越重要。但是,请谨慎对待这一点 - 我显然是有偏见的,因为我花了几年时间开发 Rust IDE。
也就是说,宏有巨大的吸引力 - 它们是语言设计师的胶带。宏很少是最好的工具,但它们几乎可以完成任何工作。语言设计是渐进式的。宏系统通过为许多功能提供一个现成的穷人的替代品来缓解设计压力。
在这篇文章中,我想探讨一下 Rust 中宏的用途。目的是为了找到不放弃"推理源代码"属性的解决方案。
字符串插值 #
到目前为止,最常见的使用情况是 format!
系列的宏。这里的无宏解决方案很直接 - 字符串插值语法。
let key = "number";
let value = || 92;
let t = f"$key: ${values()}";
assert_eq!(t.to_string(), "number: 92");
在 Rust 中,插值可能不应该直接构造一个字符串,而是可以产生一个实现 Display 的值(就像 format_args!
一样),这样可以避免分配。一个有趣的扩展是允许在格式字符串片段上迭代。这样一来,插值语法就可以用于 SQL 语句或命令行参数之类的东西,而不用担心引入注入漏洞。
let arg = "my dir";
let cmd = f"ls $arg".to_cmd();
assert_eq!(cmd.to_string(), "ls 'my dir'");
这篇关于 Julia 编程语言的文章解释了这个问题。 xshell crate 为 Rust 实现了这个想法。
Derives #
我认为在 Rust 中,宏的第二个最常见,也可能是最重要的用法是派生。Rust 是为数不多的能正确实现平等的语言之一(禁止比较苹果和橘子),但这关键取决于 derive(Eq)
的能力。这个领域常见的解决方案是编译器中的特殊 casing(Haskell 的派生)或运行时反射。
但我最感兴趣的解决方案是 C#
源码生成器。这并不是什么新鲜事 - 这只是老式的(源码)代码生成器,只是具有很好的实现质量。你可以提供自定义的代码,这些代码在构建过程中被运行,它可以读取现有的源码并生成额外的文件,然后再添加到编译中。
这个解决方案的优点在于它将所有的复杂性从语言中移出,移到了编译系统中。这意味着你可以免费获得基线工具支持。生成代码的 Goto 定义?就能用了。调试时想介入一些序列化代码?磁盘上有实际的源码,所以可以放心的去做! 你是比较喜欢用 printf
的人?好吧,你需要说服构建系统不要踩过你的改动,但是,否则,为什么不呢?
此外,源码生成器的表现力明显更强。它们可以调用到 Roslyn 编译器来分析源代码,所以它们能够生成类型导向的代码。
为了有用,源码生成器需要一些语言级别的支持,以便将一个实体分割到多个文件中。在 C#
中,部分类就扮演了这个角色。
特定领域语言 #
宏的存在理由是嵌入式 DSL 的实现。我们希望在语言中引入自定义语法,以简洁地对程序的领域进行建模。例如,可以用宏来嵌入 Rust 代码中的 HTML 片段。
对我个人来说,eDSL 不是要解决的问题,只是一个问题。引入一个新的子语言(即使是小的)会花费大量的认知复杂性预算。如果你偶尔需要它,最好坚持只把有点啰嗦的函数调用链在一起。如果你经常需要它,引入外部的 DSL 是有意义的,它有一个编译器,一个语言服务器,以及所有使编程富有成效的工具。对我来说,基于宏的 DSL 只是在成本效益曲线上不落像一个有趣的点。
也就是说,Kotlin 编程语言很好地解决了强类型化、工具友好型 DSL 的问题(例子)。令人气愤的是,很难指出具体的解决方案是什么。就是……主要是具体的语法。下面是一些成分。
- 闭包的语法是
{ arg -> body }
,或者直接是{ body }
,所以闭包在语法上类似于块。 - 扩展方法(这只是静态方法的语法糖)。
- Java 风格的隐式 this,它将名称引入到作用域中,而不需要显式声明。
- TCP-preserving inline closures (这是唯一一个非语法特征)
尽管如此,这还不足以实现 Jetpack Compose UI DSL,它还需要一个编译器插件。
sqlx #
我想调用的一个有趣的 DSL 案例是 sqlx::query。它允许我们写这样的代码。
let account =
sqlx::query!("select (1) as id, 'Herp Derpinson' as name")
.fetch_one(&mut conn)
.await?;
// anonymous struct has `#[derive(Debug)]` for convenience
println!("{:?}", account);
println!("{}: {}", account.id, account.name);
这一点我想是eDSL确实很拉风的几个案例之一。没有宏的情况下,我不知道该怎么做。使用字符串插值(高级版本,以保护不被注入),可以指定查询。使用源码生成器,可以检查查询的语法和类型,例如,在这种情况下,会出现类型错误。
let (id, name): (i32, f32) =
query("select (1) as id, 'Herp Derpinson' as name")
.fetch_one(&mut conn)
.await?;
但这还不足以生成一个匿名结构体,也不足以摆脱动态 casts。
有条件编译 #
Rust 还使用宏进行条件编译。这个用例令人信服地展示了"缺乏属性"方面的能力。处理特征组合是 Cargo 永远头痛的问题。当特征标志改变时,用户不得不反复重新编译大块的装箱图。在 CI 上用 Cargo test --no-default-features
捕捉类型错误是非常恼人的,尤其是当你在提交 PR 之前确实运行了 Cargo test
。“添加特性"是一个无法选中的一厢情愿。
在这种情况下,我不知道有什么好的无宏选择。但是,原则上,这似乎是可行的,如果将条件编译进一步推到编译器流水线的下游,推到代码生成和链接阶段。编译器可以在为一个函数生成机器代码之前,选择特定平台的版本,而不是在解析过程中提前丢弃一些代码。在此之前,它会检查该函数的所有条件编译版本是否具有相同的接口。这样一来,平台特定的类型错误就不可能出现了。
占位符语法 #
最后一个我想介绍的用例是占位符语法。Rust 的 macro_call!(...)
语法开辟了一个很好的隔离区域,只要小括号是平衡的,任何东西都可以用。理论上,这允许语言设计者在确定某些东西之前先试验临时语法。在实践中,这看起来好像并没有什么好处?有人反对稳定 postfix .await
,而不通过中间期与 await!
宏来稳定。而且,稳定之后,所有的语法讨论都立即被遗忘了?另一方面,我们确实有 try! -> ?
转变,但我不认为它有助于发现任何设计上的缺陷?至少,我们成功地稳定了那个不必要的限制性去语法糖。
对于结论,我想绕回源码生成器。究竟是什么让它们比宏更容易被工具化?我认为有以下三个特性。第一,无论是输入还是输出,从根本上说,都是文本。没有中间的表示方式(比如 token 树),而这个元程序设施使用的是中间的表示方式。这意味着,它不需要与编译器深度集成。当然,在内部,该工具可以自由地对代码进行任意解析、类型检查和转换。其次,有一个阶段性的区分。源码生成器是一次执行,无序的。在元编程和名称解析之间没有来回,这又可以将"元"的部分保留在外面。第三,源码生成器只能添加代码,不能改变现有代码的含义。这意味着,在代码生成器的存在下,语义上合理的源码转换依然如此。
就这样吧! 在 /r/rust 上讨论。
原文链接: https://matklad.github.io/2021/02/14/for-the-love-of-macros.html