ValueTupleとAggregateを使ってMin,Max,Sum,Count,Avgerageを同時集計する

  • C#
  • LINQ

初めまして、iiweisです。
ブログを始めようと思い立ち早一年、いつの間にか年が明けていましたが
ようやく重い腰を上げて一記事目を書いています。

ということで今回はLINQの集計関数に関するネタをご紹介します。

LINQで最小、最大、合計、要素数、平均を求めるとき、
通常はこのようにすると思います。

var values = new int[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };

var min = values.Min(); // 最小
var max = values.Max(); // 最大
var sum = values.Sum(); // 合計
var count = values.Count(); // 要素数
var avg = values.Average(); // 平均

ただこれだと各関数呼び出しの度にループが走ってしまうので、
パフォーマンスが気になるというか、なんか無駄な感じがしてすっきりしなくないですか?
ループを1回で済ませられるならそのほうがいいですよね。

そこで、ValueTuple構造体とEnumerable.Aggregateメソッドの登場です。
この2つを組み合わせれば、一度のループ(関数呼び出し)で最小、最大、合計、要素数、平均を同時に集計できます。
各集計値毎の一時変数を用意する手間などもありません。

具体的なコードは以下の通りです。

var values = new int[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };

var result = values.
    Aggregate(
        default((int? Min, int? Max, int Sum, int Count)), // 初期値
        (x, value) =>
        {
            if(!(x.Min?.CompareTo(value) < 0)) x.Min = value;
            if(!(x.Max?.CompareTo(value) > 0)) x.Max = value;
            x.Sum += value;
            ++x.Count;
            return x;
        },
        x => (x.Min, x.Max, x.Sum, x.Count, Avg: x.Count == 0 ? (double?)null : (double)x.Sum / x.Count )
    );

Console.WriteLine($"{nameof(result.Min)} = {result.Min.ToString()}"); // Min = 1
Console.WriteLine($"{nameof(result.Max)} = {result.Max.ToString()}"); // Max = 10
Console.WriteLine($"{nameof(result.Sum)} = {result.Sum.ToString()}"); // Sum = 55
Console.WriteLine($"{nameof(result.Count)} = {result.Count.ToString()}"); // Count = 10
Console.WriteLine($"{nameof(result.Avg)} = {result.Avg.ToString()}"); // Avg = 5

まず、Enumerable.Aggregateメソッドの第一引数にValueTuple構造体の値を渡します。
ここで渡したValueTuple構造体の値に対して後述する集計処理での結果を格納していくことになるので、
メンバー名は適宜わかりやすい名前をつけてください。

// default値で渡すパターン
default((int? Min, int? Max, int Sum, int Count))

// 初期値を指定して渡すパターン
(Min: -1, Max: -1, Sum: 0, Count: 0)

第二引数には、各要素に対して行う集計処理を記述します。
ここで重要になるのは、「第一引数に渡している値がValueTuple構造体である」という点です。

構造体はガイドライン的には書き換え不能(immutable)に作るべきとされていますが、
ValueTuple構造体のメンバーは書き換え可能なため、集計結果を直接格納できます。

(x, value) =>
{

    // !(Min.HasValue && Min < value) と同義
    if(!(x.Min?.CompareTo(value) < 0)) x.Min = value; // 最小値の算出

    // !(Max.HasValue && Max > value) と同義
    if(!(x.Max?.CompareTo(value) > 0)) x.Max = value; // 最大値の算出

    // 合計値の算出
    x.Sum += value;

    // 要素数の算出
    ++x.Count;

    return x;
}

第三引数では、第二引数で行われた集計の結果を最終的な結果値に変換します。
今回は集計した合計値、要素数から平均値を算出して、
他の集計値と一緒に新たなValueTuple構造体のインスタンスを作って返しています。

// 要素数が0なら平均値はnullにする(0除算対策)
x => (x.Min, x.Max, x.Sum, x.Count, Avg: x.Count == 0 ? (double?)null : (double)x.Sum / x.Count)

これで1度のループで最小、最大、合計、要素数、平均を同時に求めることができました。
下記のベンチマーク結果からも分かるように、パフォーマンスも改善されています。(測定にはBenchmarkDotNetを使用)

BenchmarkDotNet=v0.12.1, OS=Windows 10.0.19042
Intel Core i7-1065G7 CPU 1.30GHz, 1 CPU, 8 logical and 4 physical cores
.NET Core SDK=5.0.102
  [Host]   : .NET Core 5.0.2 (CoreCLR 5.0.220.61120, CoreFX 5.0.220.61120), X64 RyuJIT
  ShortRun : .NET Core 5.0.2 (CoreCLR 5.0.220.61120, CoreFX 5.0.220.61120), X64 RyuJIT

Job=ShortRun  IterationCount=1  LaunchCount=1
WarmupCount=1

|        Method |     Mean | Error | Ratio |  Gen 0 | Gen 1 | Gen 2 | Allocated |
|-------------- |---------:|------:|------:|-------:|------:|------:|----------:|
|  CallOneByOne | 266.2 ns |    NA |  1.00 | 0.0305 |     - |     - |     128 B |
| CallAggregate | 139.5 ns |    NA |  0.52 | 0.0076 |     - |     - |      32 B |

ということで、ValueTuple構造体とEnumerable.Aggregateメソッドを使った同時集計の方法でした。
皆さんもぜひ使ってみてください。