INotifyPropertyChanged 用のソースジェネレーターを書く方法
C# 14 と .NET 11 で INotifyPropertyChanged 用の独自のインクリメンタルなソースジェネレーターを構築する完全ガイドです。IIncrementalGenerator パイプライン、マーカー属性、partial class 出力、SetProperty パターン、AOT 対応を保つ方法を扱います。
INotifyPropertyChanged (INPC) を自分でソース生成するには、カスタム属性でマークされたクラスを見つけ、その [ObservableProperty] 注釈付きフィールドを読み、インターフェースを実装し、ラッパープロパティを公開し、SetProperty ヘルパーを通じて PropertyChanged を発火させる partial class を出力する IIncrementalGenerator を書きます。ジェネレーターはコンパイル時に走り、標準の INPC 配線を超える実行時コストはゼロで、手書きのバッキングフィールドとセッターのボイラープレートをすべて取り除きます。本ガイドは .NET 11 (preview 3) と C# 14 上でジェネレーターをエンドツーエンドで構築しますが、同じコードはアナライザーが netstandard2.0 をターゲットとする限り、どんなコンシューマーに対しても動作します。これは依然として Roslyn がソースジェネレーターに要求する契約だからです。
CommunityToolkit.Mvvm がある中で自分で書く理由
よく知られた答えは CommunityToolkit.Mvvm で、[ObservableObject]、[ObservableProperty]、[NotifyPropertyChangedFor] と、よくテストされた小さな山ほどのジェネレーターを同梱しています。たいていのアプリではそれを採用してください。本ガイドはそれが採用できないケースのためのものです。
- 国内フレームワーク由来の
IObservableObjectや、ベンダー固有の通知契約のような、別のインターフェースを出力するジェネレーターが必要。 - ツールキットがカバーしない追加の振る舞い (監査ログ、ダーティトラッキング、ドメインルール経由の強制) と INPC を組み合わせたい。
- 学習用の成果物、社内ハウスフレームワーク、または
CommunityToolkit.Mvvmと並存しつつ属性名で衝突しないジェネレーターを構築している。 - 信頼する前にツールキットを理解したい。
ソースジェネレーターはまた、Roslyn API を一次情報として触れるもっとも清潔な場所のひとつであり、INPC は「小さく、明確に定義され、レバレッジが高い」典型的なターゲットです。一度も書いたことがないなら、依存性注入の登録コードや EF Core の構成を生成しようとするより、ここから始める方が良いです。
提供する必要があるピース
完全な INPC ジェネレーターは三つの部分から成り、それぞれ独自のプロジェクトまたは <None> インジェクションに置きます。
- コンシューマーが
partial classに適用する マーカー属性。慣例:[Observable]または[GenerateInpc]。 - ジェネレーターがプロパティとして公開すべき基礎状態をマークする フィールドレベルの属性。慣例:
[ObservableProperty]。 - インクリメンタルジェネレーター 本体。MSBuild がアナライザーとして読み込めるようにパッケージ化します。
マーカー属性は RegisterPostInitializationOutput 経由で配るのがもっとも簡単で、これによりジェネレーターはコンシューマーのコンパイルに属性のソースを注入できます。これによりコンシューマーは <ProjectReference> (または OutputItemType="Analyzer" 付きの <PackageReference>) を追加するだけで、別途のランタイム DLL なしに即座に属性を利用できます。
プロジェクトレイアウト
アナライザープロジェクトは netstandard2.0 をターゲットにする必要があります。これは IDE 内および古い Visual Studio インストールが使う .NET Framework の MSBuild で Roslyn が読み込む唯一の TFM だからです。
<!-- src/Inpc.SourceGenerator/Inpc.SourceGenerator.csproj -->
<!-- .NET 11 SDK, generator targets netstandard2.0 -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<IsRoslynComponent>true</IsRoslynComponent>
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp"
Version="4.13.0"
PrivateAssets="all" />
</ItemGroup>
</Project>
IsRoslynComponent により、Visual Studio はこれをデザイン時の読み込み用ジェネレーターとして扱います。EnforceExtendedAnalyzerRules は、再現性が重要なジェネレーター内部での string.Format のカルチャ問題のような誤りをフラグするアナライザースタイルのルールセットです。
コンシューマープロジェクトはアナライザーとして参照します。
<!-- consumer .csproj -->
<ItemGroup>
<ProjectReference Include="..\Inpc.SourceGenerator\Inpc.SourceGenerator.csproj"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false" />
</ItemGroup>
ReferenceOutputAssembly="false" は重要です。アナライザー DLL をコンシューマーのランタイムパスに含めたく ありません。これを忘れると、コンシューマーは Roslyn を実行時に出荷することになり、数メガバイトの死荷重となり、Native AOT を壊します。
post-init で注入されるマーカー属性
ジェネレーター内部で、解析が走る前に属性ソースを登録します。これによりコンシューマーは別パッケージなしに属性を利用できることが保証されます。
// .NET 11, C# 14, generator-side code (netstandard2.0)
using Microsoft.CodeAnalysis;
[Generator]
public sealed class InpcGenerator : IIncrementalGenerator
{
private const string AttributeSource = """
// <auto-generated/>
#nullable enable
namespace Inpc;
[global::System.AttributeUsage(
global::System.AttributeTargets.Class, Inherited = false)]
internal sealed class ObservableAttribute : global::System.Attribute { }
[global::System.AttributeUsage(
global::System.AttributeTargets.Field, Inherited = false)]
internal sealed class ObservablePropertyAttribute : global::System.Attribute
{
public string? PropertyName { get; init; }
}
""";
public void Initialize(IncrementalGeneratorInitializationContext context)
{
context.RegisterPostInitializationOutput(ctx =>
ctx.AddSource("Inpc.Attributes.g.cs", AttributeSource));
// pipeline registration follows in the next section
}
}
自明でない選択をいくつか挙げます。
- 属性は
internalです。各コンシューマーアセンブリは post-init 経由で自身のコピーを取得します。これにより、二つのアセンブリはTypeForwardedToの小細工やバージョン競合なしに[Observable]を使用できます。コストとして属性はアセンブリ境界を越えて生き残れませんが、ジェネレーターはコンパイル時のみそれらを必要とするため問題ありません。 - すべての型参照に
global::プレフィックスを使用します。生成されたコードは任意の名前空間に着地します。たまたまSystemやInpcという名前のものも含まれます。global::がないと名前解決が誤った型を選び、生成ファイルがコンパイルできなくなります。 - ヘッダーコメント
// <auto-generated/>はEditorConfigルールや StyleCop からのアナライザー警告を抑制します。
インクリメンタルパイプライン
実際の解析を配線します。Roslyn のインクリメンタルジェネレーター API は二つの半分から成ります。キーストロークごとに安価な構文フィルタリングを行う SyntaxProvider と、構文スナップショットが変化したときにのみ高価なセマンティック作業を行う変換です。
// .NET 11, C# 14, generator-side
public void Initialize(IncrementalGeneratorInitializationContext context)
{
context.RegisterPostInitializationOutput(ctx =>
ctx.AddSource("Inpc.Attributes.g.cs", AttributeSource));
var classes = context.SyntaxProvider
.ForAttributeWithMetadataName(
"Inpc.ObservableAttribute",
predicate: static (node, _) => node is ClassDeclarationSyntax c
&& c.Modifiers.Any(SyntaxKind.PartialKeyword),
transform: static (ctx, ct) => Extract(ctx, ct))
.Where(static x => x is not null)
.Select(static (x, _) => x!.Value);
context.RegisterSourceOutput(classes,
static (spc, model) => Emit(spc, model));
}
ForAttributeWithMetadataName は Roslyn 4.3 以降、属性駆動のあらゆるジェネレーターにとって正しい入口です。コンパイラの属性インデックスを使うため、predicate は一致する属性名をすでに持つ構文に対してのみ走ります。これは古い CreateSyntaxProvider プラス Where パターンより劇的に安価で、利用可能なパフォーマンス向上として最大の単体ゲインです。
predicate は、セマンティックモデルが存在する前の構文レベルで partial を強制します。これにより、最も一般的なコンシューマーのミス (partial を忘れる) を最も安価なチェックで捕捉できます。
安定したモデルの抽出
変換は構造的に比較可能な値を返さなければなりません。Roslyn のキャッシュ層は実行間でモデル値を比較し、何も変わっていなければ再出力をスキップします。シンボル (INamedTypeSymbol、IFieldSymbol) を返すと、シンボルは単一のコンパイレーション内でのみ参照等価なので、キーストロークごとにキャッシュが無効化されます。
プレーンな文字列の record (または readonly record struct) を使います。
// .NET 11, C# 14, generator-side
internal readonly record struct ClassModel(
string Namespace,
string ClassName,
EquatableArray<PropertyModel> Properties);
internal readonly record struct PropertyModel(
string FieldName,
string PropertyName,
string TypeName);
EquatableArray<T> は構造的 Equals を実装する ImmutableArray<T> の薄いラッパーです。Roslyn は同梱しませんが、すべてのジェネレータープロジェクトはツールキットから同じ六行をコピーします。
// .NET 11, C# 14, generator-side
internal readonly record struct EquatableArray<T>(ImmutableArray<T> Items)
: IEnumerable<T> where T : IEquatable<T>
{
public bool Equals(EquatableArray<T> other) =>
Items.AsSpan().SequenceEqual(other.Items.AsSpan());
public override int GetHashCode()
{
var hash = new HashCode();
foreach (var item in Items) hash.Add(item);
return hash.ToHashCode();
}
public IEnumerator<T> GetEnumerator() =>
((IEnumerable<T>)Items).GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() =>
((IEnumerable)Items).GetEnumerator();
}
これを忘れて生の ImmutableArray<T> を返すことは、CreateSyntaxProvider の誤用に次いで多いジェネレーターのパフォーマンスバグです。ImmutableArray<T>.Equals は参照ベースなので、すべてのスナップショットが新しく見えます。
実際の Extract 関数はシンボルからフィールドを引き出します。
// .NET 11, C# 14, generator-side
private static ClassModel? Extract(
GeneratorAttributeSyntaxContext ctx,
CancellationToken ct)
{
if (ctx.TargetSymbol is not INamedTypeSymbol type) return null;
var properties = ImmutableArray.CreateBuilder<PropertyModel>();
foreach (var member in type.GetMembers())
{
ct.ThrowIfCancellationRequested();
if (member is not IFieldSymbol field) continue;
var attr = field.GetAttributes().FirstOrDefault(a =>
a.AttributeClass?.ToDisplayString() == "Inpc.ObservablePropertyAttribute");
if (attr is null) continue;
string property = attr.NamedArguments
.FirstOrDefault(kv => kv.Key == "PropertyName")
.Value.Value as string
?? Capitalize(field.Name.TrimStart('_'));
properties.Add(new PropertyModel(
FieldName: field.Name,
PropertyName: property,
TypeName: field.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)));
}
return new ClassModel(
Namespace: type.ContainingNamespace.IsGlobalNamespace
? string.Empty
: type.ContainingNamespace.ToDisplayString(),
ClassName: type.Name,
Properties: new EquatableArray<PropertyModel>(properties.ToImmutable()));
}
private static string Capitalize(string name) =>
name.Length == 0 ? name : char.ToUpperInvariant(name[0]) + name[1..];
SymbolDisplayFormat.FullyQualifiedFormat は global::System.Collections.Generic.List<global::Foo.Bar> 形式の名前を生成し、出力ファイルが他の方法で衝突しうる名前空間解決の問題をすべて回避します。
ループ内の ct.ThrowIfCancellationRequested() は予想以上に重要です。IDE はユーザーがタイプするにつれて積極的にジェネレーターの実行をキャンセルします。トークンを無視するジェネレーターは IntelliSense をブロックします。
partial class の出力
出力ステップは単一の StringBuilder ウォークです。ジェネレーターは美しく見えて遅く動く Roslyn.SyntaxFactory ベースのビルダーに育ちがちですが、これほど規則的なコードには文字列テンプレートで十分で、デバッグもはるかに容易です。
// .NET 11, C# 14, generator-side
private static void Emit(SourceProductionContext ctx, ClassModel model)
{
var sb = new StringBuilder(1024);
sb.AppendLine("// <auto-generated/>");
sb.AppendLine("#nullable enable");
if (model.Namespace.Length > 0)
{
sb.Append("namespace ").Append(model.Namespace).AppendLine(";");
sb.AppendLine();
}
sb.Append("partial class ").Append(model.ClassName)
.AppendLine(" : global::System.ComponentModel.INotifyPropertyChanged");
sb.AppendLine("{");
sb.AppendLine(" public event global::System.ComponentModel.PropertyChangedEventHandler? PropertyChanged;");
sb.AppendLine();
sb.AppendLine(" private bool SetProperty<T>(ref T storage, T value, string propertyName)");
sb.AppendLine(" {");
sb.AppendLine(" if (global::System.Collections.Generic.EqualityComparer<T>.Default.Equals(storage, value))");
sb.AppendLine(" return false;");
sb.AppendLine(" storage = value;");
sb.AppendLine(" PropertyChanged?.Invoke(this,");
sb.AppendLine(" new global::System.ComponentModel.PropertyChangedEventArgs(propertyName));");
sb.AppendLine(" return true;");
sb.AppendLine(" }");
sb.AppendLine();
foreach (var p in model.Properties)
{
sb.Append(" public ").Append(p.TypeName).Append(' ').Append(p.PropertyName)
.AppendLine();
sb.AppendLine(" {");
sb.Append(" get => this.").Append(p.FieldName).AppendLine(";");
sb.Append(" set => SetProperty(ref this.").Append(p.FieldName)
.Append(", value, nameof(").Append(p.PropertyName).AppendLine("));");
sb.AppendLine(" }");
sb.AppendLine();
}
sb.AppendLine("}");
string hint = string.IsNullOrEmpty(model.Namespace)
? $"{model.ClassName}.Inpc.g.cs"
: $"{model.Namespace}.{model.ClassName}.Inpc.g.cs";
ctx.AddSource(hint, sb.ToString());
}
注目に値する点。
SetPropertyは変更ごとに新しいPropertyChangedEventArgsをアロケートします。これは典型的な UI ワークロードでは許容範囲です。高頻度のストリーム (ゲーム状態、センサーデータ) を INPC にバインドする場合は、プロパティごとにPropertyChangedEventArgsを静的フィールドにキャッシュしてください。ツールキットの[ObservableProperty]はオプトイン時にこれを行います。- ヒント名 (
AddSourceの最初の引数) はコンパイレーション内で一意である必要があります。名前空間を含めることで、異なる名前空間の二つのクラスが同じ名前を共有するときの衝突を防ぎます。 EqualityComparer<T>.Defaultは参照型のnullを正しく扱い、値型プロパティにも正しい比較演算子です。==を使うとユーザー定義の等価性をショートサーキットしてしまいます。
コンシューマーコード
すべての訓練の本旨です。
// .NET 11, C# 14, consumer code
using Inpc;
[Observable]
public partial class PersonViewModel
{
[ObservableProperty]
private string _firstName = "";
[ObservableProperty]
private string _lastName = "";
[ObservableProperty(PropertyName = "Age")]
private int _ageYears;
}
ジェネレーターはパブリックな FirstName、LastName、Age プロパティ、PropertyChanged イベント、SetProperty ヘルパーを出力します。コンシューマーファイルは上記のままで残り、OnPropertyChanged 配線も歩調を合わせるバッキングフィールドもありません。
Native AOT とトリミング
ジェネレーターはビルド時に走るので、実行時には何も支払いません。興味深い問いは、AOT またはトリミングされたアプリで 生成された コードが何を要するかです。
INotifyPropertyChangedはトリマーによってデータバインディング契約の一部として認識されます。インターフェースとPropertyChangedイベントは観測可能型から削除されません。EqualityComparer<T>.Defaultは完全にトリム安全かつ AOT 安全で、リフレクションを使いません。PropertyChangedEventArgsのコンストラクタは、イベントのシグネチャがそれをルートとするため、トリミングされません。
注意すべきは XAML バインディングです。WPF と Avalonia は INPC プロパティを発見するためにリフレクションを使うので、それらのフレームワーク向けのトリム構成はすでにディスクリプタ経由で観測可能な view-model 型をトリミングからオプトアウトしています。MAUI のコンパイル済みバインディングはその必要性を完全に取り除き、両方の世界が欲しければこのようなジェネレーターは [BindableProperty] スタイルのコード生成と自然に組み合わさります。
つまずきポイント、頻度順
- クラスに
partialを忘れる:predicateがそれを除外し、何も生成されません。コンシューマーは「定義が見つからない」または未実装インターフェースエラーを見て、ジェネレーターが壊れていると思います。Where(x => x is null)分岐のRegisterSourceOutput経由で親切なメッセージを表示する診断を predicate パスに追加してください。 - 変換からシンボルを返す: インクリメンタル性を殺します。キーストロークごとに再変換と再出力が起きます。ジェネレーターは単一クラスの再現では「十分速い」ように見え、本物のソリューションでは這い回ります。
- 出力する型名で
global::を忘れる: コンシューマーのSystem.Fooという名前空間がSystemを遮蔽し、生成ファイルはその一つのプロジェクトでコンパイルに失敗し、ジェネレータープロジェクト自体にはエラーが出ません。常に完全修飾してください。 - 属性を別のランタイム DLL で出力する: 可能ですが、post-init の注入の方がシンプルで、アナライザーとランタイム契約の間で NuGet バージョンドリフトのリスクを避けられます。
_プレフィックス慣例を扱わない:string _firstNameは_FirstNameではなくFirstNameを生成すべきです。Capitalize(name.TrimStart('_'))ステップが標準慣例を扱います。どんな慣例を選ぶにせよ文書化してください。- 重複するヒント名の生成: 二つの名前空間からの
AddSource("Class.g.cs", ...)は衝突します。常に名前空間をヒントに含めてください。
このように構築されたジェネレーターはおよそ 200 行のコードで、変更ごとにマイクロ秒単位で走り、コンシューマーごとに数百行の手書きボイラープレートを置き換えます。一つ出荷したら、次のもの (コマンド、依存性注入の登録、状態機械) は同じ骨格のコピーです。
関連
- System.Text.Json でカスタム JsonConverter を書く方法 — 似た落とし穴を持つもう一つの「Roslyn 隣接の小さな拡張点」。
- C# で BlockingCollection の代わりに Channels を使う方法 — view-model と組み合わせる非同期パターン。
- ASP.NET Core 最小 API で Native AOT を使う方法 — trim と AOT が生成されたコードをどう見るか。
- ASP.NET Core 11 でグローバル例外フィルターを追加する方法 — 生成されたボイラープレートと組み合わされることが多いもう一つのパターン。
ソース
- MS Learn: Source generators overview
- Roslyn cookbook: Incremental generators
- Roslyn API:
IIncrementalGenerator,ForAttributeWithMetadataName - CommunityToolkit.Mvvm reference implementation: CommunityToolkit/dotnet on GitHub
Comments
Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.