はじめに
C# では、オブジェクトのプロパティ値が変更されたことをクライアントに通知する汎用的なパターンがあります。
System.ComponentModel.INotifyPropertyChanged
インターフェイス
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
は読み取り専用クラスなのでキャッシュしても問題なく、ここに工夫の余地がありそうです。
またプロパティごとに ↑ のような決まり文句のコード(ボイラープレート)を書くのも億劫なので、処理をまとめたいところです。
今回は、これを実装する際にパフォーマンスがいいやり方を模索しようと思います。
サンプルコード
テストコード
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;
});
}
}
実装案
ジェネリックを使用したパターン
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
は呼び出しメソッドの名前を取得する属性で、ややテクい書き方です- ↑ の例では
propertyName = "Value1"
になります - コンパイラによって文字列リテラルに置き換えられるため、パフォーマンスがとても良いのも特徴です
- ↑ の例では
更にイベント引数をキャッシュしたパターン
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
の参照を保持することはあまりないため多分問題ないです
最初からイベント引数をキャッシュしたパターン
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 コンストラクタでキャッシュします
- スレッドセーフです
辞書を使用したパターン
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
を使用しています
パフォーマンス比較
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
辞書を使用したパターンはガベージこそ発生していないものの、他に比べてパフォーマンスがやや悪いです
おわりに
プロパティ通知パターンではかねてより最初からイベント引数をキャッシュしたパターンが最速であるとされていましたが、今回数値的にどのくらい速いかを検証してみました。
今回最速だったパターンでは
static readonly PropertyChangedEventArgs _value3Args = new(nameof(Value3));
のような決まり文句のコード(ボイラープレート)をプロパティごとに書く必要があるものの、これは AI コード支援のおかげであまり気にならなくなった印象です。