Functional Programming w/ C# LINQ - with 初期化子の代替メソッド定義 for C# 8.0 or former

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>
  /// オブジェクトのクローンを Shallow Copy で作成する。
  /// (ICloneable 互換)
  /// </summary>
  [MethodImpl(MethodImplOptions.AggressiveInlining)]
  public object Clone() => Clone(null);

  /// <summary>
  /// オブジェクトのクローンを Shallow Copy で作成する。
  /// 引数で指定される匿名クラスで同名プロパティの値を置き換える。
  /// </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; }

  // list_original を基にしてプロパティ Value の値を2乗にしたもうひとつのシーケンス list_converted を作成する例
  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 ||                                              // T  dst <=> T src ... acceptable
      (dst.IsGenericType                                    && // T? dst <=> T src ... acceptable
       dst.GetGenericTypeDefinition() == typeof(Nullable<>) &&
       dst.GenericTypeArguments.FirstOrDefault() == src      );

  /// <summary>
  /// プロパティの値を置換する Action を作成する。(静的メソッド)
  /// (リフレクションを極小化するための式木)
  /// </summary>
  private static Action<object, object> CreatePropertyReplaceMethod(Type type_dst, Type type_src) {
    var props = type_src.GetProperties();

    // 式木構築前の型チェック (不都合がある場合は Runtime Exception)
    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();

    // Lambda Parameters
    var o_dst = Parameter(typeof(object));              // object dst (1st param of Action)
    var o_src = Parameter(typeof(object));              // object src (2nd param of Action)
    var t_dst = Parameter(type_dst);
    var t_src = Parameter(type_src);

    // Lambda Body
    var body  =
      (Enumerable.Empty<System.Linq.Expressions.Expression>()
       .Append(Assign(t_dst, Convert(o_dst, type_dst))) // T1 t_dst = (T1)o_dst;
       .Append(Assign(t_src, Convert(o_src, type_src))) // T2 t_src = (T2)o_src;
       .Concat(props
               .Select( x =>
                        Assign(Property(t_dst, x.Name), // t_dst.Prop[x] = (t_dst.Prop[x] の型)t_src.Prop[x];
                               Convert(Property(t_src, x.Name),
                                       type_dst.GetProperty(x.Name)?.PropertyType)) ))); // 不存在チェック済み

    // Compiling Lambda
    return
      (Action<object, object>)
        Lambda<Action<object, object>>(Block(new [] { t_dst, t_src }, body), o_dst, o_src)
          .Compile();
  }

  /// <summary>
  /// オブジェクトのクローンを Shallow Copy で作成する。
  /// (ICloneable 互換)
  /// </summary>
  [MethodImpl(MethodImplOptions.AggressiveInlining)]
  public object Clone() => Clone(null);

  /// <summary>
  /// オブジェクトのクローンを Shallow Copy で作成する。
  /// 引数で指定される匿名クラスで同名プロパティの値を置き換える。
  /// </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(),  // Clone shallow-copied
       param ?? new {}       ); // Substitute
}


クラスの辞書を静的に保持してキャッシュとし、同じクラスの組み合わせが来たらプロパティを置換するアクションをキャッシュから抽出して利用する。また、置換先 Record のプロパティの型が T? のときに置換元匿名クラスのプロパティをいちいち Nullable にキャストして指定するのが面倒であるため、Nullable か否かの違いは許容するようにしている。

  // もし、置換元と置換先のプロパティの型を完全一致させなければならない場合は、こう書かなければならない
  .Select( org => org.Clone(new { Value = (int?)1 }) as RecordSample )

  // 置換先のプロパティが int? であろうがこう書きたいため、T? <=> T の違いは許容するようにしている 
  .Select( org => org.Clone(new { Value = 1 }) as RecordSample )

  // 置換元のプロパティが null である場合は、キャストせざるを得ない (匿名型は型不定のプロパティを許容しない) 
  .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 層構造で考える必要がある。メタプログラミングは (おもしろいが) 非常に疲れる。