用源生成器试着做了个 enum 的 ToStringFast 的感想
ソースジェネレーターでenumのToStringFastを作ってみた感想
前言
你好,我是空音!
平时是用Unity和C#做游戏开发的学生。
此前我做过Narimono、Kumiko等自己开发的库,而在其中一直很在意的是源生成器。
虽然知道“似乎可以在编译时生成代码”,但对于Roslyn到底是什么还不太清楚。
于是这次,我请AI(Claude)来当老师,以生成enum的ToStringFast()的生成器为题材,从零开始做了一遍。
在这篇文章里,我会先介绍做出来的东西,然后写下和AI一起学习的感想、制作过程中卡住的地方以及学到的东西!
把enum的值转成字符串时,一般会使用ToString()。
var name = Element.Water.ToString(); // "Water"
不过,Enum.ToString()内部使用了反射,所以很慢,在Unity的环境里每次调用还会产生垃圾。
如果在UI显示或日志等地方每帧调用,就会实实在在地造成影响。
因此,我改成只需给 enum 添加特性,就能自动生成不使用反射的转换方法。
namespace Game
{
[EnumGenerator.GenerateToStringFast]
public enum Element
{
Fire,
Water,
Wind,
}
}
// 使う側
var name = Game.Element.Water.ToStringFast(); // "Water"
生成的代码如下所示。
// <auto-generated/>
namespace Game
{
public static class ElementToStringFastExtensions
{
public static string ToStringFast(this global::Game.Element value)
{
return value switch
{
global::Game.Element.Fire => nameof(global::Game.Element.Fire),
global::Game.Element.Water => nameof(global::Game.Element.Water),
global::Game.Element.Wind => nameof(global::Game.Element.Wind),
_ => value.ToString(),
};
}
}
}
名称通过nameof在编译时变为字符串常量,因此运行时几乎没有开销。
定义之外的值(如(Element)99)会由最后的_ => value.ToString()接收,所以也不会抛出异常。
试着让 AI 扮演老师
这次不是请它来写代码,而是采用「请当我的老师」,自己动手一边学一边做的形式。
推进方式大致如下。
| 段階 | 内容 |
|---|---|
| 第0〜2课 | Roslyn是什么、语法树与符号的说明 |
| 第3课 | 只输出Hello World的最小生成器 |
| 第4课 |
逐步制作ToStringFast(注入特性 → 探索 → 提取信息 → 生成 → 公开范围) |
| 第5课 | 对错误用法报错 |
| 検証 | 放入 Unity 中并确认运行 |
从文件夹和项目的创建方法开始教我,每个阶段都会给出填空代码和思考题。
例如“为什么transform返回的不是ISymbol,而是string呢?”这样的问题。
说实话,不懂的问题也很多,我也经常回答“不知道”。
尽管如此,对方会从那里开始按顺序解释理由,所以比起只拿到答案,为什么这样写更留在了我的脑海里。
另外,我写的代码每次都会请人 review。
不仅看运行情况,还会看命名、注释和风格是否统一,这对平时就重视设计和命名的我来说让我很感激。
AI的说明有时也会出错
不过,AI的说明并非每次都是正确的。
- 关于生成器在Unity中应用于哪个程序集的说明有一部分是错误的,是在Unity实际发出警告后才发现的
- 提供的代码(
EquatableArray)中缺少必要的接口,导致输出变得异常 -
我忽略了
HashCode无法在netstandard2.0中使用这一点
这些都是因为自己实际运行并确认了结果才注意到的。
只要把错误或输出贴上去,它就会说明原因并给出修改方法,所以这反而是最有收获的部分。
在让 AI 教我时,我感受到重要的是,不要盲目相信说明,而要自己动手运行来确认。
说到底,Roslyn 是什么?
在开始制作之前,我先学习了 Roslyn。
Roslyn 是让 C# 编译器能够作为库来使用的东西。源生成器也好,Visual Studio 的警告和分析器也好,都运行在它之上。
我尤其觉得重要的是语法与**语义(符号)**的区别。
| 構文 | 语义(符号) | |
|---|---|---|
| 表示的内容 | 代码的“形” | 名称指的是什么 |
| 例 | 这是一个 enum 声明 | 该属性是EnumGenerator.GenerateToStringFastAttribute |
| 速度 | 快 | 慢(需要进行名称解析) |
例如,属性既可以写成[GenerateToStringFast],也可以写成[EnumGenerator.GenerateToStringFast]。
在语法阶段无法判断这两者是否为同一个东西,只有到语义阶段才能做出判断。
因此,生成器就采用先在语法层面粗略筛选候选,再用符号来确定的两阶段方式来构建。
起初我对这种区分并没有真切的理解,但实际动手写之后,终于明白了。
所做东西的构成
文件按如下方式进行了划分。
| 文件 | 役割 |
|---|---|
ToStringFastGenerator |
生成器本体。只负责组装处理流程 |
ToStringFastEmitter |
接收信息,并组装要生成的代码字符串 |
EnumModel |
生成所需的 enum 信息(命名空间、名称、公开范围、成员名) |
NestedEnumInfo |
为了抛出错误所需的信息 |
DiagnosticDescriptors |
错误的定义 |
EquatableArray |
可以按内容进行比较的数组 |
处理流程大概是这样。
コンパイル開始
├─ 属性の定義を追加する
├─ 属性が付いた enum を探す(トップレベル)
│ └─ EnumModel を作る → ToStringFast のコードを生成
└─ 属性が付いた enum を探す(入れ子)
└─ NestedEnumInfo を作る → エラーを報告
将「从哪里取出什么」与「如何书写」分离之后,主体部分变得相当短了。
卡住的地方与学到的东西
特性不仅需要「查找」,还需要「创建」
有一个用特性来查找 enum 的ForAttributeWithMetadataName机制,但这终究只是查找功能。
特性本身的定义,是由生成器通过另一套机制(RegisterPostInitializationOutput)添加到使用方代码中的。
有一次把这段追加处理注释掉之后,构建就无法通过了,那时我才第一次理解到「注入」和「探索」是两回事。
“能够按值比较”这一点最重要
这次收获最大的就是这里。
源生成器有可能在编辑器中每输入一个字时就会运行。
因此,这套机制会把各个阶段的结果与上一次进行比较,如果相同就跳过后续处理。
这里成为问题的是 Roslyn 的对象。
ISymbol 和 Location 会在每次编辑时被重新创建成新的对象,所以即使内容相同,也会被判定为“不同的东西”。
| 不希望它持有的东西 | 取而代之持有的东西 |
|---|---|
ISymbol(类型信息) |
命名空间及名称的string
|
ImmutableArray<string> |
自定义的EquatableArray<string>
|
Location(源代码中的位置) |
文件路径 + 范围的值 |
特别是ImmutableArray,即使内容相同也会按引用进行比较,这一点出乎我的意料。
也就是说,在生成器中途传递的数据,需要拆解成可以按值比较的简单类型。
如果只是能跑起来,即使不在意这些也照样能运行,所以我感觉这是不知道就注意不到的地方。
netstandard2.0 的壁垒
生成器由于 Unity 和 Roslyn 的缘故,必须用 netstandard2.0 来创建。
平时在 .NET 中能用的HashCode在这里用不了,为了使用record还得自己定义IsExternalInit这个类型,在一些细节上撞上了旧环境的限制。
另外,生成器本身可以用最新的 C# 编写,但生成的代码必须与 Unity 的 C# 9 保持一致。
“编写的代码”和“被生成的代码”语言版本不同,这是一种很新鲜的感觉。
生成代码的公开范围
最初,我把生成的类全部设为internal。
但这样一来,即使是public enum,其他程序集也无法调用ToStringFast()。
那么是不是全部设为public就好呢?其实不然,给internal enum添加属性时,会因CS0051(可访问性不一致)导致构建失败。
对方用故意引发错误的步骤给我做了说明,我这才理解。
最终,我改成了让生成的类的公开范围与原来的 enum 保持一致。
不能因为生成方自身的方便而破坏使用者的构建,我觉得这是制作库时非常重要的思路。
把「什么都没发生」当作错误
这次的生成器不支持类中嵌套的 enum。
一开始是直接忽略的,但那样就会变成明明加了属性却什么都没生成,而且不知道原因的状态。
于是,我改成了:如果嵌套的 enum 上带有属性,就在 enum 名称的位置报错。
public sealed class Holder
{
[EnumGenerator.GenerateToStringFast]
public enum Inner { A } // ← ETF0001: 入れ子の enum には ToStringFast を生成できません
}
把沉默的失败,变成能告诉你哪里出错的错误。
我感觉这与我一直以来重视的“不靠规约,而靠结构来守护”这一思路非常接近。
导入 Unity 时的注意事项
生成器先在外部项目中做成 DLL,然后再导入 Unity。
最初试做的 Hello World 生成器,我把 DLL 直接放在了 Assets 的正下方,结果类型被生成到了 URP 包的程序集里,出现了 CS0436(类型冲突)的警告。
因此这次,我用以下两点做了应对。
- 把 DLL 放在 asmdef 的文件夹里,将适用范围限定在该程序集(以及引用它的程序集)
- 把注入的属性设为
internal,使其对其他程序集不可见
在 Unity 6 中试了一下,生成和错误显示都没有问题,运行正常!
当前限制
编译与所生成代码形式相同的代码进行验证后发现,当前的实现存在以下限制。
| 情况 | 結果 | 修正方法 |
|---|---|---|
存在相同值的成员(A = 1, B = 1) |
switch 的分支重复,导致CS8510
|
去除重复的值 |
与保留字同名的成员(@int) |
生成的代码出现语法错误 | 给成员名加上@后生成 |
| 嵌套的 enum | 不支持(通过错误告知) | 使用包含外层类型名的类名 |
上面两项会导致生成的代码无法编译,破坏用户的构建,所以我打算接下来修复它们。
制作后的感想
如果只是输出 Hello World,做起来比想象中要简单得多。
不过,若要把它做成实用的生成器,就需要考虑缓存机制、公开范围、错误提示方式等,进行为用户着想的设计。
我原本以为源生成器是“能自动编写代码的便利功能”,但实际制作后发现,它也是一种能在编译时阻止错误用法的机制。
不依赖约定,而是通过结构来保证正确性,这一点我觉得与迄今为止所制作库的设计理念也很契合。
把AI当作老师的学习方式也很适合我。
不懂的地方可以反复提问,能按自己的节奏推进,我觉得这是自学很难获得的环境。
另一方面,最终判断「是否真的正确」的是自己,所以动手运行来确认的习惯今后我也想继续保持重视。
接下来,我想运用这次的知识,尝试做一个让C#的event的订阅与取消订阅更易处理的库。
另外,我也想挑战一下检查所写代码并报错的「分析器(Analyzer)」。
结语
这次介绍了用源生成器试着制作enum的ToStringFast()的感想。
虽然是我第一次做源生成器,但请AI担任老师角色后,从Roslyn的机制到引入Unity,我都能大致接触了一遍。
如果对同样想着「源生成器该从哪里开始呢?」的人,或对用AI学习的方式感兴趣的人能有所帮助,我会很高兴。
如果有感想或指正错误等,欢迎在Zenn的评论中告诉我!
感谢您读到最后!
Input 与 Update:将昨日的知识,化为今日的技术
来源:zenn · 游戏开发社区 · zenn.dev