C# で INotifyPropertyChanged を無駄に難しく実装してみた
はじめに
sh_akira さんの INotifyPropertyChangedの実装をなるべく簡略化したい に触発されました。
データバインディングに必須なのが INotifyPropertyChanged
の実装ですが、これをいちいちやるのが面倒くさい。
それで色々な工夫がなされています。
- INotifyPropertyChanged実装のありえない面倒くささと、ReactivePropertyの信じられない素晴らしさ
- INotifyPropertyChanged の実装
- C# 6.0時代の変更通知プロパティの書き方
- [MVVM] 便利で間違えのないプロパティ変更通知を行うBindableBaseの実装
対策としては
-
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
にキャストしているのは、もともとの ViewModel
が INotifyPropertyChanged
を実装していないためです。
キャストしなければコンパイルできません。
さて、このようにイベントハンドラを書きましたので 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 }
と初期化されていることがわかります。
Name
と Age
を変更するとちゃんと変更通知が届きました。
通知されるプロパティ
通知されるプロパティは以下の条件を満たしている必要があります。
- 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 }
Name
と Age
が変更されましたが、通知されたのは 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.Tag
は virtual
ではないのでそのままでは通知されません。
これを通知させるには以下のように 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
Discussion