はじめに
本記事では .NET向けのスクリプト言語エンジンの scriban を使用して、コンパイル時にC#コードを生成する ScribanSourceGenerator の使用方法を紹介します。
「scriban 言語の詳細なんて興味ねぇ! とにかくコードを生成したいんだ!」 って人向けの内容です。
私自身が scriban を使用した経験がなくBing様に助けを求めたのですが、世間話程度の回答しかもらえなかったので、 Bing様をより賢くする目的で調査した内容をまとめました。
T4テキストテンプレート との比較
.NET のコード生成では 古来より T4 テキスト テンプレート が用意されていますが、残念ながら Visual Studio (for Windows) でしか使用できません。 またメンテさている様子もないことから将来性を感じません。
私自身は上記理由のみで十分に T4 を忌避するのですが、日本C#界の父であり(私が勝手に思ってる)ScribanSourceGenerator 作者の ufcpp さんが 脱T4のメリット をまとめてくださっていますので以下に引用いたします。
UfcppSample/Demo/2022/NoMoreT4 at master · ufcpp/UfcppSample より
脱 T4 メリット
- 速い
- 普通の C# だけで書ける
- 補完・コード解析が働く
- Visual Studio 以外で使える
- 「Visual Studio 上で保存時にコード生成」がきつかった
- この Roslyn Source Generator な時代に、誰も Source Generator 化しないあたりでお察し
- csproj がすっきり
<None Update="T4Generator.tt">
みたいな謎タグ要らない<Service Include="{508349b6-6b84-4df5-91f0-309beebad82d}" />
とかいう謎サービス要らない<PackageReference Include="System.CodeDom" />
とかいうつらみあるパッケージ参照要らない- Visual Studio 自体のバージョン・表示言語に依存しない
- TextTemplatingFilePreprocessor の生成結果は「T4 ファイルを編集して保存した PC」に依存しちゃう
- 複数人編集すると Git 差分が悲惨なことになってた
ScribanSourceGenerator によるコード生成
0. 前提
未確認ですが ScribanSourceGenerator は Visual Studio 2022 以上(Roslyn 4 以上)でないと動作しないと思います。 ISourceGenerator はサポートしてなさそう(IIncrementalGenerator のみの提供)なためです。
1. 準備
改めて整理するほど重要な内容ではないと思っていますが、話を順に進めるためワンパス通すところまでをまとめます。
パッケージ導入
まずは .NETプロジェクトに ScribanSourceGenerator パッケージをインストールしましょう。 本記事の作成時点の最新版は 1.1.0 です。
> dotnet add package ScribanSourceGenerator
コード生成の方法
コード生成には、Source Generator (C#9 以降) を利用する方法 と scribanテキストファイルをパースする方法 の2種類があります。 今回は より便利と思ってる前者 1 のみを紹介します。
Source Generator によるコード生成
対象の partial クラスに ScribanSourceGenerator.ClassMemberAttribute
を付与し、引数の文字列に scribanスクリプト を記述します。 以下は scriban を使用しておらずコード生成の旨味はないです。
生成元のコード
namespace ScribanSourceGeneratorDemo;
// 生文字列リテラル は C#11 以降
[ScribanSourceGenerator.ClassMember("""
public void SayHello() => System.Console.WriteLine("Hello!");
""")]
public partial class Demo1 { }
生成されたコード
// <auto-generated/>
namespace ScribanSourceGeneratorDemo {
partial class Demo1 {
public void SayHello() => System.Console.WriteLine("Hello!");
}}
コード生成のワンパス対応は以上です。
2. scribanスクリプト言語
scriban の言語仕様の中からコード生成に利用されそうな部分のみを紹介します。 言語の詳細仕様は scribanの公式ドキュメント をご確認ください。
2-1. ブロック
scribanスクリプト言語には 下記3つ のブロックが存在します。
-
コードブロック
{{
と}}
で囲まれたブロックは、scribanスクリプトエンジン によって解釈されます。{{ x = 5 # 変数 x に 5 が設定されます。(何も表示されません) x # 5 が表示されます x + 1 # 6 が表示されます x # 5 が表示されます # 565 が隙間なく表示されます }}
-
テキストブロック
{{
と}}
で囲まれてないブロックは、テキストとしてそのまま出力されます。Hello this is {{ name }}, welcome to scriban! ______________ _____________________ ^ text block ^ text block
-
エスケープブロック
{%{
と}%}
で文字のエスケープができますが、コード生成では使用しなさそうなので割愛します。
2-2. 言語仕様
コード生成で使用しそうな scribanスクリプト を C#コード と対比して紹介していきます。
ソースコメント
{{ $x = 1 # シングルライン
$y = 2 ## マルチ
ライン ##
}}
変数定義
- 動的型付け なので型指定は不要です。
- ローカル変数には
$
を付与します。 - シンボルは スネークケース で記述します。
// cs: int x = 123;
// cs: int byte_max = 0xff;
// cs: string s1 = "abc";
{{ $x = 123
$byte_max = 0xff
$s1 = "abc" # 'abc' の指定も可
}}
配列定義
- 宣言時の各要素は
[]
で括ります。(C# のリストパターン ライク) - 配列は index でアクセスできます。(C# と同様)
- 配列の要素数は
size
で取得できます。 (C# では Length プロパティ)
// cs: string[] ss = new[] { "false", "true" };
// cs: int length = ss.Length;
// cs: string s = ss[^1];
{{ $ss = ["false","true"]
$length = $ss.size
$s = ss[length - 1]
}}
if ステートメント
if
の処理が単一文であっても必ず end
で閉じる必要があります。忘れないようにしましょう。
// cs: if (i < 0) i = 0;
{{ # 複数行
if $i < 0
$i = 0
end
}}
{{ if $i < 0; $i = 0; end; # 単一行 }}
for ステートメント
範囲を i..j
で指定できますが、C# の Range
構造体 と異なり、scriban では j
も含まれます。C# に揃えたい場合は i..<j
を使用しましょう。
for
の処理が単一文であっても必ず end
で閉じる必要があります。忘れないようにしましょう。
// cs: for (int i = 0; i <= 3; i++) { }
{{ for $i in 0..3 # 3も含まれます }}
~~
{{ end }}
// cs: for (int i = 0; i < 3; i++) { }
{{ for $i in 0..<3 # 3は含まれません }}
~~
{{ end }}
foreach ステートメント
scriban では for
を使用します。 foreach
は存在しません。
// cs: string[] ss = new[] { "false", "true" };
// cs: foreach (var s in ss) { }
{{ $ss = ["false","true"]
for $s in $ss }}
~~
{{ end }}
関数定義
func
で定義して ret
で結果を戻し end
で閉じます。 デフォルト引数にも対応しています。
// cs: int Add(int x, int y, int z = 1) => x + y + z;
{{ func add(x,y,z=1)
ret x + y + z
end
}}
add(1,2) = {{ add(1,2) #4 }}
可変長引数 にも対応しています。
// cs: int GetSum(params int[] values) => values.Sum();
{{ func get_sum(values...)
$sum = 0
for $x in values
$sum += $x
end
# Referencing with index
# for $i in 0..<values.size
# $sum += values[$i]
# end
ret $sum
end
}}
get_sum(1,2,3,4) = {{ get_sum(1,2,3,4) #10 }}
以上を抑えておけば、なんとなくコード生成できると思います。
2-3. 生成コードの改行
scriban では 空白 と 改行 のコード生成もコントロールできます。
デフォルトだとコードブロック前後の 空白 と 改行 が そのまま出力されるため、無駄に隙間の多いコードが生成されます。 実動作への影響はありませんが、生成コードを読む機会もあると思いますので、多少はケアしてもよいかと思います。
改行を含む空白の削除 -
削除指定なし
The yard-pound system '
{{ $s }}' disappear.
# The yard-pound system '
# must' disappear.
前の空白と改行を削除
{{-
は、前方に遡って 空白以外の文字が登場するまで空白を削除します。 改行も空白に含まれます。
The yard-pound system '
{{- $s }}' disappear.
# The yard-pound system 'must' disappear.
後ろの空白と改行を削除
-}}
は、後方に進んで 空白以外の文字が登場するまで空白を削除します。 改行も空白に含まれます。
The yard-pound system '{{ $s -}}
' disappear.
# The yard-pound system 'must' disappear.
改行で動作が異なる削除 ~
削除指定なし
{{ for $i in 2..<4 }}
{{ $i }}
{{ $i * $i }}
{{ end }}
#
# 2
# 4
#
# 3
# 9
#
前方の削除
{{~
は、前方に遡って最初に登場する改行までの空白を削除します。改行は削除されません。
{{ for $i in 2..<4 }}
{{ $i }}
{{~ $i * $i }}
{{ end }}
#
# 2
#4
#
# 3
#9
#
後方の削除
~}}
は、後方に進んで最初に登場する改行までの空白 と 改行 を削除します。
{{ for $i in 2..<4 }}
{{ $i ~}}
{{ $i * $i }}
{{ end }}
#
# 2 4
#
# 3 9
#
3. コード生成の実例
コード生成で頻繁に使用されていそうな例を用意しました。これをコピって改変するのが手っ取り早いと思います。
3-1. 組み込み型のオーバーロード
各組み込み型に対する TryParse(this string s, out type result)
メソッドを生成する例です。
生成元のコード
namespace ScribanSourceGeneratorDemo;
[ScribanSourceGenerator.ClassMember("""
{{
$types = ["bool","byte","sbyte","short","ushort","int","uint","long","ulong","float","double","DateTime","DateTimeOffset","TimeSpan"]
for $t in $types
~}}
public static bool TryParse(this string s, out {{$t}} x) => {{$t}}.TryParse(s, out x);
{{ end }}
""")]
public static partial class Demo2 { }
生成されたコード
// <auto-generated/>
namespace ScribanSourceGeneratorDemo {
partial class Demo2 {
public static bool TryParse(this string s, out bool x) => bool.TryParse(s, out x);
public static bool TryParse(this string s, out byte x) => byte.TryParse(s, out x);
public static bool TryParse(this string s, out sbyte x) => sbyte.TryParse(s, out x);
~~~ 割愛 ~~~
}}
3-2. 引数個数のオーバーロード
可変長引数 params
による ヒープアロケーション を回避するため、n個の引数までオーバーロード メソッド を生成する例です。
生成元のコード
namespace ScribanSourceGeneratorDemo;
[ScribanSourceGenerator.ClassMember("""
{{
func get_word(max,words...);
$s1 = "";
for $i in 1..max
$s2 = "";
for $w in words
$s2 += $w
# Do not join index at the end.
if !for.last; $s2 += $i; end
end
$s1 += $s2
if !for.last
$s1 += ", "
end
end
ret $s1;
end
-}}
{{ for $i in 2..16 ~}}
public static async Task<({{ get_word $i "T" "" }})> WhenAll<{{ get_word $i "T" "" }}>({{ get_word $i "Task<T" "> task" "" }})
{
await Task.WhenAll({{ get_word $i "task" "" }});
return ({{ get_word $i "task" ".Result" }});
}
{{ end }}
""")]
public static partial class Demo3 { }
生成されたコード
// <auto-generated/>
namespace ScribanSourceGeneratorDemo {
partial class Demo3 {
public static async Task<(T1, T2)> WhenAll<T1, T2>(Task<T1> task1, Task<T2> task2)
{
await Task.WhenAll(task1, task2);
return (task1.Result, task2.Result);
}
public static async Task<(T1, T2, T3)> WhenAll<T1, T2, T3>(Task<T1> task1, Task<T2> task2, Task<T3> task3)
{
await Task.WhenAll(task1, task2, task3);
return (task1.Result, task2.Result, task3.Result);
}
public static async Task<(T1, T2, T3, T4)> WhenAll<T1, T2, T3, T4>(Task<T1> task1, Task<T2> task2, Task<T3> task3, Task<T4> task4)
{
await Task.WhenAll(task1, task2, task3, task4);
return (task1.Result, task2.Result, task3.Result, task4.Result);
}
~~~ 割愛 ~~~
}}
ソースコード
上記の実例コードは以下にアップしています。
hsytkm/ScribanSourceGeneratorDemo - GitHub
おわりに
.NETのコンパイル時コード生成パッケージの ScribanSourceGenerator の使用方法を紹介しました。
Bing様 が回答を持っておられなかったので(また Qiita にも記事がなかったので)まとめてみました。本記事が Bing様 に取り込まれ頒布されることを願っています🙏
確認環境
- Windows 11 22H2
- Visual Studio 2022 17.5.3
- .NET 7 (C#11)
- ScribanSourceGenerator 1.1.0
参考にさせていただいたページ
Visual Studio 2022 17.5 Preview 2 - ufcpp YouTube こちらの動画で 本Generator の存在を知りました。
ufcpp/ScribanSourceGenerator - GitHub
-
Source Generator の場合、生成元コードの クラス名 から生成コードにジャンプできます。scriban テキストファイルではインテリセンスが効かないため そのような操作ができません。 ↩