[食譜好菜] 在 C# 中能將集合轉化為一切的擴充函式 - Aggregate

如果有一天,微軟讓大家在 C# 裡面 Enumerable 眾多的擴充函式當中選一個留下來,我想我會選 Aggregate(),Aggregate() 算是一個滿萬用的擴充函式,從 .NET Framework 3.5 開始就一直存在,它只有三個多載方法,要額外丟的參數最多也只要三個,結構上算是簡單,但運用起來卻能千變萬化。

接下來我就將 Aggregate() 的三個多載方法一一做介紹,各做一個範例來說明 Aggregate() 的使用方法。

Aggregate(IEnumerable, Func<TSource,TSource,TSource>)

第一個多載方法只需要額外丟一個參數進去,最好理解,但也是我最少用到的,它的輸入是集合,輸出只是單一個體,簡單講就是多個變一個,只要有多個變一個的場景,都可以使用 Aggregate() 的這個多載方法來達成,但前提是輸入及輸出要是相同類別,舉凡像是數值集合的加總、字串集合的串接。

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

var sum = integers.Aggregate((accu, next) => accu + next);

var strs = new[] { "Things", "you're", "doing", "to", "your", "pet", "that", "they", "actually", "hate" };

var str = strs.Aggregate((accu, next) => string.Concat(next, " ", accu));

我很少用它的原因是,大部分的場景都有現成的擴充函式可以用,但是如果沒有的話,我第一個就會想到它。

Aggregate<TSource,TAccumulate>(IEnumerable, TAccumulate, Func<TAccumulate,TSource,TAccumulate>)

第二個多載方法是我最常用的,剛剛是多個變一個,而這個則是多個變另一個,這個「另一個」就不限定是要相同類別,也就是說另一個可以是任意類別,想像起來,第二個多載方法運用起來就比第一個更多元了。

我這邊舉一個我在工作上常遇到的例子,就是利用集合裡面的資料,加減乘除之後,算出一個結果,這個結果也是個集合,並且在這個結果的集合中,篩選出我最終想要的資料。

例如:有 500 個數字,這 500 個數字的範圍落在 232 ~ 673 之間,每個數字要算出自己的 MA5(MA=Moving Average),MA5 就是包含自己這個數字往前的 5 個數字的平均值,最後篩選出大於 MA5 的數字。

不知道各位朋友會怎麼處理?用 ForLoop 的話,應該大致上差不多像下面這樣:

var numbers = new List<decimal> { ... };
var result = new List<decimal>();

for (var i = 0; i < numbers.Count; i++)
{
    if (i < 4) continue;

    if (numbers[i] > numbers.Skip(i - 4).Take(5).Average(n => n))
    {
        result.Add(numbers[i]);
    }
}

如果改用 Aggregate() 的話,我們可以將整個運算過程封裝起來。

var numbers = new List<decimal> { ... };

var result = numbers.Aggregate(
        new { Index = default(int), Result = new List<decimal>() },
        (accu, next) =>
            {
                if (accu.Index >= 4 && next > numbers.Skip(accu.Index - 4).Take(5).Average(n => n))
                {
                    accu.Result.Add(next);
                }

                return new { Index = accu.Index + 1, accu.Result };
            })
    .Result;

由於 Aggregate() 沒有將集合的索引給丟進來,所以如果我們需要集合的索引就要自己想辦法做,那這邊我是透過一個匿名型別將索引值跟回傳值封裝在一起,最後只取回傳值。

不過我認為如果我們對集合的索引值有強烈需求的話,自己再增加一個 Aggregate() 的多載方法會是比較好的解決方式,微軟現在已經將很多 .NET API 的原始碼都放出來了,我們自己要去擴充原本的 API 變得簡單多了,我們就參考 Aggregate() 的原始碼自己再做一個可以帶索引值的 Aggregate()。

public static TAccumulate Aggregate<TSource, TAccumulate>(this IEnumerable<TSource> source, TAccumulate seed, Func<TAccumulate, TSource, int, TAccumulate> func)
{
    if (source == null) throw new ArgumentNullException(nameof(source));
    if (func == null) throw new ArgumentNullException(nameof(func));

    int index = 0;
    TAccumulate result = seed;

    foreach (TSource element in source)
    {
        result = func(result, element, index);

        index++;
    }

    return result;
}

改用我們自己寫的 Aggregate(),就變得簡潔多了。

var numbers = new List<decimal> { ... };

var result = numbers.Aggregate(
    new List<decimal>(),
    (accu, next, index) =>
        {
            if (index < 4) return accu;

            if (next > numbers.Skip(index - 4).Take(5).Average(n => n))
            {
                accu.Add(next);
            }

            return accu;
        });

Aggregate<TSource,TAccumulate,TResult>(IEnumerable, TAccumulate, Func<TAccumulate,TSource,TAccumulate>, Func<TAccumulate,TResult>)

最後是第三個多載方法,這個多載方法比第二個多載方法多了一個 Func<TAccumulate,TResult>,讓我們可以把原本運算出來的結果,丟到這個方法內再做一次運算,吐出又另一個結果,比如說,我想將剛剛篩選出來大於 MA5 的這些數字,串成一串用逗號隔開的字串。

var numbers = new List<decimal> { ... };

var result = numbers.Aggregate(
    new { Index = default(int), Result = new List<decimal>() },
    (accu, next) =>
        {
            if (accu.Index >= 4 && next > numbers.Skip(accu.Index - 4).Take(5).Average(n => n))
            {
                accu.Result.Add(next);
            }

            return new { Index = accu.Index + 1, accu.Result };
        },
    accu =>
        {
            return string.Join(",", accu.Result);
        });

不過老實說,我也很少用這第三個多載方法,如果真的有再次做運算的需求,我大都會使用其他的擴充函式。

到這邊我們可以知道,Aggregate() 其實內部也是跑迴圈,那跟我們自己用迴圈去寫一樣的運算邏輯有什麼不一樣? Enumerable 的擴充函式大都是可以互相串起來使用的,在工作上,我收到一個集合丟進來,往往也不是只有一個結果要計算,單純用迴圈來寫,我發現程式碼的結構有些鬆散,曝露太多在計算過程當中所宣告的中繼物件,後續維護上增加了一些複雜度,改用 Enumerable 的擴充函式我可以比較精準地去切割運算單元,建立一條又一條的「運算生產線」,讓我的資料在這些運算生產線上跑,甚至說有一些運算單元可以重複使用的,我就抽出來。

所以總結來說,我選擇用 Enumerable 擴充函式絕對不是效能比較好,因為底層都是跑迴圈,沒道理效能比較好,效能是取決於我們的演算法,會用 Enumerable 擴充函式的原因是,它可以讓我將程式碼切割封裝之後,再重新組合起來使用,讓程式碼看起來比較凝聚一點,以上,Aggregate() 就介紹到這邊,希望對大家有一丁點的幫助。

相關資源

C# 指南
ASP.NET 教學
ASP.NET MVC 指引
Azure SQL Database 教學
SQL Server 教學
Xamarin.Forms 教學