2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Posted at

はじめに

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);
    }
}

パフォーマンス比較

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 はガベージコレクション回数を表します(少ないほどパフォーマンスがよい)。

おわりに

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

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

static readonly PropertyChangedEventArgs _value3Args = new(nameof(Value3));

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

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?