C#: 動的なプロパティアクセスの実装方式案と性能評価

想定する処理要件

  • 選択値に応じて異なる値を表示する画面、出力項目のカスタマイズが可能なCSVファイル生成機能、等の業務機能を想定しています。
  • このような機能では、DB等のデータストアから取得したデータをエンティティに格納し、特定のプロパティの値に応じて別のプロパティの値を取得するような設計・実装を行う場合があります。
  • ここでは、このような特定のプロパティ値に基づいて別のプロパティ値を取得する方式を「動的プロパティアクセス」とします。前者の取得する値を決定するためのプロパティを「選択プロパティ」、後者のプロパティを「被選択プロパティ」と区別します。
  • ここで紹介するサンプルでは、次のTestEntityクラスを使用します。
    選択プロパティSelectionに、値プロパティ群のいずれかのプロパティ名を格納します。例えば、選択プロパティSelectionの値が”Prop29″の場合、被選択プロパティProp29の値を取得します。

    public class TestEntity
    {
        public string Selection { get; set; }
    
        public string Prop01 { get; set; }
        public string Prop02 { get; set; }
    ...
        public string Prop29 { get; set; }
        public string Prop30 { get; set; }
    }

実現方式案

  • 業務で良く見る設計・実装をイメージしたものが案A~Dです。案E, Fは、より最適化した案です。
  • 各方式のサンプルは参考をご覧ください。
方式案説明
A) switch版選択プロパティの値をswitch文で判定して、被選択プロパティの値を取得する。
B) リフレクション版プロパティ定義(PropertyInfo)を使用して、選択プロパティ値のプロパティ名から被選択プロパティの値を取得する。
C) 一時Dictionary版被選択プロパティのプロパティ名・値の組を格納するDictionaryを一時的に作成する。選択プロパティ値のプロパティ名をキーとして、Dictionaryから被選択プロパティの値を取得する。
D) リフレクションDictionary版被選択プロパティのプロパティ名・プロパティ定義(PropertyInfo)の組を格納するDictionaryを事前に作成する。選択プロパティ名をキーとして、Dictionaryからプロパティ定義を取得し、その定義を使用して被選択プロパティ値を取得する。
E) ラムダ式Dictionary版被選択プロパティのプロパティ名・プロパティ値を取得するラムダ式の組を格納するDictionaryを事前に作成する。選択プロパティ名をキーとして、Dictionaryからラムダ式を取得し、そのラムダ式を使用して被選択プロパティの値を取得する。
F) ラムダ式リスト版被選択プロパティの値を取得するためのラムダ式を事前にリストに格納する。また、選択プロパティの値は、被選択プロパティを識別するためのインデックスとする。選択プロパティの値をインデックスとして、リストからラムダ式を取得し、そのラムダ式を使って被選択プロパティの値を取得する。

測定条件と結果

測定条件

  • 前述のTestEntityデータから任意の被選択プロパティ(合計30プロパティのいずれか)の値を取得するサンプルを使用します。
  • TestEntityデータ件数は100万件を対象とし、各方式で処理時間を測定します。
  • 各エンティティでは、選択プロパティSelectionに”Prop01″, “Prop30″等のランダムなプロパティ名、被選択プロパティの各値には”[00001]value01″等の適当な文字列を設定しています。
  • 測定を正確にするために、各方式で5回測定しています。

測定環境

  • 測定に使用した環境は次の通りです。
    OSWindows 10(64ビット)
    IDEMicrosoft Visual Studio Community 2022(17.6.0)
    言語C#(10.0) + .NET6
    ハードウェアCPU: AMD Ryzen 5 3400G, MEM: 16GB, SSD: 130GB

測定結果

考察

  • 処理時間の最大は「C) 一時Dictionary版」(約7倍)、最小は「F) ラムダ式リスト」(約0.6倍)でした。
  • 「C) 一時Dictionary版」の処理時間は、標準方式に比べ約7倍で最大です。
    • キーに対応する値を高速に探索する場合、「ハッシュ探索を実装したHashTableやDictionary等のコレクションを使用する」(計算量が「O(1)」)というのが定石であり、当方式案はこの考えに基づいています。
    • ですが、コレクションインスタンス(Dictionary)を作成するのはコストが高いようです。何度か値を取得するような想定であれば、トータルの処理時間は低くなる可能性がありますが、今回のように1回のみの取得だと、効果が低くなります。
  • 「B) リフレクション版」「D) リフレクションDictionary版」は、標準方式より約1.2~1.3倍の処理時間になっています。
    • リフレクションによる「プロパティの定義(PropertyInfo)の取得」、「プロパティ定義を使ったプロパティ値の取得」のコストが高いようです。
    • なお、この方式は被選択プロパティの数に依存しないため、将来的な被選択プロパティの追加に対して、改修なしで対応できます。処理時間や性能を気にしないなら、この方式を採用することも考えられます。
  • 「E) ラムダ式Dictionary版」は標準的な方式の約0.8倍です。
    • 被選択プロパティの選択に関して、switchによる条件分岐より、事前に作成したDictionaryを使った方がコストが低いことが分かります。また、ラムダ式によるプロパティ値の取得は、標準方式(単純な代入)と同等のコストのようです。
    • ラムダ式はコンパイルされた関数であり、実行時にクラス・インスタンスの解析を行うリフレクションより高速です。
      (.net – In C#, Is Expression API better than Reflection – Stack Overflow)
  • 「E) ラムダ式リスト版」の処理時間は、標準方式に比べ約0.6倍の最小です。
    • 被選択プロパティの選択にインデックス(整数)を採用しており、「E) ラムダ式Dictionary版」のようなハッシュ探索処理が不要になるからだと考えられます。
  • 感想
    • 測定対象の100万件でも最大で2秒弱程度です。一般的なシステムの性能要件の範囲内だと思われます。下手な工夫をするより、if/switch等の標準的な条件分岐を使用した方が無難な処理時間になります。性能改善を考える場合、本稿のように測定と評価が必要になります。
    • 性能要件が厳しい場合や、同時実行数が多い場合、より性能を向上(CPUやメモリ等の使用リソースを削減)するために、「E) ラムダ式Dictionary版」「F) ラムダ式リスト」のような実装が参考になります。

測定のためのサンプルコード

完全なソースコードはgithubで公開しています。
(Visual Studio 2022のC#クラスライブラリプロジェクト)

各方式の処理時間を測定するコード

  • 測定プログラムはxUnitのテストコードとして実装しました。
    • 100万件のテストデータ(エンティティ)を作成します。
    • 各方式をGetValuesBySwitch(), GetValuesByReflection()等のメソッドとして実装し、各メソッドを5回実行して処理時間の平均を出力します。
    • 念のため、各方式で取得した被選択プロパティの値が一致することを、Assert.Equal()で検証しています。(測定先頭のswitch版による出力結果リストを、他の方式の出力結果リストと照合しています。)
    private const int TestCount = 5; // 各方式の測定回数
    private ITestOutputHelper _output;
    public ExampleTest(ITestOutputHelper output)
    {
        _output = output;
    }
    
    [Fact]
    public void EvaluatePerformance()
    {
        var sw = new Stopwatch();
        var list = TestEntity.CreateTestEntityList(1_000_000); // テストデータ100万件
    
        // 各方式のラムダ式
        var testType = new (string, Func<IEnumerable<TestEntity>, IEnumerable<string>>)[]
        {
            ("switch", e => GetValuesBySwitch(list)), // 方式A
            ("ref", e => GetValuesByReflection(list)), // 方式B
            ("temp dic", e => GetValuesByTempDic(list)), // 方式C
            ("ref dic", e => GetValuesByReflectionDic(list)), // 方式D
            ("lamda dic", e => GetValuesByLambdaDic(list)), // 方式E
            ("lamda list", e => GetValuesByLambdaList(list)) // 方式F
        };
    
        // 各方式を複数回実行して処理時間を集計
        // (実行順による偏りを減らすために、それぞれN回ではなく、全種類×N回ループ)
        var sum = new long[testType.Length];
        for (var i = 0; i < TestCount; i++)
        {
            IEnumerable<string>? expectedList = null;
            for (var j = 0; j < testType.Length; j++)
            {
                GC.Collect();
                sw.Restart();
                var typeFunc = testType[j].Item2;
                var resultList = typeFunc(list);
                sw.Stop();
                sum[j] += sw.ElapsedMilliseconds;
    
                expectedList ??= resultList;
                Assert.Equal(expectedList, resultList);
            }
        }
    
        // 各方式の平均処理時間を出力
        for (var i = 0; i < testType.Length; i++)
        {
            var typeName = testType[i].Item1;
            _output.WriteLine("({0,-10}): {1,6:#,#} [ms]", typeName, sum[i] / TestCount);
        }
    }
  • テストデータを作成するコードは次の通りです。
    • 選択プロパティSelectionにはランダムなプロパティ名、被選択プロパティには適当な文字列を設定しています。
    public class TestEntity
    {
    ...
        public static IEnumerable<TestEntity> CreateTestEntityList(int size)
        {
            var random = new Random();
            var list = new List<TestEntity>();
            for (int i = 0; i < size; i++)
            {
                var index = random.Next() % 30;
                var propNo = index + 1;
                var prefix = string.Format("[{0:00000}]-", i);
                list.Add(new TestEntity
                {
                    Selection = "Prop" + propNo.ToString("00"),
                    Selction2 = (PropType)index,
                    Prop01 = prefix + "value01",
                    Prop02 = prefix + "value02",
    ...
                    Prop29 = prefix + "value29",
                    Prop30 = prefix + "value30"
                });
            }
            return list;
        }
    }
  • 実行結果(xUnit)の例は次の通りです。
    • 各方式毎の平均の処理時間(ミリ秒)を出力します。

A) switch版のコード

  • switch文(switch式)を使用して、被選択プロパティの値を取得します。
public static List<string> GetValuesBySwitch(IEnumerable<TestEntity> entityList)
{
    var list = new List<string>();
    foreach (var entity in entityList)
    {
        string val = entity.Selection switch
        {
            "Prop01" => entity.Prop01,
            "Prop02" => entity.Prop02,
...
            "Prop29" => entity.Prop29,
            "Prop30" => entity.Prop30,
            _ => throw new InvalidOperationException(),
        };
        list.Add(val);
    }
    return list;
}

B) リフレクション版のコード

  • Selectionに格納されるプロパティ名のプロパティ定義(PropertyInfo)を取得します。
  • このプロパティ定義を使って、被選択プロパティの値を取得します。
public static List<string> GetValuesByReflection(IEnumerable<TestEntity> entityList)
{
    var entityType = typeof(TestEntity);
    var list = new List<string>();
    foreach (var entity in entityList)
    {
        var propInfo = entityType.GetProperty(entity.Selection);
        var val = propInfo?.GetValue(entity)?.ToString() ?? "";
        list.Add(val);
    }
    return list;
}

C) 一時Dictionaryを使用したプロパティ値の取得方式

  • 被選択プロパティ名・値を組としたDictionaryを一時的に作成します。
  • Selectionに格納されるプロパティ名をキーとして、このDictionaryから被選択プロパティの値を取得します。
public static List<string> GetValuesByTempDic(IEnumerable<TestEntity> entityList)
{
    var list = new List<string>();
    foreach (var entity in entityList)
    {
        var val = new Dictionary<string, string>
        {
            ["Prop01"] = entity.Prop01,
            ["Prop02"] = entity.Prop02,
...
            ["Prop29"] = entity.Prop29,
            ["Prop30"] = entity.Prop30
        }[entity.Selection];
        list.Add(val);
    };
    return list;
}

D) リフレクションを使用したプロパティ値の取得方式

  • 被選択プロパティ名・プロパティ定義(PropertyInfo)を組としたDictionaryを事前に作成します。
  • Selectionに格納されるプロパティ名をキーとして、このDictionaryからプロパティ定義を取得し、この定義を使って被選択プロパティの値を取得します。
public static List<string> GetValuesByReflectionDic(IEnumerable<TestEntity> entityList)
{
    var list = new List<string>();
    foreach (var entity in entityList)
    {
        var propInfo = refDic[entity.Selection];
        var val = propInfo?.GetValue(entity)?.ToString() ?? "";
        list.Add(val);
    }
    return list;
}

private static Dictionary<string, PropertyInfo?> refDic = new()
{
    ["Prop01"] = typeof(TestEntity).GetProperty("Prop01"),
    ["Prop02"] = typeof(TestEntity).GetProperty("Prop02"),
...
    ["Prop29"] = typeof(TestEntity).GetProperty("Prop29"),
    ["Prop30"] = typeof(TestEntity).GetProperty("Prop30")
};

E) ラムダ式Dictionaryを使用したプロパティ値の取得方式

  • 被選択プロパティのプロパティ名・プロパティ値を取得するためのラムダ式を組としたDictionaryを事前に作成します。
  • Selectionに格納されるプロパティ名をキーとして、このDictionaryからラムダ式を取得し、このラムダ式を使って被選択プロパティの値を取得します。
public static List<string> GetValuesByLambdaDic(IEnumerable<TestEntity> entityList)
{
    var list = new List<string>();
    foreach (var entity in entityList)
    {
        var getter = lambdaDic[entity.Selection];
        list.Add(getter(entity));
    }
    return list;
}

private static readonly Dictionary<string, Func<TestEntity, string>> lambdaDic = new()
{
    ["Prop01"] = e => e.Prop01,
    ["Prop02"] = e => e.Prop02,
...
    ["Prop29"] = e => e.Prop29,
    ["Prop30"] = e => e.Prop30
};

F) ラムダ式リストを使用したプロパティ値の取得方式

  • 選択プロパティの型を整数に変更(Selection2)し、整数で被選択プロパティを識別するものとします。この整数をインデックスとして、対応する被選択プロパティを取得するラムダ式のリストを事前に作成します。例えば、リストのインデックス0にはProp01の値を取得するラムダ式、インデックス29にはProp30の値を取得するラムダ式を格納します。
  • 選択プロパティに格納された整数をインデックスとして、リストからラムダ式を取得し、そのラムダ式を使用して被選択プロパティの値を取得します。
    例えば、Selection2が29の場合、リストの29番目のProp30プロパティの値を取得するラムダ式を取得します。このラムダ式を使用して、Prop30の値を取得します。
public static List<string> GetValuesByLambdaList(IEnumerable<TestEntity> entityList)
{
    var list = new List<string>();
    foreach (var entity in entityList)
    {
        var getter = lambdaList[(int)entity.Selction2];
        list.Add(getter(entity));
    }
    return list;
}

private static readonly List<Func<TestEntity, string>> lambdaList = new()
{
    e => e.Prop01,
    e => e.Prop02,
...

    e => e.Prop29,
    e => e.Prop30
};
public class TestEntity
{
    public PropType Selction2 { get; set; }

    public string Prop01 { get; set; }
    public string Prop02 { get; set; }
...
    public string Prop29 { get; set; }
    public string Prop30 { get; set; }
}

public enum PropType
{
    Prop01 = 0,
    Prop02,
...
    Prop29,
    Prop30
}