Start Debugging

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] と、よくテストされた小さな山ほどのジェネレーターを同梱しています。たいていのアプリではそれを採用してください。本ガイドはそれが採用できないケースのためのものです。

ソースジェネレーターはまた、Roslyn API を一次情報として触れるもっとも清潔な場所のひとつであり、INPC は「小さく、明確に定義され、レバレッジが高い」典型的なターゲットです。一度も書いたことがないなら、依存性注入の登録コードや EF Core の構成を生成しようとするより、ここから始める方が良いです。

提供する必要があるピース

完全な INPC ジェネレーターは三つの部分から成り、それぞれ独自のプロジェクトまたは <None> インジェクションに置きます。

  1. コンシューマーが partial class に適用する マーカー属性。慣例: [Observable] または [GenerateInpc]
  2. ジェネレーターがプロパティとして公開すべき基礎状態をマークする フィールドレベルの属性。慣例: [ObservableProperty]
  3. インクリメンタルジェネレーター 本体。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
    }
}

自明でない選択をいくつか挙げます。

インクリメンタルパイプライン

実際の解析を配線します。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 のキャッシュ層は実行間でモデル値を比較し、何も変わっていなければ再出力をスキップします。シンボル (INamedTypeSymbolIFieldSymbol) を返すと、シンボルは単一のコンパイレーション内でのみ参照等価なので、キーストロークごとにキャッシュが無効化されます。

プレーンな文字列の 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.FullyQualifiedFormatglobal::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());
}

注目に値する点。

コンシューマーコード

すべての訓練の本旨です。

// .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;
}

ジェネレーターはパブリックな FirstNameLastNameAge プロパティ、PropertyChanged イベント、SetProperty ヘルパーを出力します。コンシューマーファイルは上記のままで残り、OnPropertyChanged 配線も歩調を合わせるバッキングフィールドもありません。

Native AOT とトリミング

ジェネレーターはビルド時に走るので、実行時には何も支払いません。興味深い問いは、AOT またはトリミングされたアプリで 生成された コードが何を要するかです。

注意すべきは XAML バインディングです。WPF と Avalonia は INPC プロパティを発見するためにリフレクションを使うので、それらのフレームワーク向けのトリム構成はすでにディスクリプタ経由で観測可能な view-model 型をトリミングからオプトアウトしています。MAUI のコンパイル済みバインディングはその必要性を完全に取り除き、両方の世界が欲しければこのようなジェネレーターは [BindableProperty] スタイルのコード生成と自然に組み合わさります。

つまずきポイント、頻度順

このように構築されたジェネレーターはおよそ 200 行のコードで、変更ごとにマイクロ秒単位で走り、コンシューマーごとに数百行の手書きボイラープレートを置き換えます。一つ出荷したら、次のもの (コマンド、依存性注入の登録、状態機械) は同じ骨格のコピーです。

関連

ソース

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< 戻る