大部分的语言或多或少提供了一些元编程机制,使得开发者能够对现有的语法语义进行拓展,能够极大的提高编程体验和开发效率。可能一提到元编程,大家很容易想到大名鼎鼎的 Lisp
语言(你可能会说:“难道不是 Cpp?”,我又不会模板元编程,你让我讲啥 x)。得益于其本身接近语法树的语法,使得 Lisp 开发者可以很容易操作语法节点,甚至实现 “自己的 Lisp”,于是就才涌现了很多的 Lisp 方言。当然,本篇文章并不是为了讨论 Lisp,只是想要就 “元编程” 这一个角度,来谈谈 C# 和 Rust 语言各自的机制。
关于元编程 是什么,简单来说是用程序编写程序
更详细的描述建议参考 wiki : https://zh.wikipedia.org/wiki/%E5%85%83%E7%BC%96%E7%A8%8B
元编程概述 元编程一般有两种实现方式:一是通过使用内部暴露的 API 来直接处理语法节点,二是能够动态执行字符串表达式。
对于后者来说,典型的是 Python 的 eval
和 exec
函数,JS 中也有类似的功能。对于这类实现来说,固然十分方便,但是也会带来很严重的安全问题。如果对于外部用户来说,eval
这类的危险函数的输入是可控的,那么用户就可以传入一段精心构造的字符串来执行恶意命令导致可能的信息泄露、提权等安全问题。
所以我个人会更倾向于前者(虽然这往往意味着更加抽象的语法和更高的编写难度),比如 C# 和 Rust 便是如此。
在 C# 中,Roslyn 编译器提供了一系列 API (Microsoft.CodeAnalysis.CSharp
)用于编写源生成器,源生成器会在编译时被调用,访问每个语法节点,然后根据你的实现去动态的生成代码给编译器(如果不开 emit,那么这些代码不会以实际的源文件形式出现在你的文件夹中);而 Rust 则是提供了极其强大,堪比 Lisp 的宏系统。普通的函数宏会依据用户编写的规则去在编译期展开,过程宏则有点接近 C#,不过 Rust 并不提供直接遍历全局的语法节点,而只是限制在该宏的使用者本身。
一些简单实践 以下是关于 C# 和 Rust 元编程的简单实践,可以观察一下他们之间的差异。
以下代码并非是为了教学,只是从一些简单的示例来比较他们之间的差异性,以及讨论各自的优劣。
关于代码的解释,会在后面一起分析
C# 增量生成器(IncrementalGenerator)实践
由于 C# 的源生成器 每次都要遍历所有语法节点,十分影响性能,微软已经将其标记为弃用 ,取而代之的是新的增量生成器 API
增量生成器 中提供了多种管道进行分析,比如 CompilationProvider
, SyntaxProvider
, AdditionalTextsProvider
等等。相比于旧的 ISourceGenerator
,可以提供更细粒度的分析,接受的分析对象也不局限于源代码。
首先需要新建一个 C# 库项目,我推荐目标框架是 netstandard2.0,使用的 Microsoft.CodeAnalysis.CSharp
版本为 4.3.0
(这可以保证该源生成器能在大部分的项目中使用,甚至 Unity)
简单写一个小 demo:
对于该生成器,需要实现 IIncrementalGenerator
接口的 Initialize
方法。然后我们从语法节点的角度切入,比如我们需要给具有 [Test]
Attribute 的 class 分部实现一个方法,那么就可以像下面这样。
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 43 44 45 using System.Linq;using System.Text;using Microsoft.CodeAnalysis;using Microsoft.CodeAnalysis.CSharp.Syntax;using Microsoft.CodeAnalysis.Text;namespace Test.Analysis { [Generator(LanguageNames.CSharp) ] public sealed class TestGenerator : IIncrementalGenerator { public void Initialize (IncrementalGeneratorInitializationContext context ) { var provider = context.SyntaxProvider .CreateSyntaxProvider( (s, _) => s is ClassDeclarationSyntax classDecl && classDecl.AttributeLists .SelectMany(al => al.Attributes) .Any(a => a.Name.ToString() == "Test" ), (ctx, _) => ctx.Node as ClassDeclarationSyntax ).Collect(); context.RegisterSourceOutput(provider, (spc, source) => { foreach (var @class in source) { var namespaceName = @class.FirstAncestorOrSelf<NamespaceDeclarationSyntax>().Name.ToString(); var code = $@"// <auto-generated/> using System; namespace {namespaceName} {{ public partial class {@class .Identifier} {{ public void Test() => Console.WriteLine(""Test""); }} }}" ; spc.AddSource($"{@class .Identifier} _Test.g.cs" , SourceText.From(code, Encoding.UTF8)); } }); } } }
另外在一个终端项目中引用该项目,我们需要定义一个 TestAttribute,然后在一个分部类上使用它,然后这个类就会自动实现 Test() 方法了。
说明:information_source: : 由于测试项目使用的是 net9.0, 支持顶级表达式,所以可以不需要写静态 Main 函数。
1 2 3 4 5 6 7 8 9 10 11 var test = new Test.TestClass();test.Test(); namespace Test { [Test ] public partial class TestClass { } [AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true) ] public class TestAttribute : Attribute { } }
运行一下就会发现正确输出了 "Test"
C# 诊断分析器(DiagnosticAnalyzer)实践 比如我希望 [Test]
所在的 class 是密封的,那么我可以创建一个诊断器来分析,当不是 sealed 时抛出一个错误。
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 43 44 45 46 47 48 49 50 51 #pragma warning disable RS2008 using System.Collections.Immutable;using System.Linq;using Microsoft.CodeAnalysis;using Microsoft.CodeAnalysis.CSharp;using Microsoft.CodeAnalysis.CSharp.Syntax;using Microsoft.CodeAnalysis.Diagnostics;namespace Test.Analysis { [DiagnosticAnalyzer(LanguageNames.CSharp) ] public sealed class TestAnalyzer : DiagnosticAnalyzer { public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => ImmutableArray.Create(Rule); private static readonly DiagnosticDescriptor Rule = new DiagnosticDescriptor( id: "TEST001" , title: "Test class must be sealed" , messageFormat: "class '{0}' must be sealed" , category: "Test" , defaultSeverity: DiagnosticSeverity.Error, isEnabledByDefault: true , description: "Test class must be sealed." ); public override void Initialize (AnalysisContext context ) { context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.Analyze | GeneratedCodeAnalysisFlags.ReportDiagnostics); context.EnableConcurrentExecution(); context.RegisterSyntaxNodeAction(AnalyzeSyntax, SyntaxKind.ClassDeclaration); } public void AnalyzeSyntax (SyntaxNodeAnalysisContext context ) { if (context.Node is not ClassDeclarationSyntax classDecl) return ; var hasTestAttribute = classDecl.AttributeLists .SelectMany(al => al.Attributes) .Any(a => a.Name.ToString() == "Test" ); if (hasTestAttribute == false ) return ; var isSealed = classDecl.Modifiers.Any(m => m.IsKind(SyntaxKind.SealedKeyword)); if (isSealed == false ) { context.ReportDiagnostic(Diagnostic.Create(SupportedDiagnostics[0 ], classDecl.GetLocation(), classDecl.Identifier.Text)); } } } }
然后回到测试项目尝试 build,就可以看到有条报错:error TEST001: class 'TestClass' must be sealed
Rust 函数宏(声明宏)实践
我使用的是新的宏声明方式,旧的方式是使用 macro_rules!
进行声明
#![feature(decl_macro)]
需要使用 Rust 的 nightly 或 beta 版本 :warning:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 #![feature(decl_macro)] fn main () { macros::hello!(world); macros::muti_hello!(world, rust, macro ); } pub mod macros { pub macro hello ($n:ident) { println! ("Hello, {}!" , stringify! ($n)); } pub macro muti_hello ($( $n:ident ),*$(,)?) { $( println! ("Hello, {}!" , stringify! ($n)); )* } }
运行一下观察输出:
1 2 3 4 Hello, world!Hello, world!Hello, rust!Hello, macro!
Rust 过程宏(proc-macro)实践 Rust 写过程宏类似于 C# 的增量生成器或诊断器,也是需要新建一个 Rust 项目,在新的过程宏项目中添加 quote, syn 依赖,然后简单写一个 Hello 特征的 derive
Rust trait (特征) 可以类比于其他语言的 interface(接口)
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 43 44 45 46 47 48 49 50 51 52 53 54 55 extern crate proc_macro;use proc_macro::TokenStream;use quote::quote;use syn::{parse::Parse, parse_macro_input, DeriveInput, Token};#[proc_macro_derive(Hello)] pub fn hello_derive (input: TokenStream) -> TokenStream { let input = parse_macro_input!(input as DeriveInput); let name = &input.ident; let expanded = quote! { impl Hello for #name { fn hello () { println! ("Hello, {}!" , stringify! (#name)); } } }; expanded.into () } struct SingleHelloInput { from: syn::Ident, to: syn::Ident, } struct HelloInput { some_hellos: Vec <SingleHelloInput>, } impl Parse for HelloInput { fn parse (input: syn::parse::ParseStream) -> syn::Result <Self > { let mut some_hellos = Vec ::new (); while !input.is_empty () { let from = input.parse ()?; input.parse::<Token![=>]>() .map_err (|e| syn::Error::new (e.span (), "Expected `=>`" ))?; let to = input.parse ()?; some_hellos.push (SingleHelloInput { from, to }); } Ok (HelloInput { some_hellos }) } } #[proc_macro] pub fn say_hello (input: TokenStream) -> TokenStream { let input = parse_macro_input!(input as HelloInput); let hellos = input.some_hellos.iter ().map (|SingleHelloInput { from, to }| { quote! { println! ("say hello from {} to {}!" , stringify! (#from), stringify! (#to)); } }); let expanded = quote! { #(#hellos)* }; expanded.into () }
把该项目添加到另一个项目的依赖,并测试宏的功能:
hello_derive
宏的功能是给目标实现 Hello
特征(当然,这得你自己定义),于是可以调用 World::hello()
方法
注意:warning: :此处不是静态方法,Rust 并不能直接在 struct 中定义静态方法(实际上也没有这个概念x),此处是由于 World 结构体没有任何成员,所以创建 World 实例可以直接简化成 World
,而不需要 World { }
say_hello
宏则是可以接受任意多组输入,每一组的格式必须是 标识符 => 标识符
,该宏的解析是自定义的。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 use test_macro::{Hello, say_hello};fn main () { World::hello (); say_hello!(World => Rust); say_hello! { Hacbit => Rust Rust => Hacbit My => ABAB My => Anii } } #[derive(Hello)] pub struct World ;pub trait Hello { fn hello (); }
输出:
1 2 3 4 5 6 Hello, World! say hello from World to Rust!say hello from Hacbit to Rust!say hello from Rust to Hacbit!say hello from My to ABAB!say hello from My to Anii!
C#和Rust在元编程实现上的异同 看了上面的几个示例,应该对两者的元编程机制有了一定的印象。
对于 C# 和 Rust 的相同的地方(虽然上面的例子可能没有很好的体现):
都是把源代码解析成 AST (抽象语法树),然后分析节点对象
需要额外引入库项目作为类似编译器插件的东西,以此实现在编译期对原有代码的功能进行拓展
不过差异也十分显著:
C# 提供多个管道(比如 context.SyntaxProvider
)来访问各种节点(比如 ClassDeclarationSyntax
)对象,生成器可以自定义筛选条件,并分析筛选出来的节点来生成相关拓展; Rust 则需要用户显示的去 “使用” 宏,比如 #[derive(Hello)]
就是直接指定了宏的使用目标,因而无需筛选。所以实际上,C# 在获取元信息的范围更广,Rust 只能获取目标的元信息,而无法得知目标以外的元信息。
但是另一方面,受限于 C# 本身的语法,C# 无法提供更细粒度的指令流分析 。而 Rust 在这方面就十分灵活,可以自定义 Token 流解析器,你可以把各种符号(允许的 Token)塞进的宏规则中,而无需在意语法(就像上面的 say_hello!
那样)。这意味着,在 Rust 中你可以定义自己语法。
无论是 C# 的生成器/分析器,还是 Rust 的宏,其实核心区别无非就是作用范围的不同,一个是全局,一个是局部。而由这两点分歧,又很容易想到,C# 的元编程机制只能进行增量 ,是不能够修改原有代码的(因为生成器/分析器是并行的,而元信息作为共享资源,自然不能随意修改);Rust 的宏作用范围只有局部,每个宏之间是独立(或者包含),而不会产生交叉,因而可以随意修改原有逻辑 。
不管具体实现如何,毫无疑问他们都是具有相当程度可定制化的,并且也能保证运行时性能和安全性。不同于反射、或者运行时动态执行字符串等运行时特性,他们本质上是将一部分运行时逻辑提前到编译期 ,因而减少运行时的性能损耗(当然,这也意味着你需要付出更长编译时间的代价 :laughing: ),也就是 Rust 中特别提倡的零成本抽象 。同时,由于这些编译期特性只会在编译期产生作用,就不会有运行时特性那样潜在的注入漏洞等安全风险。(除非黑客能够控制你的编译管线 x :laughing: )
适用场景 有什么比较合适的使用场景呢?
按照我的经验可能往往是用来动态实现一些接口,函数之类的。不过这个应该更容易在 Rust 中实现(也十分自然),因为 Rust 的 struct
可以在任何地方去实现新功能,只需要定义一个新的 trait
(如果是在该 struct
定义所在的模块之外)。而放在 C# 就有点麻烦,通常我们不得不需要强制用户使用 partial
关键字,并且也无法对一些内置的(或第三方库中的)类型进行更大限度的拓展,比如我们不能给 int
添加新的接口实现。
生成一些桥接代码可能是不错的选择,比如我们想要在 c 端和 s 端交换数据,那么肯定需要对两端的数据结构进行一个适配,这时候就可以根据配置动态生成;或者是想要和其他语言走 FFI,那么也可以通过元编程机制生成相关代码(比如 pyo3 项目对 Rust 和 Python 的 FFI 通信的处理)。
不过 C# 的诊断器确实是个很有意思的东西,天然适合写框架。毕竟可以全局检查你的代码,并且添加各种约束,以减少使用框架时的未定义行为,所以如果去翻各种第三方库,他们大多都会写一些 Analyzer 。Rust 的确做不了这块,原因如前文所述,Rust 不提供能够直接访问全局语法树的 API,不过 Rust 本身也有 rust-analyzer ,还可以配合 cargo-clippy,因此对于代码本身的检查也很严格就是了()
写在后面 要我说的话,肯定是 Rust 的机制写的更爽(),毕竟可以自定义语法欸,超帅的吧!
Rust 独立开发的话,随便写点宏自娱自乐其实挺有趣的,之前也写过一些奇奇怪怪的功能,比如使用宏加载 json 文件自动生成 enum。(C# 当然也很容易实现)
由于我是 Rust 出身,代码风格极大程度受到了 Rust 设计哲学的影响,在使用 C# 的时候,说实话多次尝试将一些 Rust 特性在 C# 中实现(),不过确实有点麻烦,特别是我想要实现类似过程宏的东西时候遇到了很大的阻碍(一大理由就是 C# 不允许用户自己分析 TokenStream,使得不可能像 Rust 那样自由),我尝试的一个思路是使用字符串,然后我就自己设计语法(参考 Rust),搓了词法分析,语法分析。不过话说回来,这样就不像是 C#,固然人家 Lisp,Rust 可以设计自己的语法,但是他们都还是 Lisp,Rust,而 C# 既然本身设计哲学就是这样,强行改变写法只会显得抽象和丑陋。
当然 Rust 和 C# 都是极其优秀的语言,写工程确实舒服。(叠个甲)
不知道看本篇文章的读者有没有玩过所学语言的元编程机制,如果本文给你打开了一扇新世界的大门(大概不是深渊吧),将是我的荣幸。