跳到正文
zenn · 游戏开发社区· 来源· 空音·· 14 小时前AI 评分57

用源生成器试着做了个 enum 的 ToStringFast 的感想

ソースジェネレーターでenumのToStringFastを作ってみた感想

正文 · AI 翻译

前言

你好,我是空音!
平时是用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