C# の LINQ を使ってデータ変換をしていると、データクラスのインスタンスをコピーして一部のプロパティを別の値に差し替えたいということがよくある。C# 9.0 では Records v2 の with 構文というものが導入されるようで期待できるのだが、現行の C# 8.0 では存在しないため、Clone() メソッドを作成し、リフレクションでプロパティを置換していた。
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Runtime.CompilerServices;
public abstract class Record : ICloneable {
<summary>
</summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public object Clone() => Clone(null);
<summary>
</summary>
public object Clone(object? param = null) {
var clone = this.MemberwiseClone();
var subst = param ?? new {};
var props = subst.GetType().GetProperties();
var dic = clone.GetType().GetProperties()?.ToDictionary( p => p.Name, p => p);
foreach (var p in props)
if (dic?.ContainsKey(p.Name) ?? false)
dic[p.Name].SetValue(clone, p.GetValue(subst));
else
throw new ArgumentException($"{this.GetType().Name} のクローン時にプロパティ {p.Name} の置換に失敗しました。\n" +
$"当該プロパティは {this.GetType().Name} に存在しません。");
return clone;
}
}
下記のように使う。この例ではプロパティ数が少なくてありがたみがないが、プロパティがたくさんあるデータクラスをコピーしてごく一部のプロパティしか置換しない場合に (さらに Select, Join, GroupJoin 等で何度も何度も変換する場合には特に) 重宝する。
public class RecordSample : Record {
public string ID { get; set; }
public int? Value { get; set; }
public static void Test() {
var list_original =
Enumerable.Range(0, 10)
.Select( x => new RecordSample {
ID = "" + x,
Value = x ,
} )
.ToList();
var list_converted =
list_original
.Select( org => org.Clone(new { Value = org.Value * org.Value }) as RecordSample )
.ToList();
}
}
これまで、毎度毎度リフレクションでプロパティ走査をするのは非効率であることは自覚しつつも、このメソッドに特に不満はなかった。が、しかし、最近 PostgreSQL サーバをたてて大量データを扱うようになり、このオーバヘッドが気になったため、この記事を参考に式木を使ってリフレクションを極小化してみた。
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Runtime.CompilerServices;
using static System.Linq.Expressions.Expression;
public abstract class Record : ICloneable {
private static readonly IDictionary<string, Action<object, object>>DicAction = new Dictionary<string, Action<object, object>>();
<summary>
</summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static bool CheckPropertyType(Type dst, Type src) =>
dst == src ||
(dst.IsGenericType &&
dst.GetGenericTypeDefinition() == typeof(Nullable<>) &&
dst.GenericTypeArguments.FirstOrDefault() == src );
<summary>
</summary>
private static Action<object, object> CreatePropertyReplaceMethod(Type type_dst, Type type_src) {
var props = type_src.GetProperties();
props
.Select( p_src => type_dst.GetProperty(p_src.Name) switch {
null => throw new ArgumentException($"プロパティ {p_src.Name} は置換先インスタンスのクラス " +
$"{type_dst.Name} に存在しません。"),
var p_dst => (p_dst.PropertyType, p_src.PropertyType) switch {
(var t_dst, var t_src) =>
CheckPropertyType(t_dst, t_src) ?
true : throw new ArgumentException($"プロパティ {p_src.Name} の型 {t_src.Name} が置換先インスタンスのクラス " +
$"{type_dst.Name} のプロパティの型 {t_dst.Name} と一致しません。"),
},
} )
.ToList();
var o_dst = Parameter(typeof(object));
var o_src = Parameter(typeof(object));
var t_dst = Parameter(type_dst);
var t_src = Parameter(type_src);
var body =
(Enumerable.Empty<System.Linq.Expressions.Expression>()
.Append(Assign(t_dst, Convert(o_dst, type_dst)))
.Append(Assign(t_src, Convert(o_src, type_src)))
.Concat(props
.Select( x =>
Assign(Property(t_dst, x.Name),
Convert(Property(t_src, x.Name),
type_dst.GetProperty(x.Name)?.PropertyType)) )));
return
(Action<object, object>)
Lambda<Action<object, object>>(Block(new [] { t_dst, t_src }, body), o_dst, o_src)
.Compile();
}
<summary>
</summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public object Clone() => Clone(null);
<summary>
</summary>
public object Clone(object? param = null) =>
new Func<object, object, object>((dst, src) => {
var type_dst = dst.GetType();
var type_src = src.GetType();
var pattern = $"{type_dst.Name}/{type_src.Name}";
if (! DicAction.TryGetValue(pattern, out var action))
DicAction.Add(pattern, action = CreatePropertyReplaceMethod(type_dst, type_src));
action(dst, src);
return dst;
})(this.MemberwiseClone(),
param ?? new {} );
}
クラスの辞書を静的に保持してキャッシュとし、同じクラスの組み合わせが来たらプロパティを置換するアクションをキャッシュから抽出して利用する。また、置換先 Record のプロパティの型が T? のときに置換元匿名クラスのプロパティをいちいち Nullable にキャストして指定するのが面倒であるため、Nullable か否かの違いは許容するようにしている。
.Select( org => org.Clone(new { Value = (int?)1 }) as RecordSample )
.Select( org => org.Clone(new { Value = 1 }) as RecordSample )
.Select( org => org.Clone(new { Value = (int?)null }) as RecordSample )
Record のプロパティ数 4、匿名クラスによる置換プロパティ数 1 の小さなデータクラスで試したところ、4 - 5 倍の高速化が図れた。めでたしめでたし。
繰り返し回数 |
100,000 |
1,000,000 |
旧版 (Reflection Only) |
3,565ms |
27,582ms |
新版 (Expression) |
692ms |
6,695ms |
高速化 |
5.2 倍 |
4.1 倍 |
実運用目的の式木を初めて自作してみて勉強になったが、コンパイル時、ランタイム初回実行時 (キャッシュがないとき)、ランタイム2回目以降実行時 (キャッシュヒットのとき) と 3 層構造で考えなければならず、この程度のロジック構築に半日もかかってしまった。
LINQ to Entities でロジック構築する場合も、LINQ to Object 互換レベル (IEnumerable)、LINQ to Entities 互換レベル (IQueryable)、データベースプロバイダー互換レベル (PostgreSQL では OK だが Oracle では NG というようなケース)、データベース実装互換レベル (Oracle 12c では OK だが Oracle 11g では NG な Apply 文など) と 4 層構造で考える必要がある。メタプログラミングは (おもしろいが) 非常に疲れる。