2020年を振り返る
- 2020-12-30
今年は前半が絶好調で、ConsoleAppFramework、ProcessX、ZString、ZLogger、そしてUniTask v2と、凄い勢いでプログラミング的なクリエイティビティを発揮できていました。なので今年トータルとしてみれば良かったと言えます。
が、後半が絶不調で無。とにかく無。なんでこんな無になったのか分からないほどに無。コロナか、コロナが悪いんか。それも実際あるんですが、いや、というかそれが全てかなー。リモートワーク向いてないんすよー、みたいな。リモート前半で魂の貯金を使い切った。無が加速してからヤバいと思って自主リモート返納(自分だけオフィスワーク)に戻したんですが、それでもなんか違うんですよねえ。まぁ、言い訳なんですけどね!新環境に適応できない旧世代民には死あるのみ、って感じなので、来年は脳みそ入れ替えてやってきたいと思います。
この12月は、書く予定だったアドベントカレンダーも書けずにフィニッシュと最悪な感じですからねえ、終わりが全くしまらなかった結果、今年の印象としてはあんま良くない。でも客観的に一年通しで見たら、中々の成果を上げたとは言えます。
OSSがかなり出揃ったことで、Cysharpという会社の輪郭をはっきりさせられた年になりました。対外的には何やってる会社か分からない、まぁ実際そこは今もよくわからないと思うんですが、それでもC#の最先端を突っ走っている会社だというイメージは確固たるものになったのではないでしょうか。去年ではまだまだ足りてないと考えていたのですが、今年追加したOSS群によって、一つポジションを引き上げられたと思っています。
MagicOnionもv4になって .NET 5/Pure C# gRPC 対応を果たしましたし、今年は実際に採用しているアプリケーションがリリースされていったことで、よりCysharpの目指しているヴィジョンの現実感が出てきました。来年はそのヴィジョンをより鮮明にしていくことと、もうプラスαに仕込んでいるものがあるので、その辺の露出がうまくできるといいかなーと思ってます。
私個人の能力の成長という点でも、UniTask v2を始めとしてパワーある実装をやりきったことと、そこから深く学んだこともいっぱいあるので、まだまだ行けるぞという感じです。ちゃんとね、毎年成長してますよ。はい。人間、停滞=衰退ですから。
私は出したもののウケ度に割と拘るところがあるんですが、これは自分の感覚と市場の感覚が乖離していないかを測っているという面もあります。今日が誕生日でもうN回この振り返りも書いてるわけですが、そろそろ油断すると感性が腐る頃合いなんですよね。なんかピンとのズレたことを言い始めてしまうという。端的に言えばそれが老害というわけなんですが、自分も油断するとなりかねない。という危機感がそぞろ出てくるような頃合いでして。しかもね、そういうのは自覚がないわけですよ、本人は自覚がない!本人はイケてると思っているのが余計辛い!自覚がないからこそ老害なのだ。みたいなところがある。
と、いうわけで、客観的な指標が必要で、とりあえず今年はOKじゃないですかね。はい。
その他文化
今年のGame of the YearはDOOM Eternalですよね……!震えるほど面白いゲームって本当に数年単位で久々で、腐った感性を復活させてくれた神の救いですよ。というわけでマストバイ。(しかし超期待したDLCは微妙だった……)
今年のベストアルバムは中村佳穂のAINOUです。中村佳穂『AINOU』はなぜ2018年を代表する名盤なのか?とかって記事出てるように全然今年のアルバムじゃないんですが、聴いたのは今年だからshoganai。名盤。
読み物としては、ちょくちょく月刊専門料理を買ってて、これが面白いんですよね。料理とエンジニアリングは共通するものがあるとCooking for Geeksをはじめとしてよく言われるやつですが、それプラス経営的な話とかも中々身に沁みるものがあって良いわけです。あと、料理業界はまだまだ多分アナログなんですよね、だから紙の雑誌にも相応の密度がある。その点エンジニアの場合はウェブ媒体のほうが紙より良い状態なので、雑誌が面白くないんですよね(Web+DBとかもはやつまらんでしょ)。良くも悪くもですが、まぁもう進んでしまった業界は紙の媒体が面白くなることはないのでしょう。
来年
アドベントカレンダーネタは書いてないしGitHub Issuesもかなり手を付けてないのが残っちゃったしで、あんまりスッキリして来年を迎えられないんですが……!そのへんはなるはやですっきりさせたいとして、今年はCysharpの仕込みフェーズがとてもうまくいった。実際うまくいった。そして仕込みフェーズは終了。つまり来年はどーんといきましょう。というわけで、ぜひぜひ大躍進にご期待くださいな。
UnitGenerator - C# 9.0 SourceGeneratorによるValueObjectパターンの自動実装とSourceGenerator実装Tips
- 2020-12-15
ValueObjectは好きですか?私は大嫌いです。いじょ。
ざっくり言えばプリミティブ型に専用の型を付ける教義です。例えばUserIdをintとして扱っているとTeamIdと取り違えるかもしれないし、Hpに突っ込んでしまうかもしれない。StrengthとIntelligenceとAgilityとSpeedは別物なのだから全部intじゃなくて区別して欲しい、そうじゃないと間違った演算しちゃうぞ、と。まぁそういう自体を避けるために、それぞれラップした個別型を作るのです。int strengthじゃなくてStrength strengthだぞ、と。
これは一見正しく実際正しいのですが、問題もあります。一つに面倒くさい。ラップしたctorを作るのだけでも定形でウザ、と思いますが、更に等値とか実装するのは面倒くさい。また、そのままだと計算できなくなるので、算術演算のために生の値を.Value
で取り出す、が頻出すると安全度も下がるし見た目もめっちゃ汚くなる、当然ながらものすごく書きづらい。そしてシリアライゼーションの問題。Serialize(userId)としたときに「{ "Value" = 100 }」なんて形にシリアライズされたら最低で、全く許容できない。また、データベースで扱うときにもORMはそのままだとプリミティブしか扱えないので、マッピングできなくて不便なことになります。
といった問題があるため、基本的には大嫌いなのでそういうのやらない、プリミティブで何が悪いんだボケ。ぐらいの勢いでした。実際、社内でそうしたい、という話があった場合にはトップダウン権限で却下してたぐらいです(横暴!)。のですが、上記の問題が解決するのならば、全然許せます。むしろ良い。むしろすべき。かもしれません。
そこで C# 9.0 から新搭載されたSourceGeneratorの出番です。SourceGeneratorを活用したUnitGeneratorというライブラリを新しく作りました。今回はその内容の解説と、SourceGeneratorを実装する上でのTipsを紹介します。また、この記事は C# その2 Advent Calendar 2020 15日用です。19日にもC# Advent Calendar 2020でSourceGeneratorネタを書く予定なので、まずはPart 1ということで合わせてお楽しみください。
ちなみにC# Advent Calendar 2020の初日の記事 C# 9.0で加わったC# Source Generatorと、それで作ったValueObjectGeneratorの紹介 と内容的には非常に似通ってるんですが、そこはshoganai。またC#9.0 SourceGeneratorでReadonly構造体を生成するGeneratorを作ってみました。とも被ってますね、しょーがしょーがない。
SourceGeneratorの特性
GitHubとNuGetにUnitGenerator
として公開しました(この記事でも後で触れますが、ReadMe末尾にはUnityでの使い方も載せてあります)。
使い方は、public readonly partial struct
に対して、[UnitOf(typeof(T))]
を書くだけです。
using UnitGenerator;
[UnitOf(typeof(int))]
public readonly partial struct UserId { }
これを書くと、SourceGeneratorが裏側で以下のpartial classをコンパイル時(ビルド前)に生成します。
[System.ComponentModel.TypeConverter(typeof(UserIdTypeConverter))]
public readonly partial struct UserId : IEquatable<UserId>
{
readonly int value;
public UserId(int value)
{
this.value = value;
}
public readonly int AsPrimitive() => value;
public static explicit operator int(UserId value) => value.value;
public static explicit operator UserId(int value) => new UserId(value);
public bool Equals(UserId other) => value.Equals(other.value);
public override bool Equals(object? obj) => // snip...
public override int GetHashCode() => value.GetHashCode();
public override string ToString() => "UserId(" + value + ")";
public static bool operator ==(in UserId x, in UserId y) => x.value.Equals(y.value);
public static bool operator !=(in UserId x, in UserId y) => !x.value.Equals(y.value);
private class UserIdTypeConverter : System.ComponentModel.TypeConverter
{
// snip...
}
}
SourceGeneratorのいいところは、生成コードがC#コンパイラのメモリ内で完結していることです。つまり、ファイルが出てきません。ファイルが出てこないのは非常にいいことで、自動生成ファイルが減った時の管理をしなくてすみます。ディレクトリごと毎回Cleanするのもイマイチですし、かといって古いファイルが残り続けるのはマズいので、そこの管理をどうするか問題は毎度面倒くさいことです。
欠点はメモリ内で完結していることです。ソースが見えないとデバッガビリティも下がりますし、コンパイルしないと追加されたコードが使えないというのもコード書いてる最中の手触り的に面倒。というのが一般的な話なのですが、そこを言語組み込みの機能として用意したことでカバーしているのがSourceGeneratorの良いところです。まず、デバッガビリティに関してはIDE(Visual Studioなど)でコードジャンプできるようになっているし、デバッガのステップ実行もフルサポート。また、IDEのインクリメンタルコンパイルとフルに連動しているため、属性を書いた瞬間から、裏ではそこの部分だけコンパイルが走ってコードが生成されて、生成コードが利用可能になっています。これは今までのビルド時プリプロセッサー/ポストプロセッサーではできなかった体験で、中々小気味良いものです。
唯一の欠点は既存コードをEditできないので、partialであることが必須になることと、編集を要求する内容は作れないことでしょうか。まぁ、それは従来あったAnalyzer(CodeFixProvider)でやればいいということで、それなりに棲み分けもできてますし、ソースコードの追加しかできないという仕様のお陰で、作成に関してはかなりシンプルになったこともいいことです。
UnitGenerateOptions
値の等値性だけを実装するのはままあるのですが、それだけだと不便なんですよね。例えばHpは + 100 とかそのまま演算したいじゃん、と。その辺のサポートがないとすぐに.Valueで生の値を取り出すことになって
よくないし、MinやMaxなんかもそのまんま使いたい、例えばHpを現在値の2倍で回復する、みたいなのは target.Hp = Hp.Min(target.Hp * 2, target.MaxHp)
と書けたるとかなり自然でいいよね、と。
その辺の生成をサポートするのが UnitGenerateOptions で、これを組み合わせることによって、算術演算子など好きなメソッドが追加されます。UserIdのようなものは算術演算子が生成されては困るので抑制したいし、Hpはフルで生成したい、みたいな使い分けができます。
[UnitOf(typeof(int), UnitGenerateOptions.ArithmeticOperator | UnitGenerateOptions.ValueArithmeticOperator | UnitGenerateOptions.Comparable | UnitGenerateOptions.MinMaxMethod)]
public readonly partial struct Hp { }
// -- generates
[System.ComponentModel.TypeConverter(typeof(HpTypeConverter))]
public readonly partial struct Hp : IEquatable<Hp> , IComparable<Hp>
{
readonly int value;
public Hp(int value)
{
this.value = value;
}
public readonly int AsPrimitive() => value;
public static explicit operator int(Hp value) => value.value;
public static explicit operator Hp(int value) => new Hp(value);
public bool Equals(Hp other) => value.Equals(other.value);
public override bool Equals(object? obj) => // snip...
public override int GetHashCode() => value.GetHashCode();
public override string ToString() => "Hp(" + value + ")";
public static bool operator ==(in Hp x, in Hp y) => x.value.Equals(y.value);
public static bool operator !=(in Hp x, in Hp y) => !x.value.Equals(y.value);
private class HpTypeConverter : System.ComponentModel.TypeConverter { /* snip... */ }
// UnitGenerateOptions.ArithmeticOperator
public static Hp operator +(in Hp x, in Hp y) => new Hp(checked((int)(x.value + y.value)));
public static Hp operator -(in Hp x, in Hp y) => new Hp(checked((int)(x.value - y.value)));
public static Hp operator *(in Hp x, in Hp y) => new Hp(checked((int)(x.value * y.value)));
public static Hp operator /(in Hp x, in Hp y) => new Hp(checked((int)(x.value / y.value)));
// UnitGenerateOptions.ValueArithmeticOperator
public static Hp operator ++(in Hp x) => new Hp(checked((int)(x.value + 1)));
public static Hp operator --(in Hp x) => new Hp(checked((int)(x.value - 1)));
public static Hp operator +(in Hp x, in int y) => new Hp(checked((int)(x.value + y)));
public static Hp operator -(in Hp x, in int y) => new Hp(checked((int)(x.value - y)));
public static Hp operator *(in Hp x, in int y) => new Hp(checked((int)(x.value * y)));
public static Hp operator /(in Hp x, in int y) => new Hp(checked((int)(x.value / y)));
// UnitGenerateOptions.Comparable
public int CompareTo(Hp other) => value.CompareTo(other);
public static bool operator >(in Hp x, in Hp y) => x.value > y.value;
public static bool operator <(in Hp x, in Hp y) => x.value < y.value;
public static bool operator >=(in Hp x, in Hp y) => x.value >= y.value;
public static bool operator <=(in Hp x, in Hp y) => x.value <= y.value;
// UnitGenerateOptions.MinMaxMethod
public static Hp Min(Hp x, Hp y) => new Hp(Math.Min(x.value, y.value));
public static Hp Max(Hp x, Hp y) => new Hp(Math.Max(x.value, y.value));
}
この辺のメソッドがしっかり生成されることによって、プリミティブ型をそのまま使うのと遜色のない使用感が担保できるわけです。
if (character.Hp <= 0) // Hp.GetType == typeof(Hp)
{
// is dead.
}
みたいに書けるようになってとても嬉しい。
また、演算子のオーバーロードはしっかり考慮して作るのが地味に大変な代物なので、そこをちゃんとやってくれるのも助かりです。例えばboolの場合はtrue演算子を自動実装します。
public static bool operator true(Foo x) => x.value;
public static bool operator false(Foo x) => !x.value;
public static bool operator !(Foo x) => !x.value;
こんなの自分で実装する機会なんてほとんどないと思いますが、これによってifに直接突っ込めるようになります。
if (foo) // foo.GetType() == typeof(Foo)
{
}
UnitGenerateOptionsは現在のところ以下のオプションを提供しています。
[Flags]
internal enum UnitGenerateOptions
{
None = 0,
ImplicitOperator = 1,
ParseMethod = 2,
MinMaxMethod = 4,
ArithmeticOperator = 8,
ValueArithmeticOperator = 16,
Comparable = 32,
Validate = 64,
JsonConverter = 128,
MessagePackFormatter = 256,
DapperTypeHandler = 512,
EntityFrameworkValueConverter = 1024,
}
例えば以下のように指定できます。
[UnitOf(typeof(int), UnitGenerateOptions.ArithmeticOperator | UnitGenerateOptions.ValueArithmeticOperator | UnitGenerateOptions.Comparable | UnitGenerateOptions.MinMaxMethod)]
public readonly partial struct Strength { }
[UnitOf(typeof(DateTime), UnitGenerateOptions.ParseMethod | UnitGenerateOptions.Comparable)]
public readonly partial struct EndDate { }
[UnitOf(typeof(string), UnitGenerateOptions.MessagePackFormatter)]
public readonly partial struct Message { }
[UnitOf(typeof(byte[]))]
public readonly partial struct Image { }
[UnitOf(typeof((string street, string city)), UnitGenerateOptions.Validate)]
public readonly partial struct StreetAddress
{
private partial void Validate()
{
if (!DataMaster.Contains(value.street)) throw new Exception("Invalid Street: " + value.street);
if (!DataMaster.Contains(value.city)) throw new Exception("Invalid City: " + value.city);
}
}
Validateだけ少し特殊で、自動生成側のコードがpartial void Validate()
メソッドを生成して、自動生成されるコンストラクタでそれを呼ぶようになっています。Validateの実体をユーザー側が書けばOKということですね。プリミティブ型と違って、値が検証済みであることが保証されている、というのも一般的なプラクティスとしては重要な話です。(ただしstructのため、default(T)は防げないので、そういう意味では完全なValidationではありません)
シリアライザの自動実装
繰り返しますが 「{ "Value" = 100 }」みたいにシリアライズされるのは最低です。「100」とシリアライズされなければならない。と、いうわけで、そういう場合は専用のシリアライザを実装すれば回避できます。現状はSystem.Text.JsonのJsonConverterとMessagePack用のMessagePackFormatterを自動実装するオプションが用意されています。こういうのをちまちま用意するのは、私がシリアライザについて人一倍拘りがあるからで、普通はあんまないでしょうね。でもシリアライザはシステムにおいて本当に大事なことだから!
例えば UnitGenerateOptions.MessagePackFormatter
は以下のようなコードを自動実装します。
[UnitOf(typeof(int), UnitGenerateOptions.MessagePackFormatter)]
public readonly partial struct UserId { }
// -- generates
[MessagePackFormatter(typeof(UserIdMessagePackFormatter))]
public readonly partial struct UserId
{
class UserIdMessagePackFormatter : IMessagePackFormatter<UserId>
{
public void Serialize(ref MessagePackWriter writer, UserId value, MessagePackSerializerOptions options)
{
options.Resolver.GetFormatterWithVerify<int>().Serialize(ref writer, value.value, options);
}
public UserId Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options)
{
return new UserId(options.Resolver.GetFormatterWithVerify<int>().Deserialize(ref reader, options));
}
}
}
private classでFormatterが実装されるのがポイントで、Attributeからそのフォーマッターを取り出すことで、外部のResolverへの登録をせずに専用の対応をしています。Serialize/DeserializeはResolver経由じゃなくて直接Writer/Readerのプリミティブ型を呼ぶことで高速化できますが、まぁそれは次の機会に。このコードを発展化させた、MessagePack for C#におけるSourceGenerator対応については12/19の記事で詳しく触れる予定です。
データベースに関しても UnitGenerateOptions.DapperTypeHandler, UnitGenerateOptions.EntityFrameworkValueConverter でDapperとEF Coreの対応コードを生成します。ただしこちらは自動利用のシステムがないので、手動で取り出して登録する必要があります。
.Value is dead
UnitGeneratorはpublicプロパティを一つも生成しません。つまり、.Valueはありません。私は.Valueによる値の取り出しが悪いプラクティスだと思っていて、カジュアルに使おうという気持ちを起こさないようにしています。演算子の生成なども用意してあるし、あとは専用のメソッドを自前で書いたりしていくなどで解決できるといいよね、と。
とはいえさすがに取り出せないのは不便というか実用的ではないので、.AsPrimitive()
で取れます。プロパティではなくメソッドというだけで、心理的に少し抵抗感出るんじゃないでしょうか?制約なんてそのぐらいでいいでしょう。あんまりキツくやるのも好きではないので。
Unityで使う
Source Generatorは C# 9.0 の機能です。というわけで、2020年現在のUnityはどのバージョンもそれをサポートしていません。じゃあ使えないじゃんって話なのですが、幸いファイルとして生成する機能も用意されているので、外部コマンドを実行したら自動生成する、ぐらいの雰囲気でならUnityでも使うことができます。
まずはコンフィグとなるcsprojを用意します。例えばUnitSourceGen.csprojとして、以下のような内容のものを作ります。
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net5.0</TargetFramework>
<!-- add this two lines and configure output path -->
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
<CompilerGeneratedFilesOutputPath>$(ProjectDir)..\Generated</CompilerGeneratedFilesOutputPath>
</PropertyGroup>
<ItemGroup>
<!-- reference UnitGenerator -->
<PackageReference Include="UnitGenerator" Version="1.0.0" />
<!-- add target sources path from Unity -->
<Compile Include="..\MyUnity\Assets\Scripts\Models\**\*.cs" />
</ItemGroup>
</Project>
あとは .NET SDKを入れて、コマンドを叩きましょう。
dotnet build UnitSourceGen.csproj
これで UnitGenerator\UnitGenerator.SourceGenerator*.Generated.cs がOutputPathに指定したところに生成されています。UnitGeneratorは、UnitOfAttributeやUnitGenerateOptionsも自動生成コードの中に含まれる仕様(ランタイムレス)なので、一回空の状態で実行すれば、それらのコードが生成されて利用可能になります。
SourceGenerator実装の方法
netstandard2.0のライブラリプロジェクトとして(いまのところnet5.0だとうまくいかない、これはVisual Studioが .NET Frameworkで動いているせいだから、らしい)Microsoft.CodeAnalysis.CSharpを参照します。
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="3.8.0" PrivateAssets="all" />
</ItemGroup>
</Project>
また、合わせてテスト用のプロジェクトを用意して、ライブラリプロジェクトを参照するようにしておくといいでしょう。プロジェクト参照を、OutputItemType="Analyzer"にしておきます。
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net5.0</TargetFramework>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\UnitGenerator\UnitGenerator.csproj"
OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
</ItemGroup>
</Project>
あとはISourceGeneratorを実装するだけ。
[Generator]
public class SourceGenerator : ISourceGenerator
{
public void Initialize(GeneratorInitializationContext context)
{
#if DEBUG
if (!System.Diagnostics.Debugger.IsAttached)
{
// System.Diagnostics.Debugger.Launch();
}
#endif
context.RegisterForSyntaxNotifications(() => new SyntaxReceiver());
}
public void Execute(GeneratorExecutionContext context)
{
}
// 実装しなくてもいいけど、この段階で対象になるファイルを引っ掛けておくとワンパスで処理できる
class SyntaxReceiver : ISyntaxReceiver
{
public void OnVisitSyntaxNode(SyntaxNode syntaxNode)
{
}
}
}
System.Diagnostics.Debugger.Launch() を入れておくと、デバッガでアタッチできて実装が捗ります。ただしVisual Studioがインクリメンタルコンパイル的にかなりの頻度でキックしてくるので、不要なときはコメントアウトしておくのが吉。また、SourceGeneratorの実装コードの変更にたいしてVisual Studioのキャッシュがうまく追随してくれなくて、実装中は挙動が腐ることがよくあるので、困ったときの再起動でやり過ごしましょう。
RegisterForSyntaxNotificationsは使っても使わなくてもどちらでもいいのですが(ExecuteのところでSyntaxTreeの全てが手に入るので探索し放題)、ここで大雑把でも引っ掛けておいたほうが、その後の処理が軽量になるので、使ったほうが基本的にはヨシ。
SourceGeneratorでユーザーが使う属性は、参照DLL内に含めておいてそれを使う場合と、参照DLLは完全に空にして、ソースジェネレーター自身が生成するパターンがあります。後者のパターンを使うと、ソースジェネレーターのためだけに参照DLLが増えることを避けれるので、今回のUnitGeneratorのような、生成コードが全ての処理を行うタイプのものは、そちらのパターンを使ったほうが良いでしょう。
やりかたは単純に最初に必要な属性を突っ込んでしまうという、ただそれだけなのですが一点注意なのは、この生成は絶対死守しましょう。Execute内で例外が発生したりすると、ここでAddSourceした属性の追加はキャンセルされます。
public void Execute(GeneratorExecutionContext context)
{
context.AddSource("UnitOfAttribute.cs", "internal class UnitOfAttribute...);
try
{
// manipulate syntax...
}
catch (Exception ex)
{
System.Diagnostics.Trace.WriteLine(ex.ToString());
}
}
特にIDEのインクリメンタルコンパイルが稼働している状態だと、入力途中の「不完全なコード」が頻繁に飛んできます。こうした不完全なコードによる不正な構文木を正しくハンドリングするのはかなり難しく、例外を飛ばしてしまうのは正直避けられません。しかし、何があっても最初に生成する属性のAddSourceだけは維持しないと、「入力途中の不完全コード→例外発生で属性が吹っ飛ぶ→属性が吹っ飛ぶので入力補完が効かないどころか書いてるものが全てエラーになる」という負のループが発生します。なので、これに関してはtry-catchで握り潰しOKです。
コード生成のためのテンプレートですが、サンプルだとみんなstring interpolationでさっくり処理してますが、やめときましょう。複雑なコードを生成しようとすると破綻するので、よほど単純な生成じゃないならちゃんとテンプレートエンジン使いましょう。
じゃあ何を使えばいいのか、というとT4 Templateです。以前に.NET Core時代のT4によるC#のテキストテンプレート術という記事を書いたので、それを読んでくださいな。これの「実行時テキスト生成(TextTemplatingFilePreprocessor)」を使います。具体的なUnitGeneratorのテンプレートはUnitGenerator/CodeTemplate.ttにあるので参考にどうぞ。ただたんにOptionによってifがちょろちょろある程度ですが、それでもこれをstring interpolationとStringBuilderで処理するのは無理があります。
ユニットテストに関しては CSharpGeneratorDriver
というものが用意されているので、それで小さいCompilationを作って渡せばOK。ってどういうこっちゃという感じですが、chsienki/GeneratorTests.csのコードをまんま使えばOKですね。中身は単純です。
var comp = CreateCompilation(/* ソースジェネレーターの対象コード */);
var newComp = RunGenerators(comp, out var generatorDiags, new SimpleGenerator());
// あとはnewCompから生成コードを引っ張ってきて、それが意図通りの正しさかどうか見たり
Assert.Empty(newComp.GetDiagnostics()); // エラーなくちゃんと生成できてるかどうか
ただし、参照DLLを増やすと面倒くさい挙動したり、そもそも生成されたコードの挙動が正しいかどうかを見たい(UnitGeneratorでいうと算術演算子が正しいかとか、シリアライザの実装が正しいかとか)ほうが多いんじゃないかなーと思うので、普通にユニットテストプロジェクトにSourceGenerator参照して、それが生成されたコードを動かして普通にAssert書く、みたいなのでいいかな。私は実際そんなわけで、CSharpGeneratorDriver経由のテストはやめました。(というかそもそも普通のユニットテストもたいして書いてない説はある)
最後にNuGetへのパブリッシュについて。SourceGeneratorはAnalyerとして登録したいので、ひと手間いります。具体的には以下のように処理します。
<PropertyGroup>
<IncludeBuildOutput>false</IncludeBuildOutput>
<SuppressDependenciesWhenPacking>true</SuppressDependenciesWhenPacking>
</PropertyGroup>
<ItemGroup>
<None Include="$(OutputPath)\$(AssemblyName).dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" />
</ItemGroup>
IncludeBuildOutputで、自身のDLLを参照用として含めないようにします、これは前述の「参照DLLは完全に空にして、ソースジェネレーター自身が必要な属性を生成するパターン」を使う場合には自身の参照は不要だからですね。SuppressDependenciesWhenPackingは、これ設定しとかないとpack時に空なんだけど、という警告が出てくるので黙らせます。空なのは知っとるがな。
Analyzerとしてpackするにはanalyzers/dotnet/cs以下に配置すればいいだけ、ということで、そういう設定をしておきます。
一手間と言ってもこれだけです。昔はAnalyzerはPowerShell動かして小細工しなきゃいけないとか色々あって超絶面倒くさかったんですが、.NET 5時代の今は、だいぶ簡単になりました。
まとめ
ずっとF#のUnits of Measureのようなものが欲しいと思っていたのですよね。プリミティブなのだけど型がついてる。コンパイル時には型が消えてプリミティブそのものになるのでオーバーヘッドがない。そのままでも色々な演算ができる。
UnitGeneratorはお洒落なsuffixで生成とかはできないし組み込みの単位の変換関係(グラムとキログラムとかインチとフィーととか)があるわけじゃないので、同じものかといったら全然別物ではありますが、しかしValue Objectパターンの実装としては必要十分で、雰囲気も近づけられたのではないかと思います。
C#において、1要素のstructはメモリレイアウト的にはプリミティブ型と同一なので、完全に消せるわけではないですが、オーバーヘッドも減らしていける余地があります(演算のたびにnewで包み直していたりするのも、Unsafe.Asを活用していけばなくせるので、だいぶ近づけはするかな、と)。
実際ちゃんと型がついているのは良い状態で最終的に捗るのは間違いないので、このUnitGeneratorのアプローチが役に立てば何よりですね、是非試してみてください。あと、SourceGeneratorも是非作っていきましょう!