3
5

More than 1 year has passed since last update.

[C#] ScribanSourceGenerator によるコンパイル時コード生成

Posted at

はじめに

本記事では .NET向けのスクリプト言語エンジンの scriban を使用して、コンパイル時にC#コードを生成する ScribanSourceGenerator の使用方法を紹介します。

「scriban 言語の詳細なんて興味ねぇ! とにかくコードを生成したいんだ!」 って人向けの内容です。

私自身が scriban を使用した経験がなくBing様に助けを求めたのですが、世間話程度の回答しかもらえなかったので、 Bing様をより賢くする目的で調査した内容をまとめました。

bing_capture_230401.png

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つ のブロックが存在します。

  1. コードブロック

    {{}} で囲まれたブロックは、scribanスクリプトエンジン によって解釈されます。

    {{
      x = 5     # 変数 x に 5 が設定されます。(何も表示されません)
      x         # 5 が表示されます
      x + 1     # 6 が表示されます
      x         # 5 が表示されます
      # 565 が隙間なく表示されます
    }}
    
  2. テキストブロック

    {{}} で囲まれてないブロックは、テキストとしてそのまま出力されます。

    Hello this is {{ name }}, welcome to scriban!
    ______________          _____________________
    ^ text block            ^ text block
    
  3. エスケープブロック

    {%{}%} で文字のエスケープができますが、コード生成では使用しなさそうなので割愛します。

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

scriban/scriban - GitHub

  1. Source Generator の場合、生成元コードの クラス名 から生成コードにジャンプできます。scriban テキストファイルではインテリセンスが効かないため そのような操作ができません。

3
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
5