C# で INotifyPropertyChanged を無駄に難しく実装してみた

2021/09/02に公開

はじめに

sh_akira さんの INotifyPropertyChangedの実装をなるべく簡略化したい に触発されました。

データバインディングに必須なのが INotifyPropertyChanged の実装ですが、これをいちいちやるのが面倒くさい。
それで色々な工夫がなされています。

対策としては

  • BindableBase のような INotifyPropertyChanged を実装したクラスを継承する
  • set { SetValue(ref field, value); } のような形の汎用セッターを使う
  • ReactiveProperty を使う

が主に挙げられるようです。
しかし、継承したのでは他のクラスからの継承ができなくなるし、セッターを変えるのはせっかく簡単にプロパティが記述できるよう進化してきた C# の素の機能がもったいない。ReactiveProperty は便利だけど Value が気になる。と、些細なことではありますが不満はあります。
その中で IL を使ったことのない sh_akira さんがほんの数時間で作ったものは ViewModel の見た目の簡略さと記述のしやすさに於いて画期的だと思いました。

ソース

それに触発されて私も書いてみました。

IL を使うか Roslyn を使うか迷ったのですが、結局 CodeDom を使うことにしました。
せっかくあるものだからオワコンになる前に一度使ってみようという気持ちです。

そして継承が使えるよう、任意のクラスに INotifyPropertyChanged を実装する仕様にしました。

以下のソースを PropertyNotifier.cs という名前で保存してプロジェクトに追加してください。

using System;
using System.CodeDom;
using System.CodeDom.Compiler;
using System.Collections.Generic;
using System.ComponentModel;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text;
using Microsoft.CSharp;

namespace Zuishin
{
	public class NotifyPropertyAttribute : Attribute { }

	public class PropertyNotifier
	{
		private static Dictionary<Type, Type> typeDictionary = new Dictionary<Type, Type>();

		public static T Create<T>(params object[] arguments)
		{
			if (!typeDictionary.TryGetValue(typeof(T), out Type type))
			{
				type = CreateType(typeof(T));
				typeDictionary.Add(typeof(T), type);
			}
			if (type == null) return default;
			return (T)Activator.CreateInstance(type, arguments);
		}

		private static CodeCompileUnit CreateCompileUnit(Type baseType)
		{
			var compileUnit = new CodeCompileUnit();
			compileUnit.ReferencedAssemblies.AddRange(AppDomain
				.CurrentDomain
				.GetAssemblies()
				.Where(a => !a.IsDynamic)
				.Select(a => a.Location)
				.ToArray());
			var nameSpace = new CodeNamespace($"INotifyPropertyChanged_{Guid.NewGuid().ToString("N")}");
			compileUnit.Namespaces.Add(nameSpace);
			var type = new CodeTypeDeclaration(baseType.Name);
			type.BaseTypes.Add(baseType);

			var thisReference = new CodeThisReferenceExpression();
			var baseReference = new CodeBaseReferenceExpression();

			#region PropertyChanged
			var propertyChanged = new CodeMemberEvent()
			{
				Attributes = MemberAttributes.Public,
				Name = "PropertyChanged",
				Type = new CodeTypeReference(typeof(PropertyChangedEventHandler))
			};
			type.Members.Add(propertyChanged);
			#endregion

			#region propertyChangedEventArgs
			var propertyChangedEventArgsField = new CodeMemberField(
				new CodeTypeReference(typeof(Dictionary<string, PropertyChangedEventArgs>)),
				"propertyChangedEventArgs"
			)
			{
				Attributes = MemberAttributes.Private | MemberAttributes.Static,
			};
			propertyChangedEventArgsField.InitExpression = new CodeObjectCreateExpression(
				typeof(Dictionary<string, PropertyChangedEventArgs>)
			);
			type.Members.Add(propertyChangedEventArgsField);
			var propertyChangedEventArgs = new CodeMemberProperty()
			{
				Attributes = MemberAttributes.Family | MemberAttributes.Static,
				Name = "PropertyChangedEventArgs",
				Type = new CodeTypeReference(typeof(Dictionary<string, PropertyChangedEventArgs>))
			};
			propertyChangedEventArgs.GetStatements.Add(
				new CodeMethodReturnStatement(
					new CodeFieldReferenceExpression(
						new CodeTypeReferenceExpression(type.Name),
						propertyChangedEventArgsField.Name
					)
				)
			);
			type.Members.Add(propertyChangedEventArgs);
			#endregion

			#region OnPropertyChanged
			var onPropertyChanged = new CodeMemberMethod()
			{
				Attributes = MemberAttributes.Family | MemberAttributes.VTableMask,
				Name = "OnPropertyChanged"
			};
			onPropertyChanged.Parameters.Add(new CodeParameterDeclarationExpression(typeof(string), "propertyName"));
			type.Members.Add(onPropertyChanged);
			var propertyChangedReference = new CodeEventReferenceExpression(
				thisReference,
				propertyChanged.Name
			);
			onPropertyChanged.Statements.Add(
				new CodeConditionStatement(
					new CodeBinaryOperatorExpression(
						propertyChangedReference,
						CodeBinaryOperatorType.ValueEquality,
						new CodePrimitiveExpression(null)
					),
					new CodeMethodReturnStatement()
				)
			);
			var eventArgs = new CodeVariableDeclarationStatement(
				typeof(PropertyChangedEventArgs),
				"eventArgs"
			);
			onPropertyChanged.Statements.Add(
				eventArgs
			);
			var eventArgsReference = new CodeDirectionExpression(
				FieldDirection.Out,
				new CodeVariableReferenceExpression(eventArgs.Name)
			);
			var eventArgsVariable = new CodeVariableReferenceExpression(eventArgs.Name);
			var propertyName = new CodeArgumentReferenceExpression("propertyName");
			var propertyChangedEventArgsReference = new CodePropertyReferenceExpression(
				new CodeTypeReferenceExpression(type.Name),
				propertyChangedEventArgs.Name
			);

			var conditionStatement = new CodeConditionStatement(
				new CodeBinaryOperatorExpression(
					new CodeMethodInvokeExpression(
						propertyChangedEventArgsReference,
						"TryGetValue",
						propertyName,
						eventArgsReference
					),
					CodeBinaryOperatorType.ValueEquality,
					new CodePrimitiveExpression(false)
				),
				new CodeAssignStatement(
					eventArgsVariable,
					new CodeObjectCreateExpression(
						new CodeTypeReference(typeof(PropertyChangedEventArgs)),
						propertyName
					)
				)
			);
			conditionStatement.TrueStatements.Add(
				new CodeMethodInvokeExpression(
					propertyChangedEventArgsReference,
					"Add",
					propertyName,
					eventArgsVariable
				)
			);
			onPropertyChanged.Statements.Add(
				conditionStatement
			);
			onPropertyChanged.Statements.Add(
				new CodeDelegateInvokeExpression(
					propertyChangedReference,
					thisReference,
					eventArgsVariable
				)
			);
			#endregion

			#region Properties
			var properties = baseType
				.GetProperties(BindingFlags.Public | BindingFlags.Instance)
				.OfType<PropertyInfo>()
				.Where(a => a.GetMethod != null && a.GetMethod.IsVirtual && !a.GetMethod.IsFinal)
				.Where(a => a.SetMethod != null && a.SetMethod.IsVirtual && !a.SetMethod.IsFinal)
				.Where(a => a.GetIndexParameters().Length == 0)
				.ToList();
			var containsNotifyPropertyAttribute = properties
				.SelectMany(a => a.CustomAttributes)
				.Any(a => a.AttributeType == typeof(NotifyPropertyAttribute));
			if (containsNotifyPropertyAttribute)
			{
				properties = properties
					.Where(a => a.CustomAttributes.Any(b => b.AttributeType == typeof(NotifyPropertyAttribute)))
					.ToList();
			}
			foreach (var baseProperty in properties)
			{
				var property = new CodeMemberProperty()
				{
					Attributes = MemberAttributes.Override | MemberAttributes.Public,
					Name = baseProperty.Name,
					Type = new CodeTypeReference(baseProperty.PropertyType),
				};
				var basePropertyReference = new CodePropertyReferenceExpression(
					baseReference,
					baseProperty.Name
				);
				var valueArgument = new CodeArgumentReferenceExpression("value");

				#region Getter
				property.GetStatements.Add(
					new CodeMethodReturnStatement(basePropertyReference)
				);
				#endregion

				#region Setter
				property.SetStatements.Add(
					new CodeConditionStatement(
						new CodeMethodInvokeExpression(
							new CodeMethodReferenceExpression(
								new CodePropertyReferenceExpression(
									new CodeTypeReferenceExpression(
										typeof(EqualityComparer<>).MakeGenericType(baseProperty.PropertyType)
									),
									"Default"
								),
								"Equals"
							),
							basePropertyReference,
							valueArgument
						),
						new CodeMethodReturnStatement()
					)
				);
				property.SetStatements.Add(
					new CodeAssignStatement(
						basePropertyReference,
						valueArgument
					)
				);
				property.SetStatements.Add(
					new CodeMethodInvokeExpression(
						new CodeMethodReferenceExpression(
							thisReference,
							onPropertyChanged.Name
						),
						new CodePrimitiveExpression(baseProperty.Name)
					)
				);
				#endregion

				type.Members.Add(property);
			}
			#endregion

			#region Constructors
			foreach (var baseConstructor in baseType.GetConstructors())
			{
				var constructor = new CodeConstructor();
				constructor
					.Parameters
					.AddRange(baseConstructor
						.GetParameters()
						.Select(a => new CodeParameterDeclarationExpression(a.ParameterType, a.Name))
						.ToArray()
					);
				constructor
					.BaseConstructorArgs
					.AddRange(baseConstructor
						.GetParameters()
						.Select(a => new CodeVariableReferenceExpression(a.Name))
						.ToArray()
					);
				constructor.Attributes = MemberAttributes.Public;
				type.Members.Add(constructor);
			}
			#endregion

			type.BaseTypes.Add(typeof(INotifyPropertyChanged));
			nameSpace.Types.Add(type);
			return compileUnit;
		}

		private static Type CreateType(Type baseType)
		{
			var compileUnit = CreateCompileUnit(baseType);
			var provider = new CSharpCodeProvider();
			var compilerResults = provider.CompileAssemblyFromDom(new CompilerParameters() { GenerateInMemory = true }, compileUnit);
			if (compilerResults.Errors.HasErrors)
			{
				throw new Exception(string.Join("\n", compilerResults.Errors.OfType<CompilerError>()));
			}
			var nameSpace = compileUnit.Namespaces[0];
			return compilerResults.CompiledAssembly.GetType(nameSpace.Name + "." + nameSpace.Types[0].Name);
		}

		public static string CreateSource(Type baseType)
		{
			var compileUnit = CreateCompileUnit(baseType);
			var provider = new CSharpCodeProvider();
			var option = new CodeGeneratorOptions();
			var sb = new StringBuilder();
			using (var writer = new StringWriter(sb))
			{
				provider.GenerateCodeFromCompileUnit(compileUnit, writer, option);
			}
			return sb.ToString();
		}
	}
}

使い方

ViewModel

まず INotifyPropertyChanged を実装したいクラスを書きます。

public class ViewModel
{
	public virtual string Name { get; set; } = "Alice";

	public virtual int Age { get; set; } = 10;

	public override string ToString()
	{
		return $"{{ Name = {Name}, Age = {Age} }}";
	}
}

説明の必要はないと思いますが、単に string 型の Name プロパティと int 型の Age プロパティを持つだけのクラスです。
プロパティはそれぞれ Alice, 10 で初期化され、表示に便利なように ToString() をオーバーライドしています。
これを次のようにインスタンス化します。

var viewModel = PropertyNotifier.Create<ViewModel>();

イベントハンドラ

そして INotifyPropertyChanged が実装されていることを確かめるためにイベントハンドラを記述します。

((INotifyPropertyChanged)viewModel).PropertyChanged += (sender, e) =>
{
	Console.WriteLine($"{e.PropertyName} is changed to {sender.GetType().GetProperty(e.PropertyName).GetValue(sender)}.");
};

INotifyPropertyChanged にキャストしているのは、もともとの ViewModelINotifyPropertyChanged を実装していないためです。
キャストしなければコンパイルできません。

さて、このようにイベントハンドラを書きましたので PropertyChanged イベントが起こると (property Name) is changed to (new value). のようにコンソールに出力されます。

実行

実行してみましょう。

var viewModel = PropertyNotifier.Create<ViewModel>();

((INotifyPropertyChanged)viewModel).PropertyChanged += (sender, e) =>
{
	Console.WriteLine($"{e.PropertyName} is changed to {sender.GetType().GetProperty(e.PropertyName).GetValue(sender)}.");
};

Console.WriteLine(viewModel);
viewModel.Name = "Bob";
viewModel.Age = 20;
Console.WriteLine(viewModel);

次のように表示されました。

{ Name = Alice, Age = 10 }
Name is changed to Bob.
Age is changed to 20.
{ Name = Bob, Age = 20 }

viewModel{ Name = Alice, Age = 10 } と初期化されていることがわかります。
NameAge を変更するとちゃんと変更通知が届きました。

通知されるプロパティ

通知されるプロパティは以下の条件を満たしている必要があります。

  • public クラスのメンバーである
  • public プロパティである
  • virtual である
  • static でない

通知されるプロパティの選択

上記の条件を満たしているプロパティは自動的に通知されます。
しかし中には通知しなくて良いプロパティもあるかと思います。
その場合には次のように属性を使います。

public class ViewModel
{
	[NotifyProperty] public virtual string Name { get; set; } = "Alice";

	public virtual int Age { get; set; } = 10;

	public override string ToString()
	{
		return $"{{ Name = {Name}, Age = {Age} }}";
	}
}

実行結果は以下のようになります。

{ Name = Alice, Age = 10 }
Name is changed to Bob.
{ Name = Bob, Age = 20 }

NameAge が変更されましたが、通知されたのは Name のみです。
このように、NotifyPropertyAttribute を使うとデフォルトでプロパティの変更は通知されなくなり、その属性のついているプロパティのみ通知されるようになります。
通知したくないプロパティに属性を付与するのではなく、通知したいプロパティに付与することに注意してください。

コンストラクタの選択

次のようにコンストラクタに引数を与えることができます。

class Program
{
	public static void Main(string[] args)
	{
		var viewModel = PropertyNotifier.Create<ViewModel>("Charley", 30);
		((INotifyPropertyChanged)viewModel).PropertyChanged += (sender, e) =>
		{
			Console.WriteLine($"{e.PropertyName} is changed to {sender.GetType().GetProperty(e.PropertyName).GetValue(sender)}.");
		};
		Console.WriteLine(viewModel);
		viewModel.Name = "Bob";
		viewModel.Age = 20;
		Console.WriteLine(viewModel);
	}
}


public class ViewModel
{
	public ViewModel(string name, int age)
	{
		Name = name;
		Age = age;
	}

	public virtual string Name { get; set; } = "Alice";

	public virtual int Age { get; set; } = 10;

	public override string ToString()
	{
		return $"{{ Name = {Name}, Age = {Age} }}";
	}
}

結果は次のようになります。

{ Name = Charley, Age = 30 }
Name is changed to Bob.
Age is changed to 20.
{ Name = Bob, Age = 20 }

ちゃんと指定されたコンストラクタが呼ばれているのがわかります。

ソースを見る

CodeDom の良いところは C# のソースに変換できるところです。

Console.WriteLine(PropertyNotifier.CreateSource(typeof(ViewModel)));

次のようなソースが作られました。

//------------------------------------------------------------------------------
// <auto-generated>
//     このコードはツールによって生成されました。
//     ランタイム バージョン:4.0.30319.42000
//
//     このファイルへの変更は、以下の状況下で不正な動作の原因になったり、
//     コードが再生成されるときに損失したりします。
// </auto-generated>
//------------------------------------------------------------------------------

namespace INotifyPropertyChanged_2172d2073f184d9db6d12b49f79a37c0 {
    
    
    public class ViewModel : ConsoleApp1.ViewModel, System.ComponentModel.INotifyPropertyChanged {
        
        private static System.Collections.Generic.Dictionary<string, System.ComponentModel.PropertyChangedEventArgs> propertyChangedEventArgs = new System.Collections.Generic.Dictionary<string, System.ComponentModel.PropertyChangedEventArgs>();
        
        public ViewModel(string name, int age) : 
                base(name, age) {
        }
        
        protected static System.Collections.Generic.Dictionary<string, System.ComponentModel.PropertyChangedEventArgs> PropertyChangedEventArgs {
            get {
                return ViewModel.propertyChangedEventArgs;
            }
        }
        
        public override string Name {
            get {
                return base.Name;
            }
            set {
                if (System.Collections.Generic.EqualityComparer<string>.Default.Equals(base.Name, value)) {
                    return;
                }
                base.Name = value;
                this.OnPropertyChanged("Name");
            }
        }
        
        public override int Age {
            get {
                return base.Age;
            }
            set {
                if (System.Collections.Generic.EqualityComparer<int>.Default.Equals(base.Age, value)) {
                    return;
                }
                base.Age = value;
                this.OnPropertyChanged("Age");
            }
        }
        
        public event System.ComponentModel.PropertyChangedEventHandler PropertyChanged;
        
        protected virtual void OnPropertyChanged(string propertyName) {
            if ((this.PropertyChanged == null)) {
                return;
            }
            System.ComponentModel.PropertyChangedEventArgs eventArgs;
            if ((ViewModel.PropertyChangedEventArgs.TryGetValue(propertyName, out eventArgs) == false)) {
                eventArgs = new System.ComponentModel.PropertyChangedEventArgs(propertyName);
                ViewModel.PropertyChangedEventArgs.Add(propertyName, eventArgs);
            }
            this.PropertyChanged(this, eventArgs);
        }
    }
}

virtual でないプロパティを通知する

System.Windows.Forms.Form.Tagvirtual ではないのでそのままでは通知されません。
これを通知させるには以下のように new キーワードを使って同名のプロパティで上書きします。
しかしながらこれは Form1 に同名のプロパティを作ったというだけで、Form のプロパティではありません。
したがって、Form 型の変数に代入した場合には使うことができません。
Form1 には Tag というプロパティが二つあって新しく作った方をデフォルトで使うという扱いです。

Program.cs

static void Main()
{
	Application.EnableVisualStyles();
	Application.SetCompatibleTextRenderingDefault(false);
	Application.Run(Zuishin.PropertyNotifier.Create<Form1>());
}

Form1.cs

public partial class Form1 : Form
{
	public Form1()
	{
		InitializeComponent();
		((INotifyPropertyChanged)this).PropertyChanged += (sender, args) =>
		{
			MessageBox.Show($"{args.PropertyName} = {sender.GetType().GetProperty(args.PropertyName).GetValue(sender)}");
		};
	}

	public virtual new object Tag
	{
		get => base.Tag;
		set => base.Tag = value;
	}

	private void button1_Click(object sender, EventArgs e)
	{
		Tag = Tag is int t ? t + 1 : 0;
	}
}

執筆日: 2018/03/25

GitHubで編集を提案

Discussion