【C#】プロパティ変更通知パターンでガベージを出さない工夫

# はじめに

C# では、オブジェクトのプロパティ値が変更されたことをクライアントに通知する汎用的なパターンがあります。

[`System.ComponentModel.INotifyPropertyChanged` インターフェイス](https://learn.microsoft.com/ja-jp/dotnet/api/system.componentmodel.inotifypropertychanged?view=net-9.0)

```cs
class Notifier : INotifyPropertyChanged
{
    public int Sample
    {
        get => field;
        set
        {
            if (field == value) return;
            field = value;
            this.PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Sample)));
        }
    }

    public event PropertyChangedEventHandler? PropertyChanged;
}
```
`PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Sample)))` に着目すると、プロパティが変更されるたびに毎回イベント引数が作成されています。`PropertyChangedEventArgs` は読み取り専用クラスなのでキャッシュしても問題なく、ここに工夫の余地がありそうです。

またプロパティごとに ↑ のような決まり文句のコード（ボイラープレート）を書くのも億劫なので、処理をまとめたいところです。

今回は、これを実装する際にパフォーマンスがいいやり方を模索しようと思います。

## サンプルコード

<details>
<summary>テストコード</summary>

```cs
using System.ComponentModel;
using System.Runtime.CompilerServices;
using Xunit;

file class Notifier : INotifyPropertyChanged
{
    public int Sample
    {
        get => field;
        set
        {
            if (field == value) return;
            field = value;
            this.PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Sample)));
        }
    }

    public int Value1 { get => field; set => SetValue1(ref field, value); }

    private static PropertyChangedEventArgs? _value2Args;
    public int Value2 { get => field; set => SetValue2(ref field, value, ref _value2Args); }

    private static readonly PropertyChangedEventArgs _value3Args = new(nameof(Value3));
    public int Value3 { get => field; set => SetValue3(ref field, value, _value3Args); }

    private static readonly System.Collections.Concurrent.ConcurrentDictionary<string, PropertyChangedEventArgs> Args = [];
    public int Value4 { get => field; set => SetValue4(ref field, value); }

    public event PropertyChangedEventHandler? PropertyChanged;

    private void SetValue1<T>(ref T field, T value, [CallerMemberName] string propertyName = "")
    {
        if (EqualityComparer<T>.Default.Equals(field, value)) return;
        field = value;
        this.PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
    }

    private void SetValue2<T>(ref T field, T value, ref PropertyChangedEventArgs? args, [CallerMemberName] string propertyName = "")
    {
        if (EqualityComparer<T>.Default.Equals(field, value)) return;
        field = value;
        this.PropertyChanged?.Invoke(this, args ??= new PropertyChangedEventArgs(propertyName));
    }

    private void SetValue3<T>(ref T field, T value, PropertyChangedEventArgs args)
    {
        if (EqualityComparer<T>.Default.Equals(field, value)) return;
        field = value;
        this.PropertyChanged?.Invoke(this, args);
    }

    private void SetValue4<T>(ref T field, T value, [CallerMemberName] string propertyName = "")
    {
        if (EqualityComparer<T>.Default.Equals(field, value)) return;
        field = value;
        if (this.PropertyChanged is PropertyChangedEventHandler handler)
        {
            PropertyChangedEventArgs args;
            if (!Args.TryGetValue(propertyName, out args!))
                Args[propertyName] = args = new PropertyChangedEventArgs(propertyName);
            handler(this, args);
        }
    }
}

public class __NotifierTest
{
    [Fact]
    void PropertyChanged()
    {
        var notifier = new Notifier();
        var value1Count = 0;
        var value2Count = 0;
        var value3Count = 0;
        var value4Count = 0;
        notifier.PropertyChanged += (_, e) =>
        {
            switch (e.PropertyName)
            {
                case nameof(Notifier.Value1):
                    ++value1Count;
                    break;
                case nameof(Notifier.Value2):
                    ++value2Count;
                    break;
                case nameof(Notifier.Value3):
                    ++value3Count;
                    break;
                case nameof(Notifier.Value4):
                    ++value4Count;
                    break;
                default:
                    throw new InvalidOperationException();
            }
        };

        notifier.Value1 = 1;
        Assert.Equal(1, notifier.Value1);
        Assert.Equal(1, value1Count);

        notifier.Value2 = 2;
        Assert.Equal(2, notifier.Value2);
        Assert.Equal(1, value2Count);

        notifier.Value3 = 3;
        Assert.Equal(3, notifier.Value3);
        Assert.Equal(1, value3Count);

        notifier.Value4 = 4;
        Assert.Equal(4, notifier.Value4);
        Assert.Equal(1, value4Count);
    }

    static void DummyTest(Performance p)
    {
        var callCount = 0;
        p.AddTest("Dummy", () =>
        {
            ++callCount;
        });
    }

    static void PerformanceTest(Performance p)
    {
        var notifier = new Notifier();
        var callCount = 0;
        notifier.PropertyChanged += (_, _) => callCount++;

        p.AddTest("SetValue1", () =>
        {
            ++notifier.Value1;
        });

        p.AddTest("SetValue2", () =>
        {
            ++notifier.Value2;
        });

        p.AddTest("SetValue3", () =>
        {
            ++notifier.Value3;
        });

        p.AddTest("SetValue4", () =>
        {
            ++notifier.Value4;
        });
    }
}
```

</details>

## 実装案

### ジェネリックを使用したパターン

```cs
int Value1 { get => field; set => SetValue1(ref field, value); }

void SetValue1<T>(ref T field, T value, [CallerMemberName] string propertyName = "")
{
    if (EqualityComparer<T>.Default.Equals(field, value)) return;
    field = value;
    this.PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
```

- `SetValue1()` はジェネリックメソッドで、値の同値性検証・値の割当・イベント呼び出しをひとまとめにしています
- [`System.Runtime.CompilerServices.CallerMemberNameAttribute`](https://learn.microsoft.com/ja-jp/dotnet/api/system.runtime.compilerservices.callermembernameattribute?view=net-9.0) は呼び出しメソッドの名前を取得する属性で、ややテクい書き方です
  - ↑ の例では `propertyName = "Value1"` になります
  - コンパイラによって文字列リテラルに置き換えられるため、パフォーマンスがとても良いのも特徴です

### 更にイベント引数をキャッシュしたパターン

```cs
static PropertyChangedEventArgs? _value2Args;
int Value2 { get => field; set => SetValue2(ref field, value, ref _value2Args); }

void SetValue2<T>(ref T field, T value, ref PropertyChangedEventArgs? args, [CallerMemberName] string propertyName = "")
{
    if (EqualityComparer<T>.Default.Equals(field, value)) return;
    field = value;
    this.PropertyChanged?.Invoke(this, args ??= new PropertyChangedEventArgs(propertyName));
}
```

- イベント引数を遅延作成し、キャッシュします（該当のプロパティが変更されるまでイベント引数の作成が遅延される）
- `args ??= new ...` の箇所が厳密にはスレッドセーフにはなっていませんが、`PropertyChangedEventArgs` の参照を保持することはあまりないため多分問題ないです

### 最初からイベント引数をキャッシュしたパターン

```cs
static readonly PropertyChangedEventArgs _value3Args = new(nameof(Value3));
int Value3 { get => field; set => SetValue3(ref field, value, _value3Args); }

void SetValue3<T>(ref T field, T value, PropertyChangedEventArgs args)
{
    if (EqualityComparer<T>.Default.Equals(field, value)) return;
    field = value;
    this.PropertyChanged?.Invoke(this, args);
}
```

- イベント引数を static コンストラクタでキャッシュします
- スレッドセーフです

### 辞書を使用したパターン

```cs
static readonly System.Collections.Concurrent.ConcurrentDictionary<string, PropertyChangedEventArgs> Args = [];
int Value4 { get => field; set => SetValue4(ref field, value); }

private void SetValue4<T>(ref T field, T value, [CallerMemberName] string propertyName = "")
{
    if (EqualityComparer<T>.Default.Equals(field, value)) return;
    field = value;
    if (this.PropertyChanged is PropertyChangedEventHandler handler)
    {
        PropertyChangedEventArgs args;
        if (!Args.TryGetValue(propertyName, out args!))
            Args[propertyName] = args = new PropertyChangedEventArgs(propertyName);
        handler(this, args);
    }
}
```

- イベント引数を省略するために辞書を使用しています
- スレッドセーフにするため [`System.Collections.Concurrent.ConcurrentDictionary`](https://learn.microsoft.com/ja-jp/dotnet/api/system.collections.concurrent.concurrentdictionary-2?view=net-9.0) を使用しています

## パフォーマンス比較

|Test|Score|%|CG0|
|---|---:|---:|---:|
|SetValue1|1,863,426|100.0%|5|
|SetValue2|2,054,877|110.3%|0|
|**SetValue3**|2,147,531|**115.2%**|0|
|SetValue4|1,477,064|79.3%|0|

実行環境: Windows11 x64 .NET Runtime 9.0.0  
**Score** は高いほどパフォーマンスがよいです。  
**GC0** はガベージコレクション回数を表します（少ないほどパフォーマンスがよい）。

- [`SetValue1` ジェネリックを使用したパターン](#ジェネリックを使用したパターン)ではガベージが発生しています。とはいえ他に比べてパフォーマンスが特別悪いわけでもなく、簡潔なコードを優先する場合はこれでいいと思います
- [`SetValue2` 更にイベント引数をキャッシュしたパターン](#更にイベント引数をキャッシュしたパターン)はガベージが発生していません。少しパフォーマンスが良くなっています
- [`SetValue3` 最初からイベント引数をキャッシュしたパターン](#最初からイベント引数をキャッシュしたパターン)が**最もパフォーマンスが良いです**
- [`SetValue4` 辞書を使用したパターン](#辞書を使用したパターン)はガベージこそ発生していないものの、他に比べてパフォーマンスがやや悪いです

## おわりに

プロパティ通知パターンではかねてより[最初からイベント引数をキャッシュしたパターン](#最初からイベント引数をキャッシュしたパターン)が最速であるとされていましたが、今回数値的にどのくらい速いかを検証してみました。

今回最速だったパターンでは

```cs
static readonly PropertyChangedEventArgs _value3Args = new(nameof(Value3));
```
のような決まり文句のコード（ボイラープレート）をプロパティごとに書く必要があるものの、これは AI コード支援のおかげであまり気にならなくなった印象です。