ネストしたImmutableオブジェクトの更新(C#)

C#(7.0)でImmutableクラスを作成する場合、下記のような記述になります。

public class Person
{
    public int Age { get; }
    public string Name { get; }
    public Person Child { get; }
    public Person(int age, string name, Person child)
    {
        Age = age;
        Name = name;
        Child = child;
    }
}

下記のようなデータがあったとして、Richardの年齢を更新したデータを作成するのはとても面倒です。

var person = new Person(35, "John",
                new Person(10, "Richard", null));

そこでリフレクションを利用して、簡単に更新データを作成できるようにしてみたいと思います。

コード内で利用されているCreate、Scan、Zipオペレータは下記記事を参照してください。
LINQ独自オペレータ メモ - 何でもプログラミング

With関数定義

ExpressionTreeを利用してオブジェクトを更新します。

一つのオブジェクトの更新は、MemberwiseCloneとBackingFieldの更新で実現しています。

自動生成される<Name>k__BackingFieldは予告なく名称が変わる可能性がありますので、不安な場合はprivate set;を追加して、PropertyInfo.SetValueを利用するようにしてください。

public static TObj With<TObj, TValue>(this TObj obj, Expression<Func<TObj, TValue>> expression, TValue value)
{
    // MemberExpressionをたどる
    var members = EnumerableEx.Create(expression.Body as MemberExpression, x => x.Expression as MemberExpression)
        .TakeWhile(x => x != null)
        .ToArray();

    // 各メンバの現在値取得
    var objs = members.Reverse()
        .Scan((object)obj, (x, m) => ((PropertyInfo)m.Member).GetValue(x))
        .ToArray();

    // 値を下層から順次更新
    return (TObj)objs.Reverse().Skip(1)
        .Zip(members.Select(x => x.Member.Name))
        .Aggregate((object)value, (state, x) => With(x.Item1, x.Item2, state));
}
static object With(object obj, string propertyName, object value)
{
    var type = obj.GetType();
    var clone = type.GetMethod("MemberwiseClone", BindingFlags.Instance | BindingFlags.NonPublic).Invoke(obj, null);
    type.GetField($"<{propertyName}>k__BackingField", BindingFlags.Instance | BindingFlags.NonPublic).SetValue(clone, value);
    return clone;
}


利用

冒頭のデータに対し下記の様に記述することにより、Richardの年齢が11に更新された新たなデータが作成されます。

var newPerson = person.With(x => x.Child.Age, 11);