INotifyPropertyChangedの実装とプロパティ名 #2

WPFを使ったアプリの実装をすると多くの場合にINotifyPropertyChangedの実装が必要となります。前回の投稿では、INotifyPropertyChangedについて説明しました。今回は、そのときに必要となるプロパティ名の指定方法についての投稿です。

GUIの実現にWPFを使ってアプリケーションを実装すると、多くの場合に、ViewModel層やModel層のプロパティの値の変化をView層に伝えるために、INotifyPropertyChangedの実装が必要となります。

INotifyPropertyChangedの実装でイベントを発行するときは、プロパティ名が必要となります。プロパティ名の指定方法には、過去からの経緯で、複数の実現方法が存在します。複数の方法が存在しますが、C# 6.0以降を使う場合は、使用する方法は、ほぼ決まっています。

プロパティ名の指定方法

プロパティ名の指定方法には、以下の方法が考えられます。

  1. 文字列で指定する("PropertyB"、C# 2.0以降)
  2. ラムダ式で指定する(() => PropertyB、C# 3.0以降)
  3. nameofで指定する (nameof(PropertyB)、C# 6.0以降)
  4. 指定しない ([CallerMemberName]を利用、C# 5.0以降)

INotifyPropertyChangedインターフェースが導入されたのはC# 2.0からなので、当初は、プロパティ名を指定する方法としては、文字列で指定することしかできませんでした。

前回の投稿で紹介した基底クラスBindableBaseを再掲します。

#nullable enable
namespace NishySoftware.nsCommon
{
    public abstract class BindableBase : INotifyPropertyChanged
    {
        public event PropertyChangedEventHandler? PropertyChanged;

        protected bool SetProperty<T>(ref T field, T value, [CallerMemberName] String propertyName = null!, bool needFire = true)
        {
            if (EqualityComparer<T>.Default.Equals(field, value)) return false;

            field = value;
            if (needFire)
                this.OnPropertyChanged(propertyName);
            return true;
        }

        protected void OnPropertyChanged([CallerMemberName] string propertyName = null!)
        {
            this.PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
        }

        [Obsolete("Use nameof() instead. ex) SetProperty(ref field, value, nameof(property))")]
        protected bool SetProperty<T>(ref T field, T value, Expression<Func<T>> selectorExpression, bool needFire = true)
        {
            if (EqualityComparer<T>.Default.Equals(field, value)) return false;

            field = value;
            if (needFire)
                this.OnPropertyChanged(selectorExpression);
            return true;
        }

        [Obsolete("Use nameof() instead. ex) OnPropertyChanged(nameof(property))")]
        protected void OnPropertyChanged<T>(Expression<Func<T>> selectorExpression)
        {
            if (selectorExpression == null)
                throw new ArgumentNullException(nameof(selectorExpression));
            if (!(selectorExpression.Body is MemberExpression body))
                throw new ArgumentException("The body must be a member expression");
            OnPropertyChanged(body.Member.Name);
        }
    }
}

この基底クラスを使ったクラスの例(再掲)が以下となります。

#nullable enable
using NishySoftware.nsCommon;
namespace Test
{
    public class DataA : BindableBase
    {
        string _propertyB = "";
        public string PropertyB
        {
            get { return _propertyB; }
            set { SetProperty(ref _propertyB, value); }
        }
    }
}

このクラスのPropertyBのセッターのSetProperty()を呼び出す部分をそれぞれの方法で記載すると、以下の通りです。

  1. set { SetProperty(ref _propertyB, value, “PropertyB”); }
  2. set { SetProperty(ref _propertyB, value, () => PropertyB); }
  3. set { SetProperty(ref _propertyB, value, nameof(PropertyB)); }
  4. set { SetProperty(ref _propertyB, value); }

どの方法でも、変更通知は問題なくできます。しかし、コードのメンテナンス性や実行速度などの面で違いがあります。

コードのメンテナンス性

コードのメンテナンス性の面では、「4番目」、「2番目と3番目」、「1番目」の順で優れています。このプロパティの名前に指定で、メンテナンス性に影響があるのは、リファクタリングなどにおけるプロパティ名の変更のときです。

INotifyPropertyChangedは、値が変更になったプロパティ名をパラメーターとして、変更通知をします。そのため、プロパティの名前を変更した時は、パラメーターとして引数に設定するプロパティ名も変更する必要がります。

たとえば、下記のようにプロパティ名をPropertyBからPropertyCに変更することを検討します。

public string PropertyC
{
    get { return _propertyB; }
    set { SetProperty(ref _propertyB, value); }
}

この場合、SetProperty()の部分は、それぞれの方法で、以下のように変更する必要があります。

  1. set { SetProperty(ref _propertyB, value, “PropertyC); }
  2. set { SetProperty(ref _propertyB, value, () => PropertyC); }
  3. set { SetProperty(ref _propertyB, value, nameof(PropertyC)); }
  4. set { SetProperty(ref _propertyB, value); }

一目瞭然ですが、4番目の方法では、プロパティの名前が変更になっても、セッターのコードを修正する必要がありません。

1番目から3番目は修正が必要となりますが、「1番目」と「2番目と3番目」では大きな違いがあります。

「1番目」は、セッターの修正を忘れたとしてもコンパイルが成功してしまいす。しかし、「2番目と3番目」はセッターの修正を忘れると、コンパイルエラーとなります。コンパイル エラーとなることにより、修正を忘れることがなくなります。そのため、バグを含んだままアプリが出荷されることがなくなります。

この違いは、プロパティ名を文字列として指定しているか、プロパティを参照しているかの違いです。文字列として指定している場合は、実際のプロパティ名と独立しています。プロパティを参照している場合は、そのプロパティがなければ、コンパイル時にエラーとなります。

このような理由から、コードのメンテナンス性の面では、

  1. 4番目
  2. 2番目と3番目
  3. 1番目

の順で優れています。

パフォーマンス

パフォーマンスの面では、「1番目と3番目と4番目」の方法は同じで、遅いのが2番目の方法です。

1番目と3番目と4番目は、SetProperty()を呼び出すときの引数の指定は異なります。しかし、どれもコンパイル時点で情報が文字列として確定させることができます。結果としてSetProperty()側には、第3引数に単純な文字列として、プロパティ名が渡されます。そのため、3つとも実行時のパフォーマンスは同じとなります。

しかし、2番目の方法では、コンパイル時点としては、第3引数に文字列としてではなく、式として情報が渡されます。SetProperty()側では、実行時に式の中に埋め込まれているプロパティ名を抜き出す処理が必要となります。このプログラムの実行時に、引数として渡された式の中からプロパティ名を抜き出す処理が必要となり、パフォーマンス的に不利となります。

実際にSetProperty()の実行速度を調べてみたところ、Release版でビルドしたモジュールで実行時間は20倍から40倍くらいの差がありました。.NET Framework、.NET Core、.NET のいずれの場合でも、同様の結果でした。

SetProperty()のプロパティ名の最適な指定方法

SetProperty()の第3引数のプロパティ名の指定方法には、以下の4つの方法がありました。

  1. 文字列で指定する(“PropertyB”、C# 2.0以降)
  2. ラムダ式で指定する(() => PropertyB、C# 3.0以降)
  3. nameofで指定する (nameof(PropertyB)、C# 6.0以降)
  4. 指定しない ([CallerMemberName]を利用、C# 5.0以降)

前述の説明から分かるように、C# 6.0以降が利用可能な現在では、コードのメンテナンス性および実行パフォーマンスの観点から、3番目または4番目を使うのが最適です。

3番目と4番目の使い分けは次の通りです。プロパティのセッターから自身のプロパティを更新する場合は4番目を利用し、他のプロパティを更新するときなどは、3番目を使います。

C# 6.0は、Visual Studio 2015 / .NET Framework 4.6 で使えるようになりました。時期としては2015年7月です。すでに8年前から利用可能です。そのため、現在、新しいコードを実装するのであれば、問題なく3番目と4番目の方法が利用できます。

過去のソースコードで、1番目や2番目の方法を使っている場合は、順次変更していくのがよいでしょう。新しいコードでは、3番目や4番目の方法を使っていきましょう。

なお、2番目の方法もは現代では時代遅れですが、C# 3.0からC# 5.0の世代では優れた手法でした。

BindableBaseの全体コード

いままでに例として挙げた基底クラスBindableBaseは抜粋したコードでした。最後にこの基底クラスBindableBaseの全体のコードを掲示しておきます。

// ==================================================
// Copyright 2015, 2018, 2023 nishy software
// The MIT License (MIT)
// ==================================================

#if NETCOREAPP3_0_OR_GREATER || NETCOREAPP3_0 || NETCOREAPP3_1 // C# 8.0 -
#nullable enable
#endif

namespace NishySoftware.nsCommon
{
    using System;
    using System.Collections.Generic;
    using System.ComponentModel;
#if NET || NETCOREAPP || NET35_OR_GREATER || NET35 || NET40 || NET45 || NET451 || NET452 || NET46 || NET461 || NET462 || NET47 || NET471 || NET472 || NET48 || NET481 || NET482 // C# 3.0 -
    using System.Linq.Expressions;
#endif
    using System.Runtime.CompilerServices;

    /// <summary>
    /// モデルを簡略化するための <see cref="INotifyPropertyChanged"/> の実装。
    /// </summary>
    public abstract class BindableBase : INotifyPropertyChanged
    {
        /// <summary>
        /// プロパティの変更を通知するためのマルチキャスト イベント。
        /// </summary>
#if NETCOREAPP3_0_OR_GREATER || NETCOREAPP3_0 || NETCOREAPP3_1 // C# 8.0 -
        public event PropertyChangedEventHandler? PropertyChanged;
#else  // - C# 7.3
        public event PropertyChangedEventHandler PropertyChanged;
#endif

        /// <summary>
        /// プロパティが新し値と一致しているかどうかを確認し、値が異なる場合のみ、プロパティを更新し、リスナーに通知します。
        /// </summary>
        /// <typeparam name="T">プロパティの型。</typeparam>
        /// <param name="field">プロパティの値を保持するフィールドへの参照。</param>
        /// <param name="value">プロパティに設定する新しい値。</param>
        /// <param name="propertyName">リスナーに通知するために使用するプロパティの名前。
        /// <see cref="CallerMemberNameAttribute"/> をサポートするC# 5.0以降(.NET Framework 4.5以降)を使用する場合は、この値は省略可能です。省略した場合には呼び出し元のプロパティ名が自動的に設定されます</param>
        /// <param name="needFire">プロパティの値が変更されたときに通知するかどうかを決める値。trueの場合、リスナーに通知します。</param>
        /// <returns>値が変更された場合は true、既存の値と同じ場合はfalse です。</returns>
#if NETCOREAPP3_0_OR_GREATER || NETCOREAPP3_0 || NETCOREAPP3_1 // C# 8.0 -
        protected bool SetProperty<T>(ref T field, T value, [CallerMemberName] string propertyName = null!, bool needFire = true)
#elif NET || NETCOREAPP || NET45_OR_GREATER || NET45 || NET451 || NET452 || NET46 || NET461 || NET462 || NET47 || NET471 || NET472 || NET48 || NET481 || NET482 // C# 5.0 - C# 7.3
        protected bool SetProperty<T>(ref T field, T value, [CallerMemberName] string propertyName = null, bool needFire = true)
#else  // - C# 4.0
        protected bool SetProperty<T>(ref T field, T value, string propertyName = null, bool needFire = true)
#endif
        {
            //if (object.Equals(field, value)) return false;
            if (EqualityComparer<T>.Default.Equals(field, value)) return false;

            field = value;
            if (needFire)
                this.OnPropertyChanged(propertyName);
            return true;
        }

        /// <summary>
        /// プロパティ値が変更されたことをリスナーに通知します。
        /// </summary>
        /// <param name="propertyName">リスナーに通知するために使用するプロパティの名前。
        /// <see cref="CallerMemberNameAttribute"/> をサポートするC# 5.0以降(.NET Framework 4.5以降)を使用する場合は、この値は省略可能です。省略した場合には呼び出し元のプロパティ名が自動的に設定されます</param>
#if NETCOREAPP3_0_OR_GREATER || NETCOREAPP3_0 || NETCOREAPP3_1 // C# 8.0 -
        protected void OnPropertyChanged([CallerMemberName] string propertyName = null!)
#elif NET || NETCOREAPP || NET45_OR_GREATER || NET45 || NET451 || NET452 || NET46 || NET461 || NET462 || NET47 || NET471 || NET472 || NET48 || NET481 || NET482 // C# 5.0 - C# 7.3
        protected void OnPropertyChanged([CallerMemberName] string propertyName = null)
#else  // - C# 4.0
        protected void OnPropertyChanged(string propertyName)
#endif
        {
#if NET || NETCOREAPP || NET46_OR_GREATER || NET46 || NET461 || NET462 || NET47 || NET471 || NET472 || NET48 || NET481 || NET482 // C# 6.0 -
            this.PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
#else
            var eventHandler = this.PropertyChanged;
            if (eventHandler != null)
            {
                eventHandler.Invoke(this, new PropertyChangedEventArgs(propertyName));
            }
#endif
        }

        /// <summary>
        /// 複数のプロパティの値が変更されたことをリスナーに通知します。
        /// ex) OnPropertyChanged(nameof(PropertyA), nameof(PropertyB));
        /// </summary>
        /// <param name="propertyNames">リスナーに通知するために使用するプロパティの名前のリスト。</param>
        protected void OnPropertyChanged(params string[] propertyNames)
        {
            var eventHandler = this.PropertyChanged;
            if (eventHandler != null)
            {
                foreach (var propertyName in propertyNames)
                {
                    eventHandler.Invoke(this, new PropertyChangedEventArgs(propertyName));
                }
            }
        }

#if NET || NETCOREAPP || NET35_OR_GREATER // C# 3.0 -
        /// <summary>
        /// プロパティが新し値と一致しているかどうかを確認し、値が異なる場合のみ、プロパティを更新し、リスナーに通知します。
        /// C# 6.0以降(.NET Framework 4.6以降)では、nameof()が使えます。パフォーマンス向上のために、nameof()を使用してください。
        /// </summary>
        /// <param name="selectorExpression">対象のプロパティの情報をラムダ式で設定します。ex) SetProperty(ref field, value, () => Propetry)</param>
        /// <inheritdoc cref="SetProperty{T}(ref T, T, string, bool)"/>
#if NET || NETCOREAPP || NET46_OR_GREATER || NET46 || NET461 || NET462 || NET47 || NET471 || NET472 || NET48 || NET481 || NET482 // C# 6.0 -
        [Obsolete("Use nameof() instead. ex) SetProperty(ref field, value, nameof(property))")]
#endif
        protected bool SetProperty<T>(ref T field, T value, Expression<Func<T>> selectorExpression, bool needFire = true)
        {
            //if (object.Equals(field, value)) return false;
            if (EqualityComparer<T>.Default.Equals(field, value)) return false;

            field = value;
            if (needFire)
                this.OnPropertyChanged(selectorExpression);
            return true;
        }

        /// <summary>
        /// プロパティ値が変更されたことをリスナーに通知します。
        /// C# 6.0以降(.NET Framework 4.6以降)では、nameof()が使えます。パフォーマンス向上のために、nameof()を使用してください。
        /// </summary>
        /// <param name="selectorExpression">対象のプロパティの情報をラムダ式で設定します。ex) OnPropertyChanged(() => Propetry)</param>
        /// <inheritdoc cref="OnPropertyChanged(string)"/>
#if NET || NETCOREAPP || NET46_OR_GREATER || NET46 || NET461 || NET462 || NET47 || NET471 || NET472 || NET48 || NET481 || NET482 // C# 6.0 -
        [Obsolete("Use nameof() instead. ex) OnPropertyChanged(nameof(property))")]
#endif
        protected void OnPropertyChanged<T>(Expression<Func<T>> selectorExpression)
        {
            OnPropertyChanged(GetPropertyName(selectorExpression));
        }

        /// <summary>
        /// ラムダ式からプロパティの名前を取得します。
        /// C# 6.0以降(.NET Framework 4.6以降)では、nameof()が使えます。パフォーマンス向上のために、nameof()を使用してください。
        /// </summary>
        /// <param name="selectorExpression">対象のプロパティの情報をラムダ式で設定します。ex) GetPropertyName(() => Propetry)</param>
        /// <returns>ラムダ式から取り出したプロパティ名</returns>
#if NET || NETCOREAPP || NET46_OR_GREATER || NET46 || NET461 || NET462 || NET47 || NET471 || NET472 || NET48 || NET481 || NET482 // C# 6.0 -
        [Obsolete("Use nameof() instead. ex) nameof(property)")]
#endif
        public static string GetPropertyName<T>(Expression<Func<T>> selectorExpression)
        {
            if (selectorExpression == null)
                throw new ArgumentNullException("selectorExpression");
            var body = selectorExpression.Body as MemberExpression;
            if (body == null)
                throw new ArgumentException("The body must be a member expression");
            return body.Member.Name;
        }
#endif
    }
}

最新バージョンのC#だけでなく、過去のバージョンのC#でも利用できるように、#if/#endif がたくさん入っています。

用意されているメソッドは、以下の6個です。

  • bool SetProperty<T>(ref T field, T value, [CallerMemberName] string propertyName = null!, bool needFire = true)
  • void OnPropertyChanged([CallerMemberName] string propertyName = null!)
  • void OnPropertyChanged(params string[] propertyNames)
  • bool SetProperty<T>(ref T field, T value, Expression<Func<T>> selectorExpression, bool needFire = true)
  • void OnPropertyChanged<T>(Expression<Func<T>> selectorExpression)
  • string GetPropertyName<T>(Expression<Func<T>> selectorExpression)

前者3つが通常の実装用です。後者3つは、過去の互換性のためにラムダ式を使いプロパティ名を指定するメソッドです。

イベントを発行するためだけのメソッドOnPropertyChanged()も用意しています。このOnPropertyChanged()は、一つのプロパティ変更が他のプロパティに影響するときに使います。

たとえば、以下のようなクラスがあったとします。

#nullable enable
using NishySoftware.nsCommon;
namespace Test
{
    public class DataA : BindableBase
    {
        string _propertyB = "";
        public string PropertyB
        {
            get { return _propertyB; }
            set
            {
               if(SetProperty(ref _propertyB, value))
               {
                   OnPropertyChanged(nameof(PropertyC), nameof(PropertyD))
               }
            }
        }

        public string PropertyC { get { return PropertyB; } }

        public string PropertyD { get { return PropertyB; } }
    }
}

このクラスのプロパティPropertyCやプロパティPropertyDは、プロパティPropertyBに依存しています。そのため、PropertyBが変更になった時は、PropertyCPropertyDの変更通知も必要となります。SetProperty()は、値に変更があった場合にtrueを返すため、この場合、PropertyBのセッターのコードを上記のように書くことにより、実現できます。


今回の投稿は、INotifyPropertyChangedの実装において、プロパティ名の指定方法の説明と前回は抜粋であった基底クラスBindableBaseの全体の紹介でした。

コメントを残す