Linq 新功能 (3) TryGetNonEnumeratedCount

這篇介紹一個有趣的新功能 – TryGetNonEnumeratedCount

本集提要
  • 框架 : .NET 6
  • 功能 : TryGetNonEnumeratedCount
說明

第一眼看到這功能還沒細究時覺得有點奇怪,原本的 Enumerable.Count<TSource>(this IEnumerable<TSource> source) 內部實作不就已經針對 ICollection<T> 和 ICollection 最佳化了 (註1),那多出 TryGetNonEnumeratedCount 這玩意的意義是甚麼? 經過一番瞎弄得到結論 : 有些沒有實作  ICollection<T> 或 ICollection 介面的類別也是會帶著類似 Count 屬性或欄位的,而 TryGetNonEnumeratedCount 要處理的就是這些目標。

【註1】當一個 IEnumerable<T> 傳遞給 Enumerable.Count 的時候,Count 內部方法會檢查這個傳進來的執行個體是否有實作  ICollection<T> 或 ICollection , 若有他就會轉型為該介面並直接從介面上取得 Count 屬性值而無需列舉整個序列。

先來為這個命名說文解字一番,通常 Try 開頭的方法我都戲稱為試試看方法,所以回傳值通常是 bool ,表示試成功了沒有,而我們真正想要獲取的值往往都藉由 out parameter 傳出來;NonEnumeratedCount 講白一點就是 『只要不需經過列舉行為的元素計數』。整體看起來就是只有在取得計數不經過列舉的時候,才會回傳 true。

Count 的流程
  1. 如果是可轉型為 ICollection<T> 則轉型後取得 ICollection<T>.Count 回傳。
  2. 如果 1 不成立,source 如果能轉型成 Iterator<TSource> (註2),則轉型後呼叫 Iterator<TSource>.GetCount(false); (註3)
  3. 如果 2 也不成立,如果是可轉型為 ICollection 則轉型後取得 ICollection.Count 回傳。
  4. 如果 3 也不成立,那就折手指,利用迴圈歷遍所有元素。

【註2】這是一個私有抽象類別,Enumerable 許多方法都會回傳這個類別的實體,例如 Where 可能會回傳 ArrayWhereIterator<TSource>,IEnumerableWhereIterator<TSource> 等等。

【註3】這個方法的宣告是:public abstract int GetCount(bool onlyIfCheap); 從參數 onlyIfCheap 可以猜得出來如果傳 true,就只允許便宜的方法 (也就是直接取得值,不經過其他可能會執行列舉的方式)。這邊傳 false 就表示再貴都要算,這種貴的計算通常會歷遍整個序列,也就是比較耗時;而在 true 的狀況下,如果沒有便宜的方式,這個方法通常會回傳小於零的值。

TryGetNonEnumeratedCount 的流程
  1. 如果是可轉型為 ICollection<T> 則轉型後取得 ICollection<T>.Count 設定給 count 參數並回傳 true。
  2. 如果 1 不成立,source 如果能轉型成 Iterator<TSource>,則轉型後呼叫 Iterator<TSource>.GetCount(true)。如果 GetCount 的結果大於等於零,則將此結果設定給 count 參數並回傳 true。
  3. 如果 2 不成立 – 這邊的不成立有兩個可能 (1) 不能轉型為 Iterator<TSource> (2) GetCount 結果小於 0 ,如果是可轉型為 ICollection 則轉型後取得 ICollection.Count 定給 count 參數並回傳 true。
  4. 如果以上都不成立則將 count = 0,並回傳 false。

經過以上比較,結論就是 TryGetNonEnumeratedCount 人如其名:絕不歷遍序列

所以在介意效能的狀況下,會建議這樣寫,一網打盡,能快則快:

 static int GetCount<T>(IEnumerable<T> source)
 {
     if (source.TryGetNonEnumeratedCount(out int count))
     {
         return count;
     }
     else
     {
         return source.Count();
     }
 }
試試誰會成功

我寫了一個範例 NoneEnumeratedCountSample 簡單來看誰會回傳 true,大致測試以下事項:

 static void Main(string[] args)
 {
     var random = new Random();
     IEnumerable<int> range = Enumerable.Range(1, 1000);
     IEnumerable<Person> people = range.Select(i => new Person
     {
         Name = $"Name_{i}",
         Age = random.Next(10, 81)
     });
     Display(range);
     Display(people);
     Display(people.ToList());
     Display(people.ToArray());
     Display(people.Where(p => p.Age > 20));
     Display(people.Select(p => p.Age));
     Display(people.Where(p => p.Age > 20).Select(p => p.Age));
     Display(people.GroupBy(x => x.Age / 10));
     Display(people.DistinctBy(x => x.Age));
     Display(people.Distinct(new PersonAgeComparer()));
     Display(people.OrderBy(x => x.Age));
     Display(people.Where(x => x.Age > 20).OrderBy(x => x.Age));

     Display(GetStrings());
     Display(GetStrings().Select(x => x));
     static IEnumerable<string> GetStrings()
     {
         yield return "One";
         yield return "Two";
         yield return "Three";
     }

     var repeat = Enumerable.Repeat(new Person { Name = "Name", Age = 30 }, 10);
     Display(repeat);

     int[] array1 = { 1, 2, 3, 4, 5 };
     int[] array2 = { 1, 3, 4, 7, 9 };
     Display(array1.Intersect(array2));
     Display(array1.Union(array2));
     Display(array1.Except(array2));

     var teachers = Program.teachers;
     var students = Program.students;
     Display(teachers.Join(students, t => t.ClassName, s => s.ClassName, (t, s) => new { t.TeacherName, s.StudentName }));
 }


 static void Display<T>(IEnumerable<T> source, [CallerArgumentExpression(nameof(source))] string expression = null)
 {
     bool success = source.TryGetNonEnumeratedCount(out int count);
     if (success)
     {
         Console.WriteLine($"Source: {expression}, Try Result: {success}, Count: {count}");
     }
 }

輸出結果:

Source: range, Try Result: True, Count: 1000
Source: people, Try Result: True, Count: 1000
Source: people.ToList(), Try Result: True, Count: 1000
Source: people.ToArray(), Try Result: True, Count: 1000
Source: people.Select(p => p.Age), Try Result: True, Count: 1000
Source: people.OrderBy(x => x.Age), Try Result: True, Count: 1000
Source: repeat, Try Result: True, Count: 10

ToList 和 ToArray 得到的 List<T> 和 T[] 應該普遍大家都知道它們有實作 ICollection<T>,剩下的比較有趣:

  1. Enumerable.Range 和 Enumerable.Repeat 可以便宜獲得計數,原因是它們也有實作 ICollection<T>。
  2. Select 和 OrderBy 會依據來源的 IEnumerable<T> 能否便宜獲得計數而定。這也是為什麼 people.Select(p => p.Age) 會是 true  ,而   people.Where(p => p.Age > 20).Select(p => p.Age) 會是 false 的原因。而有趣的也在這邊,Select 和 OrderBy 這兩個的結果並沒有實作 ICollection<T>,效能差異就是出在類似這樣的情形。
Benchmark

不免俗地還是來個效能測試

 internal class Program
 {
     static void Main(string[] args)
     {
       var summary = BenchmarkRunner.Run<NonEnumeratedCountBenchmark>();
       
     }
 }

 public class NonEnumeratedCountBenchmark
 {

     private IEnumerable<Person> _people;
     private IEnumerable<int> _people_selectAge;
     private IEnumerable<Person> _people_orderby;
     private IEnumerable<Person> _people_where_orderby;
     private IEnumerable<int> _people_selectAge_orderby;

     [GlobalSetup]
     public void Setup()
     {
         var random = new Random();
         List<Person> list = new List<Person>(1000);
         for (int i = 0; i < 1000; i++)
         {
             list.Add(new Person
             {
                 Name = $"Name_{i}",
                 Age = random.Next(10, 81)
             });
         }
         _people = list;
         _people_selectAge = _people.Select(p => p.Age);
         _people_orderby = _people.OrderBy(p => p.Age);
         _people_where_orderby = _people.Where(p => p.Age > 9).OrderBy(p => p.Age);
         _people_selectAge_orderby = _people_selectAge.OrderBy(age => age);
     }

     [Benchmark]
     public void CallCustomCount_People()
     {
         var count = CustomCount(_people);
     }

     [Benchmark]
     public void CallCount_People()
     {
         var count = _people.Count();
     }


     [Benchmark]
     public void CallCustomCount_SelectAge()
     {
         var count = CustomCount(_people_selectAge);
     }

     [Benchmark]
     public void CallCount_SelectAge()
     {
         var count = _people_selectAge.Count();
     }

     [Benchmark]
     public void CallCustomCount_OrderBy()
     {
         var count = CustomCount(_people_orderby);
     }

     [Benchmark]
     public void CallCount_OrderBy()
     {
         var count = _people_orderby.Count();
     }

     [Benchmark]
     public void CallCustomCount_WhereOrderBy()
     {
         var count = CustomCount(_people_where_orderby);
     }

     [Benchmark]
     public void CallCount_WhereOrderBy()
     {
         var count = _people_where_orderby.Count();
     }

     [Benchmark]
     public void CallCustomCount_SelectAgeOrderBy()
     {
         var count = CustomCount(_people_selectAge_orderby);
     }
     [Benchmark]
     public void CallCount_SelectAgeOrderBy()
     {
         var count = _people_selectAge_orderby.Count();
     }

     static int CustomCount<T>(IEnumerable<T> source)
     {
         if (source.TryGetNonEnumeratedCount(out int count))
         {
             return count;
         }
         else
         {
             return source.Count();
         }
     }
 }
 public class Person
 {
     public string Name { get; set; }
     public int Age { get; set; }
 }

測試結果:

// * Summary *

BenchmarkDotNet v0.14.0, Windows 11 (10.0.22631.4751/23H2/2023Update/SunValley3)
12th Gen Intel Core i7-1265U, 1 CPU, 12 logical and 10 physical cores
.NET SDK 9.0.200-preview.0.25057.12
  [Host]     : .NET 9.0.1 (9.0.124.61010), X64 RyuJIT AVX2
  DefaultJob : .NET 9.0.1 (9.0.124.61010), X64 RyuJIT AVX2


| Method                           | Mean         | Error      | StdDev     | Median       |
|--------------------------------- |-------------:|-----------:|-----------:|-------------:|
| CallCustomCount_People           |     3.442 ns |  0.1031 ns |  0.1058 ns |     3.409 ns |
| CallCount_People                 |     4.360 ns |  0.1211 ns |  0.2056 ns |     4.330 ns |
| CallCustomCount_SelectAge        |     1.655 ns |  0.0489 ns |  0.0382 ns |     1.653 ns |
| CallCount_SelectAge              | 1,269.816 ns | 29.5624 ns | 86.7013 ns | 1,253.890 ns |
| CallCustomCount_OrderBy          |    19.590 ns |  1.2523 ns |  3.6924 ns |    17.444 ns |
| CallCount_OrderBy                |    13.127 ns |  0.3050 ns |  0.5500 ns |    13.198 ns |
| CallCustomCount_WhereOrderBy     |   636.515 ns | 12.7756 ns | 19.8901 ns |   639.559 ns |
| CallCount_WhereOrderBy           |   628.010 ns | 11.7456 ns | 11.5357 ns |   628.727 ns |
| CallCustomCount_SelectAgeOrderBy |     3.471 ns |  0.0999 ns |  0.0885 ns |     3.498 ns |
| CallCount_SelectAgeOrderBy       | 1,215.613 ns | 24.1754 ns | 45.4072 ns | 1,217.556 ns |


// * Summary *

BenchmarkDotNet v0.14.0, Windows 11 (10.0.22631.4751/23H2/2023Update/SunValley3)
12th Gen Intel Core i7-1265U, 1 CPU, 12 logical and 10 physical cores
.NET SDK 9.0.200-preview.0.25057.12
  [Host]     : .NET 9.0.1 (9.0.124.61010), X64 RyuJIT AVX2
  DefaultJob : .NET 9.0.1 (9.0.124.61010), X64 RyuJIT AVX2


| Method                           | Mean         | Error      | StdDev     |
|--------------------------------- |-------------:|-----------:|-----------:|
| CallCustomCount_People           |     4.015 ns |  0.1194 ns |  0.2059 ns |
| CallCount_People                 |     3.537 ns |  0.1126 ns |  0.2030 ns |
| CallCustomCount_SelectAge        |     1.507 ns |  0.0705 ns |  0.0754 ns |
| CallCount_SelectAge              | 1,024.929 ns | 20.5122 ns | 28.7552 ns |
| CallCustomCount_OrderBy          |    14.764 ns |  0.3236 ns |  0.4429 ns |
| CallCount_OrderBy                |    11.504 ns |  0.2714 ns |  0.3432 ns |
| CallCustomCount_WhereOrderBy     |   535.035 ns | 10.5585 ns | 12.5692 ns |
| CallCount_WhereOrderBy           |   539.504 ns | 10.2631 ns | 25.7480 ns |
| CallCustomCount_SelectAgeOrderBy |     2.985 ns |  0.0838 ns |  0.0743 ns |
| CallCount_SelectAgeOrderBy       | 1,013.673 ns | 20.0785 ns | 28.1473 ns |

有明顯差異的在

  1. CallCustomCount_SelectAge vs CallCount_SelectAge
  2. CallCustomCount_SelectAgeOrderBy vs  CallCount_SelectAgeOrderBy

Benchmark 的範例在此