這篇介紹一個有趣的新功能 – 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 的流程
- 如果是可轉型為 ICollection<T> 則轉型後取得 ICollection<T>.Count 回傳。
- 如果 1 不成立,source 如果能轉型成 Iterator<TSource> (註2),則轉型後呼叫 Iterator<TSource>.GetCount(false); (註3)
- 如果 2 也不成立,如果是可轉型為 ICollection 則轉型後取得 ICollection.Count 回傳。
- 如果 3 也不成立,那就折手指,利用迴圈歷遍所有元素。
【註2】這是一個私有抽象類別,Enumerable 許多方法都會回傳這個類別的實體,例如 Where 可能會回傳 ArrayWhereIterator<TSource>,IEnumerableWhereIterator<TSource> 等等。
【註3】這個方法的宣告是:public abstract int GetCount(bool onlyIfCheap); 從參數 onlyIfCheap 可以猜得出來如果傳 true,就只允許便宜的方法 (也就是直接取得值,不經過其他可能會執行列舉的方式)。這邊傳 false 就表示再貴都要算,這種貴的計算通常會歷遍整個序列,也就是比較耗時;而在 true 的狀況下,如果沒有便宜的方式,這個方法通常會回傳小於零的值。
TryGetNonEnumeratedCount 的流程
- 如果是可轉型為 ICollection<T> 則轉型後取得 ICollection<T>.Count 設定給 count 參數並回傳 true。
- 如果 1 不成立,source 如果能轉型成 Iterator<TSource>,則轉型後呼叫 Iterator<TSource>.GetCount(true)。如果 GetCount 的結果大於等於零,則將此結果設定給 count 參數並回傳 true。
- 如果 2 不成立 – 這邊的不成立有兩個可能 (1) 不能轉型為 Iterator<TSource> (2) GetCount 結果小於 0 ,如果是可轉型為 ICollection 則轉型後取得 ICollection.Count 定給 count 參數並回傳 true。
- 如果以上都不成立則將 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>,剩下的比較有趣:
- Enumerable.Range 和 Enumerable.Repeat 可以便宜獲得計數,原因是它們也有實作 ICollection<T>。
- 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 |
有明顯差異的在
- CallCustomCount_SelectAge vs CallCount_SelectAge
- CallCustomCount_SelectAgeOrderBy vs CallCount_SelectAgeOrderBy
Benchmark 的範例在此。