【C#】Incremental Source Generator入門
今回の記事はC#のIncremental Source Generatorについて。
C#ではSource Generatorを用いることで、コンパイル時に属性などの情報から動的にコードを生成することが可能です。これはシリアライザや通信層のライブラリなどによく使われていて、ボイラーコードの生成を自動化するのに役立ちます。また、近年ではNativeAOTへの対応から、従来ではリフレクションやIL生成で行っていた処理をSource Generatorに置き換える作業が進んでいます。
そこで今回はRoslynの基本的な部分からIncremental Source Generatorの実装までを解説していきます。ややとっつきにくさはありますが、使いこなせれば非常に強力なツールになるので、特にライブラリなどを開発する方は覚えておくと良いでしょう。
前提知識
- C#の基礎的な知識
- 構文解析に関する基本的な知識 (字句解析、抽象構文木など)
Source Generatorの開発ではRoslynのAPIでC#コードの構文解析を行うため、構文解析に関してある程度の知識があると理解がしやすいでしょう。
プロジェクトの作成
Source Generatorのプロジェクト
まずは、Source Generatorの開発用のプロジェクトを作成しましょう。
通常通りクラスライブラリを作成すれば良いのですが、一点注意としてTargetFramework
はnetstandard2.0
にする必要があります。これはVisual Studioが.NET Frameworkで動作しているため、最新のバージョンと互換性がないためです。
プロジェクトを作成したら、NuGetからMicrosoft.CodeAnalysis.CSharpをインストールします。ただし、最新のバージョンを入れれば良いわけではなく、ターゲットとなるVisual StudioやUnityに対応したバージョンを明示的に指定する必要があります。Unity 2022.3.12f1以降であれば4.3.0が利用可能であるため、その辺りを目安にすると良いでしょう。
dotnet add package Microsoft.CodeAnalysis.CSharp --version 4.3.0
NuGet\Install-Package Microsoft.CodeAnalysis.CSharp -Version 4.3.0
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.3.0" />
また、Source Generator専用の設定やNuGet配布用に設定をいくつか追加する必要があります。.csproj
ファイルに以下の項目を追加してあげればOKです。
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<!-- 最新の言語バージョンを明示的に指定 -->
<LangVersion>13</LangVersion>
<!-- SourceGeneratorを動作させるための設定 -->
<IsRoslynComponent>true</IsRoslynComponent>
<AnalyzerLanguage>cs</AnalyzerLanguage>
<!-- NuGet配布用の設定 -->
<IncludeBuildOutput>false</IncludeBuildOutput>
<DevelopmentDependency>true</DevelopmentDependency>
<IncludeSymbols>false</IncludeSymbols>
<SuppressDependenciesWhenPacking>true</SuppressDependenciesWhenPacking>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.3.0" />
</ItemGroup>
<!-- NuGetパッケージの出力先をanalyzers/dotnet/csに -->
<ItemGroup>
<None Include="$(OutputPath)\$(AssemblyName).dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" />
</ItemGroup>
</Project>
動作確認用のプロジェクト
もう一つ、作成したSource Generatorの動作確認用のプロジェクトを用意しておきましょう。ConsoleAppなどの適当なプロジェクトを作成しておきます。
作成したらプロジェクトの参照を追加するのですが、ここでも追加の設定が必要になります。
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<!-- Source Generatorのプロジェクトのパス -->
<ProjectReference Include="..">
<OutputItemType>Analyzer</OutputItemType>
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
</ProjectReference>
</ItemGroup>
</Project>
また、追加でデバッグ用の設定も済ませておきましょう。Source Generator側のプロジェクトにProperties/launchSettings.json
を追加し、先ほど作成したConsoleAppのプロジェクトの参照を追加します。
{
"profiles": {
"Sample.SourceGenerator": {
"commandName": "DebugRoslynComponent",
"targetProject": "../../ConsoleApp1/ConsoleApp1.csproj"
}
}
}
これでConsoleAppのプロジェクトを参照した状態でSource Generatorをデバッグ実行させることができるようになります。これで準備は完了です。
Source Generatorの実装
ISourceGenerator
とIIncrementalGenerator
Source GeneratorにはISourceGenerator
とIIncrementalGenerator
がありますが、今回はIIncrementalGenerator
を利用します。
ISourceGenerator
はMicrosoft.CodeAnalysis.CSharp v3で登場した初期のSource Generatorです。当初はこれが用いられてきましたが、その構造上キャッシュが効かずコード変更時に毎回生成を行っていたため、パフォーマンスに問題がありました。
そこで登場したのがIncremental Source Generatorです。これはMicrosoft.CodeAnalysis.CSharp v4からIIncrementalGenerator
として提供されており、コードの細かい変更を検知してインクリメンタルにコード生成を行います。また、機能面においてもISourceGenerator
より多くの便利なAPIが用意されています。
少し前まではUnityのRoslynのバージョンの理由からISourceGenerator
を使うこともありましたが、今ではIncremental Source Generatorが利用できるので、もうIIncrementalGenerator
一択で良いでしょう。
IIncrementalGenerator
の実装
それでは、早速実装に進んでいきましょう。今回はサンプルとしてGenerateHelloAttribute
を付加したクラスにHello()
メソッドを追加するGeneratorを作成します。
まずは以下のようなIIncrementalGenerator
を実装したクラスを追加します。
using Microsoft.CodeAnalysis;
namespace HelloGenerator;
[Generator(LanguageNames.CSharp)]
public class HelloSourceGenerator : IIncrementalGenerator
{
public void Initialize(IncrementalGeneratorInitializationContext context)
{
}
}
定義したクラスにGenerator(LanguageNames.CSharp)
属性をつけることで、RoslynがSource Generatorとして認識可能になります。また、RoslynはいつでもこのGeneratorを作成・破棄できるため、フィールドやプロパティを持たせてはいけません。
初期化コードの生成
Source Generatorが登録された際の処理を追加するにはcontext.RegisterPostInitializationOutput()
を利用します。これはコード生成のマーカーとして利用する属性の生成などによく使われます。
ライブラリがソース生成以外のコードを含む場合は事前にライブラリ側に書いておけば良いですが、簡単なコードのみであれば全てRegisterPostInitializationOutput()
で生成してしまうと良いでしょう。
public void Initialize(IncrementalGeneratorInitializationContext context)
{
context.RegisterPostInitializationOutput(static initContext =>
{
// 名前の競合でエラーが起こることが多々あるので、明示的にglobal::からクラス名を書いておく
var source = $$"""
// <auto-generated/>
namespace HelloGenerator
{
[global::System.AttributeUsage(global::System.AttributeTargets.Class, AllowMultiple = false)]
internal sealed class GenerateHelloAttribute : global::System.Attribute
{
}
}
""";
// 第1引数に渡すhintNameはGeneratorごとに一意である必要がある
context.AddSource("GenerateHelloAttribute.g.cs", source);
});
}
Source Generatorによるコード生成はアセンブリ単位で行われます。そのため、初期化処理などで生成するコードのアクセス修飾子はinternal
にしておきましょう。
属性を生成するだけですが、これで既にSource Generatorとして動作するようになっています。ConsoleApp側で確認してみましょう。(動かない場合は一度IDEを再起動してください)
using HelloGenerator;
[GenerateHello] // GenerateHelloAttributeが生成されている
public partial class Foo
{
}
Roslynにおけるコード解析
続いてはコードを解析して必要な情報を抽出する処理ですが、実装に入る前に、少しだけRoslynについての解説を挟みます。
SyntaxNode
RoslynはC#をILに変換する作業を担いますが、最初に解析対象のC#コードから構文木(SyntaxTree
)を作成します。これはSyntaxNode
、SyntaxToken
、SyntaxTrivia
で構成されており、この構文木を解析することでソースコードの情報を取得できます。
要素 | 説明 |
---|---|
SyntaxNode |
抽象構文木の各ノードに対応。コードの解析には基本的にこれ(とSymbol)を用いる。 |
SyntaxToken |
字句解析時に作成されるトークン。 |
SyntaxTrivia |
コメントなどの実際のプログラムとは関係ない部分の情報。 |
Symbol
SyntaxNode
とは別に、コンパイル時の型情報はISymbol
として保持されています。こちらの方が多くの情報を持っているため、解析時にはまずSemanticModel
からSymbolを取得してから情報を抽出するのが基本になります。
CompilationとSemanticModel
コンパイルの情報はCompilation
というイミュータブルなオブジェクトにまとめて保存されます。これはソースコードの解析に必要なアセンブリやクラスなどの情報をもつSymbolが保持されています。
一方、Compilation
から情報を引き出すのに使うのがSemanticModel
です。これはSyntaxTree
に対応して作成されるもので、スコープ内におけるSymbolの情報を持っています。SemanticModel
にSyntaxNode
を渡すことで、対応するSymbolを取得することが可能です。
属性を解析する
それでは、実際にコードから属性を解析して取得する部分を実装していきましょう。Incremental Source GeneratorではIncrementalValuesProvider<T>
がパイプラインの中核を担っており、これらを組み合わせることで必要な構文の取得やコンパイル情報の解析などを行います。
とは言っても、Microsoft.CodeAnalysis.CSharp 4.3.0からはForAttributeWithMetadataName()
という専用のメソッドが用意されているため、属性の解析に関しては簡単に実装できます。
public void Initialize(IncrementalGeneratorInitializationContext context)
{
...
// 名前で指定された属性に対するIncrementalValuesProviderを作成する
var provider = context.SyntaxProvider.ForAttributeWithMetadataName(
"HelloGenerator.GenerateHelloAttribute", // 対象の属性のフルネーム
static (node, cancellationToken) => true, // predicate, 追加でフィルタリングを行いたければこのラムダ式で
static (context, cancellationToken) => context); // transform, GeneratorAttributeSyntaxContextから必要な情報に加工する(今回はcontextをそのまま利用する)
}
IncrementalValue(s)Provider<T>
のパイプラインを構築することで目的の構文を取得し、必要な情報に加工するのがIncremental Source Generatorのコード解析の基本です。今回はForAttributeWithMetadataName()
しか利用しませんでしたが、属性以外の構文を解析する際にはWhere()
やSelect()
などのオペレータで加工して独自のIncrementalValue(s)Provider<T>
を作成していくことになります。
IncrementalValuesProvider<T>
とIncrementalValueProvider<T>
の違いは、「一つ一つの値に対して個別に処理を行う」か「全ての値をまとめて処理を行う」かです。IncrementalValuesProvider<T>
の場合はIObservable<T>
のように複数の値が流れてきますが、IncrementalValueProvider<T>
の場合は単発でまとまった値が流れます。
今回の例では生成処理が独立していますが、そうでない場合は値をまとめて扱う必要が生じてきます。そのような場合はprovider.Collect()
で値をまとめる(IncrementalValuesProvider<T>
をIncrementalValueProvider<ImmutableArray<T>>
に変換する)ことが可能です。
コードを生成する
解析部分はIncrementalValueProvider<T>
によって比較的簡単に実装できますが、コード生成部分に関しては愚直に実装していく必要があります。
先ほど作成したIncrementalValueProvider<T>
に対してprovider.RegisterSourceOutput()
でコード生成の処理を追加できます。地道に書いていきましょう。
public void Initialize(IncrementalGeneratorInitializationContext context)
{
...
// 長くなるのでメソッドに分離しておくと良い
provider.RegisterSourceOutput(source, Emit);
}
static void Emit(SourceProductionContext context, GeneratorAttributeSyntaxContext attributeContext)
{
// GeneratorAttributeSyntaxContextから対象のSymbolとSyntaxNodeを取得できる
var typeSymbol = (INamedTypeSymbol)attributeContext.TargetSymbol;
var typeNode = (TypeDeclarationSyntax)attributeContext.TargetNode;
// namespace部分 (UnityのC# 9.0に対応する場合はfile scoped namespaceを使わない)
var nsBegin = typeSymbol.ContainingNamespace.IsGlobalNamespace
? ""
: $"namespace {typeSymbol.ContainingNamespace} \{";
var nsEnd = typeSymbol.ContainingNamespace.IsGlobalNamespace
? ""
: "}";
// 型をファイル名として利用するためのエスケープ処理
var fullType = typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)
.Replace("global::", "")
.Replace("<", "_")
.Replace(">", "_");
// 生成コードを作成
// auto-generatedタグや警告の抑止はテンプレ
var sourceCode = $$"""
// <auto-generated/>
#nullable enable
#pragma warning disable CS8600
#pragma warning disable CS8601
#pragma warning disable CS8602
#pragma warning disable CS8603
#pragma warning disable CS8604
{{nsBegin}}
partial class {{typeSymbol.Name}}
{
public void Hello()
{
global::System.Console.WriteLine("Hello, this is {{typeSymbol.Name}}!");
}
}
{{nsEnd}}
""";
// AddSourceでコードを生成
context.AddSource($"{fullType}.Hello.g.cs", sourceCode);
}
これでひとまずHelloGenerator
の実装は完了です。ConsoleApp側に戻って動作を確認してみましょう。クラスに[GenerateHello]
をつけると、コンパイル時に自動的にHello()
メソッドが生成されています。
using HelloGenerator;
var foo = new Foo();
foo.Hello(); // Hello, this is Foo!
[GenerateHello]
public partial class Foo
{
}
コンパイルエラーを作成する
一応ここまでで既に動作するSource Generatorは作成できましたが、これは不完全なコードに対するエラーが親切ではありません。partial
キーワードがなかったり、Hello()
メソッドが既に定義されている場合はコンパイルエラーを出力してあげるとユーザーが使い方を理解しやすくなります。また、型がネストされている場合は生成の対応が難しいので、その場合もエラーにしてしまいましょう。
RoslynではDiagnosticDescriptor
を使って独自の警告やコンパイルエラーを作成できます。量が多くなるので、適当なstaticクラスにまとめておくと良いでしょう。
public static class DiagnosticDescriptors
{
public const string Id = "HELLO"
public const string Category = "HelloGenerator";
public static readonly DiagnosticDescriptor MustBePartial = new(
id: Id + "001",
title: "GenerateHello class must be partial",
messageFormat: "The GenerateHello class '{0}' must be partial",
category: Category,
defaultSeverity: DiagnosticSeverity.Error,
isEnabledByDefault: true);
public static readonly DiagnosticDescriptor MustNotBeNested = new(
id: Id + "002",
title: "GenerateHello class must not be nested",
messageFormat: "The GenerateHello class '{0}' must not be nested",
category: Category,
defaultSeverity: DiagnosticSeverity.Error,
isEnabledByDefault: true);
public static readonly DiagnosticDescriptor ExistsHello = new(
id: Id + "003",
title: "Hello method",
messageFormat: "The GenerateHello class '{0}' has Hello method but it is not allowed",
category: Category,
defaultSeverity: DiagnosticSeverity.Error,
isEnabledByDefault: true);
}
そして、先ほど作成したEmit
メソッド内でエラーの出力処理を行います。条件をチェックし、context.ReportDiagnostic()
でエラーを報告します。エラーを出力する場所はSyntaxNodeのGetLocation()
から取得できるLocation
を引数に渡すことで指定します。
static void Emit(SourceProductionContext context, GeneratorAttributeSyntaxContext attributeContext)
{
var typeSymbol = (INamedTypeSymbol)attributeContext.TargetSymbol;
var typeNode = (TypeDeclarationSyntax)attributeContext.TargetNode;
var hasError = false;
// partialキーワードがない場合
if (!typeNode.Modifiers.Any(m => m.IsKind(SyntaxKind.PartialKeyword)))
{
context.ReportDiagnostic(Diagnostic.Create(
DiagnosticDescriptors.MustBePartial,
typeNode.Identifier.GetLocation(),
typeSymbol.Name));
hasError = true;
}
// 型がネストされている場合
if (typeNode.Parent is TypeDeclarationSyntax)
{
context.ReportDiagnostic(Diagnostic.Create(
DiagnosticDescriptors.MustNotBeNested,
typeNode.Identifier.GetLocation(),
typeSymbol.Name));
hasError = true;
}
// Helloメソッドが既に定義されている場合
if (typeSymbol.GetMembers("Hello").Length != 0)
{
context.ReportDiagnostic(Diagnostic.Create(
DiagnosticDescriptors.ExistsHello,
typeNode.Identifier.GetLocation(),
typeSymbol.Name));
hasError = true;
}
// エラーがあるなら生成を中止
if (hasError)
{
return;
}
...
}
これでサンプルのHelloGenerator
は完成です。
組み込みのProvider
先ほどのサンプルではcontext.SyntaxProvider
を利用しましたが、他にも様々なProviderが用意されています。順番に見ていきましょう。
Provider同士を組み合わせたい場合はCombine()
を利用します。これを用いることでIncrementalValuesProvider<TLeft>
とIncrementalValuesProvider<TRight>
をIncrementalValuesProvider<(TLeft, TRight)>
に変換できます。
var attributeProvider = context.SyntaxProvider
.ForAttributeWithMetadataName(...);
// CompilationProvider(後述)と組み合わせる
var provider = attributeProvider.Combine(context.CompilationProvider);
AdditionalTextsProvider
AdditionalTextsProviderはAdditionalFilesを読み取るためのProviderです。Source Generatorに何らかの構成ファイルを渡したいときに便利です。
var config = context.AdditionalTextsProvider.Select((text, ct) =>
{
if (text.Path.EndsWith("Sample.json")) return text.GetText(ct);
return null;
})
.Where(x => x != null)
.Collect();
AnalyzerConfigOptionsProvider
.csproj
やDirectory.Build.props
などの設定を読み込むためのProviderです。
例えば、以下のようなプロパティが設定してあったとして…
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<SampleSourceGenerator_Experimental>enable</SampleSourceGenerator_Experimental>
</PropertyGroup>
<ItemGroup>
<CompilerVisibleProperty Include="SampleSourceGenerator_Experimental" />
</ItemGroup>
これを以下のように読み込むことができます。
var config = context.AnalyzerConfigOptionsProvider
.Select((options, _) =>
{
// 接頭辞にbuild_property.が必要なことに注意
var experimentalEnabled = options.GlobalOptions.TryGetValue("build_property.SampleSourceGenerator_Experimental", out var experimentalEnabledValue)
&& experimentalEnabledValue == "enable";
...
});
CompilationProvider
前述のCompilation
を取得するためのProviderです。Compilation
からは多くの情報を取得できるため、よく他のProviderとCombine()
で組み合わせて利用します。
MetadataReferencesProvider
参照しているdllの一覧を取得できるProviderです。これを活用すると、特定のライブラリを参照している場合に追加でコードを生成するなどの処理を行うことが可能です。
var hasDependencyInjection = context.MetadataReferencesProvider
.Collect()
.Select((xs, _) =>
{
var hasDependencyInjection = false;
foreach (var x in xs)
{
var name = x.Display;
if (name == null) continue;
if (!hasDependencyInjection && name.EndsWith("Microsoft.Extensions.DependencyInjection.dll"))
{
hasDependencyInjection = true;
continue;
}
// etc...
}
...
});
ParseOptionsProvider
言語バージョンやプリプロセッサシンボルなど、.csproj
を解析した情報を取得できます。これは言語バージョンやランタイムのバージョンによって生成コードを分岐させたい場合などに便利です。
var parseOptions = context.ParseOptionsProvider.Select((parseOptions, ct) =>
{
var csOptions = (CSharpParseOptions)parseOptions;
var langVersion = csOptions.LanguageVersion;
var net7 = csOptions.PreprocessorSymbolNames.Contains("NET7_0_OR_GREATER");
return (langVersion, net7);
});
SyntaxProvider
SyntaxProviderはForAttributeWithMetadataName()
やCreateSyntaxProvider()
など、構文ベースの解析を行う際に便利なメソッドが用意されたProviderです。属性の解析にはForAttributeWithMetadataName()
を使うのが手軽ですが、それ以外の構文を解析したい場合はCreateSyntaxProvider()
も便利です。
var runMethod = context.SyntaxProvider
.CreateSyntaxProvider((node, ct) =>
{
// メソッド呼び出しを解析 (InvocationExpression)
if (node.IsKind(SyntaxKind.InvocationExpression))
{
var invocationExpression = (node as InvocationExpressionSyntax);
if (invocationExpression == null) return false;
var expr = invocationExpression.Expression as MemberAccessExpressionSyntax;
// App.Run()メソッド呼び出しのみを取得
if ((expr?.Expression as IdentifierNameSyntax)?.Identifier.Text == "App")
{
var methodName = expr?.Name.Identifier.Text;
if (methodName is "Run")
{
return true;
}
}
return false;
}
return false;
}, (context, ct) => context);
注意としてpredicate
のラムダ式は高頻度で呼び出されるため、パフォーマンスを低下させるような処理は含めないでください。ToString()
などのアロケーションも避けるべきです。
最適化
Incremental Source GeneratorはIncrementalValuesProvider<T>
に流れる値を都度チェックし、同一であれば処理をスキップすることでインクリメンタルなソース生成を実現しています。しかし、この部分の等価判定が正しく実装されていないと毎回計算が走ることになり、非効率的なSource Generatorになってしまいます。
Source Generator自体が多少遅くともそこまで問題はないことが多いですが、パフォーマンスが良いに越したことはありません。この辺りもしっかり作り込んでおきましょう。
よほど簡易的なSource Generatorでない限り、基本的には既存のProviderから取得した値をSelect()
などで独自のクラスにまとめてから生成処理に渡すことになるでしょう。このとき、可能な限りプリミティブ型だけで構成されたrecord
に変換しておくと、等価判定を自前で実装する必要がないのでかなり楽になります。
// こんな感じで、必要なデータを単一のrecordにまとめておく
record MethodData
{
public required bool IsStatic { get; init; }
public required bool IsAsync { get; init; }
public required bool HasReturnValue { get; init; }
...
}
またCompilation
やISymbol
を抱えておく場合、これらは毎回異なるオブジェクトになるため、そのまま==
では判定できないことに注意してください。この場合は独自の等価判定を実装します。
比較に利用するIEqualityComparer<T>
をProviderの外部からWithComparer()
で追加してあげることも可能です。
var provider = context.SyntaxProvider
.ForAttributeWithMetadataName(
"SampleAttribute",
static (node, ct) => true,
static (context, ct) => context)
.Combine(context.CompilationProvider)
.WithComparer(Comparer.Instance);
また、ソース生成の量が非常に多い場合はRegisterImplementationSourceOutput()
の利用を検討することもできます。これはビルド時にのみ生成を行うためのAPIで、エディタ上では生成をスキップしてくれます。ただし、当然ながらエディタ側ではコードが一切生成されなくなるため、IDEでは生成部分が一切認識されなくなることに注意してください。
まとめ
今回はIncremental Source Generatorについて初歩的な部分から実践的な部分まで解説してみました。私も時々ライブラリでSource Generatorを書くことがあるのですが、あまりにも日本語の情報が少ないので…
これ以上の情報が知りたければ、実際のSource Generatorのコードを読んでみることが一番だと思います。MessagePack-CSharpやConsoleAppFrameworkあたりのコードは非常に綺麗に書かれているので参考にしてみると良いでしょう。また、これらのライブラリを手がけているneueccさんの2022年(2024年)のC# Incremental Source Generator開発手法という記事が非常にわかりやすいのでおすすめです。(この記事を書く上でも参考にさせていただいてます)
というわけでIncremental Source Generator、使いこなせると非常に強力なツールなので是非とも覚えて使っていきましょう。