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

用源生成器试着做了个 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に先生役を頼んでみた

今回は、コードを書いてもらうのではなく、「先生になってください」とお願いして、自分で手を動かしながら学ぶ形にしました。

進め方は次のような感じです。

段階 内容
Lesson 0〜2 Roslynとは何か、構文木、シンボルの説明
Lesson 3 Hello Worldを出すだけの最小のジェネレーター
Lesson 4 ToStringFastを少しずつ作る(属性の注入 → 探索 → 情報の抜き出し → 生成 → 公開範囲)
Lesson 5 間違った使い方にエラーを出す
検証 Unityに入れて動作を確認

フォルダやプロジェクトの作り方から教えてもらい、各段階では穴埋めのコードと考える問題が出されました。
例えば「transformがISymbolではなくstringを返しているのはなぜでしょうか?」といった問題です。

正直、分からない問題も多く、「分からないです」と答えることもよくありました。
それでも、そこから理由を順番に説明してもらえたので、答えだけを渡されるよりも、なぜそう書くのかが頭に残りました。

また、書いたコードは毎回レビューしてもらいました。
動作だけでなく、命名やコメント、スタイルの統一まで見てもらえたのは、普段から設計や命名を大事にしている自分にとってありがたかったです。

AIの説明も間違うことがある

ただ、AIの説明が毎回正しかったわけではありません。

  • Unityでジェネレーターがどのアセンブリに適用されるかの説明が一部間違っていて、実際にUnityで警告が出たことで発覚した
  • 渡されたコード(EquatableArray)に必要なインターフェースが足りず、出力がおかしくなった
  • HashCodeがnetstandard2.0で使えないことを見落としていた

どれも、自分で動かして結果を確かめたから気づけたものでした。
エラーや出力を貼ると、原因を説明して直し方を示してくれたので、むしろそこが一番勉強になった部分でもあります。

AIに教えてもらうときは、説明をうのみにせず、手元で動かして確かめることが大事だと感じました。

そもそもRoslynとは?

作り始める前に、まずRoslynについて教わりました。
Roslynは、C#のコンパイラーをライブラリとして使えるようにしたものです。ソースジェネレーターも、Visual Studioの警告やアナライザーも、この上で動いています。

特に大事だと感じたのが、構文と**意味(シンボル)**の違いです。

構文 意味(シンボル)
表すもの コードの「形」 名前が何を指しているか
例 これはenumの宣言である この属性はEnumGenerator.GenerateToStringFastAttributeである
速さ 速い 遅い(名前の解決が必要)

例えば、属性は[GenerateToStringFast]とも[EnumGenerator.GenerateToStringFast]とも書けます。
構文の段階ではこの2つが同じものかどうか分からず、意味の段階で初めて判断できます。

そこでジェネレーターは、構文で軽く候補を絞り、シンボルで確定するという2段構えで作ることになります。
最初はこの区別がピンと来なかったのですが、実際に書いてみて、ようやく理解できました。

作ったものの構成

ファイルは次のように分けました。

ファイル 役割
ToStringFastGenerator ジェネレーター本体。処理の流れを組み立てるだけ
ToStringFastEmitter 情報を受け取って、生成するコードの文字列を組み立てる
EnumModel 生成に必要なenumの情報(名前空間・名前・公開範囲・メンバー名)
NestedEnumInfo エラーを出すために必要な情報
DiagnosticDescriptors エラーの定義
EquatableArray 中身で比較できる配列

処理の流れはこんな感じです。

コンパイル開始
 ├─ 属性の定義を追加する
 ├─ 属性が付いた enum を探す(トップレベル)
 │    └─ EnumModel を作る → ToStringFast のコードを生成
 └─ 属性が付いた enum を探す(入れ子)
      └─ NestedEnumInfo を作る → エラーを報告

「どこから何を取り出すか」と「どう書くか」を分けるようにしたので、本体はかなり短くなりました。

つまずいたところと学んだこと

属性は「探す」だけでなく「作る」必要がある

属性を使ってenumを探すForAttributeWithMetadataNameという仕組みがあるのですが、これはあくまで探す機能です。
属性そのものの定義は、ジェネレーターが別の仕組み(RegisterPostInitializationOutput)で、利用側のコードに追加しています。

一度この追加の処理をコメントアウトしたらビルドが通らなくなり、そこで初めて「注入」と「探索」が別物だと理解できました。

「値で比較できる」ことがいちばん大事

今回いちばん学びが大きかったのがここです。

ソースジェネレーターは、エディターで1文字打つたびに動く可能性があります。
そのため、各段階の結果を前回と比べて、同じなら後の処理を飛ばすという仕組みになっています。

ここで問題になるのが、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(型の衝突)の警告が出ました。

そこで今回は、次の2つで対策しました。

  • DLLをasmdefのフォルダに置いて、適用範囲を、そのアセンブリ(と、それを参照するアセンブリ)に絞る
  • 注入する属性をinternalにして、他のアセンブリから見えないようにする

Unity 6で試したところ、生成・エラー表示ともに問題なく動きました!

現在の制限

生成されるコードと同じ形のコードをコンパイルして確かめたところ、今の実装には次の制限があります。

ケース 結果 直し方
同じ値のメンバーがある(A = 1, B = 1) switchの分岐が重複してCS8510 値の重複を取り除く
予約語と同じ名前のメンバー(@int) 生成コードが構文エラー メンバー名に@を付けて生成する
入れ子のenum 非対応(エラーで知らせる) 外側の型名を含めたクラス名にする

上の2つは、生成したコードがコンパイルできず、利用者のビルドを壊してしまうものなので、次に直したいと考えています。

作ってみた感想

Hello Worldを出すだけなら、思っていたよりずっと簡単にできました。
ただ、実用的なジェネレーターにしようとすると、キャッシュの仕組みや公開範囲、エラーの出し方など、利用者のことを考えた設計が必要になってきます。

ソースジェネレーターは「コードを自動で書いてくれる便利な機能」だと思っていたのですが、作ってみると間違った使い方をコンパイル時に止められる仕組みでもあることに気づきました。
規約に頼らず、構造で正しさを守るという点で、これまで作ってきたライブラリの考え方とも相性がいいと感じています。

AIを先生にした学び方も、自分には合っていました。
分からないところを何度でも聞けて、自分のペースで進められるのは、独学ではなかなか得られない環境だと思います。
一方で、最後に「本当に正しいか」を判断するのは自分なので、動かして確かめる習慣はこれからも大事にしたいです。

次は、今回の知識を使って、C#のeventの購読と解除を扱いやすくするライブラリを作ってみたいと思っています。
また、書かれたコードを検査してエラーにする「アナライザー」にも挑戦してみたいです。

おわりに

今回は、ソースジェネレーターでenumのToStringFast()を作ってみた感想を紹介しました。

初めてのソースジェネレーターでしたが、AIに先生役をお願いしたことで、Roslynの仕組みからUnityへの導入まで、一通り触れることができました。
同じように「ソースジェネレーターって何から始めればいいんだろう?」と思っている方や、AIを使った学び方に興味がある方の参考になれば嬉しいです。

感想や間違いの指摘などがあれば、Zennのコメントで教えていただけると嬉しいです!
最後まで読んでいただき、ありがとうございました!


InputとUpdate 昨日の知識を、今日の技術へ

来源:zenn · 游戏开发社区 · zenn.dev