實現一個基于相等性比較的 GroupBy
Intro
在我們的系統里有些數據可能會有問題,數據源頭不在我們這里,數據不好修復,在做 GroupBy
的時候就會很痛苦,默認的 group by 會依賴于 HashCode
,而某些場景下 HashCode
可能并不太大做統一,所以擴展了一個不依賴 HashCode
,只需要考慮相等性比較的一個 GroupBy
Sample
我們有下面這樣的一些數據
var?students?=?new?StudentResult[]
{new()?{?StudentName?=?"Ming",?CourseName?=?"Chinese",?Score?=?80,?},new(){StudentId?=?1,?StudentName?=?"Ming",?CourseName?=?"English",?Score?=?60,},new(){StudentId?=?2,?StudentName?=?"Mike",?CourseName?=?"English",?Score?=?70,},new()?{?StudentId?=?1,?CourseName?=?"Math",?Score?=?100,?},new(){StudentName?=?"Mike",?CourseName?=?"Chinese",?Score?=?60,},
};
這些數據是一些學生成績,但是學生的信息不全,學生信息可能有 Id,可能有 Name,假設每個學生的 Id 和 Name 都是唯一的,不會重復,將上面的信息按學生分組并獲取每個學生的總分數,你會怎么實現呢?
Implement
默認的實現依賴于 HashCode
,實現源碼可以參考文末鏈接,而多個字段的 HashCode
比較難以統一,所以就想著自己擴展 GroupBy
,實現代碼如下:
GroupBy
的返回值是 IEnumerable<IGrouping<TKey, T>>
,默認的 Grouping
的 Add
方法是 internal
的
我們先自定義一個簡單 IGrouping
,實現代碼如下:
private?sealed?class?Grouping<TKey,?T>?:?IGrouping<TKey,?T>
{private?readonly?List<T>?_items?=?new();public?Grouping(TKey?key)?=>?Key?=?key????throw?new?ArgumentNullException(nameof(key));public?TKey?Key?{?get;?}public?void?Add(T?t)?=>?_items.Add(t);public?int?Count?=>?_items.Count;public?IEnumerator<T>?GetEnumerator(){return?_items.GetEnumerator();}IEnumerator?IEnumerable.GetEnumerator(){return?GetEnumerator();}
}
接著來實現我們的按相等性比較的 GroupBy
,實現如下:
public?static?IEnumerable<IGrouping<TKey,?T>>?GroupByEquality<T,?TKey>(this?IEnumerable<T>?source,Func<T,?TKey>?keySelector,Func<TKey,?TKey,?bool>?comparer)
{var?groups?=?new?List<Grouping<TKey,?T>>();foreach?(var?item?in?source){var?key?=?keySelector(item);var?group?=?groups.FirstOrDefault(x?=>?comparer(x.Key,?key));if?(group?is?null){group?=?new?Grouping<TKey,?T>(key);group.List.Add(item);groups.Add(group);}else{keyAction?.Invoke(group.Key,?item);group.List.Add(item);}}return?groups;
}
我們來測試一下我們的 GroupBy
,測試代碼:
var?groups?=?students.GroupByEquality(x?=>?new?Student()?{?Id?=?x.StudentId,?Name?=?x.StudentName?},(s1,?s2)?=>?s1.Id?==?s2.Id?||?s1.Name?==?s2.Name,?(k,?x)?=>{if?(k.Id?<=?0?&&?x.StudentId?>?0){k.Id?=?x.StudentId;}if?(k.Name.IsNullOrEmpty()?&&?x.StudentName.IsNotNullOrEmpty()){k.Name?=?x.StudentName;}});
foreach?(var?group?in?groups)
{Console.WriteLine("-------------------------------------");Console.WriteLine($"{group.Key.Id}?{group.Key.Name},?Total?score:?{group.Sum(x?=>?x.Score)}");foreach?(var?result?in?group){Console.WriteLine($"{result.StudentId}??{result.StudentName}\n{result.CourseName}??{result.Score}");}
}
輸出結果如下:
可以看到前面的數據分成了兩組,但是可以看到的數據里仍然是信息不全的,我們可以稍微改進一下上面的方法,修改后如下:
public?static?IEnumerable<IGrouping<TKey,?T>>?GroupByEquality<T,?TKey>(this?IEnumerable<T>?source,Func<T,?TKey>?keySelector,Func<TKey,?TKey,?bool>?comparer,Action<TKey,?T>??keyAction?=?null,?Action<T,?TKey>??itemAction?=?null)
{var?groups?=?new?List<Grouping<TKey,?T>>();foreach?(var?item?in?source){var?key?=?keySelector(item);var?group?=?groups.FirstOrDefault(x?=>?comparer(x.Key,?key));if?(group?is?null){group?=?new?Grouping<TKey,?T>(key){item};groups.Add(group);}else{keyAction?.Invoke(group.Key,?item);group.Add(item);}}if?(itemAction?!=?null){foreach?(var?group?in?groups.Where(g?=>?g.Count?>?1)){foreach?(var?item?in?group)itemAction.Invoke(item,?group.Key);}}return?groups;
}
增加了一個 itemAction
,這里加了一個 group count 大于 1 的條件,因為只有一個元素的時候,key 一定是來自這個元素不需要更新,所以加了一個條件,再來修改一下我們調用的示例:
var?groups?=?students.GroupByEquality(x?=>?new?Student()?{?Id?=?x.StudentId,?Name?=?x.StudentName?},(s1,?s2)?=>?s1.Id?==?s2.Id?||?s1.Name?==?s2.Name,?(k,?x)?=>{if?(k.Id?<=?0?&&?x.StudentId?>?0){k.Id?=?x.StudentId;}if?(k.Name.IsNullOrEmpty()?&&?x.StudentName.IsNotNullOrEmpty()){k.Name?=?x.StudentName;}},?(x,?k)?=>{if?(k.Id?>?0?&&?x.StudentId?<=?0){x.StudentId?=?k.Id;}if?(k.Name.IsNotNullOrEmpty()?&&?x.StudentName.IsNullOrEmpty()){x.StudentName?=?k.Name;}});
foreach?(var?group?in?groups)
{Console.WriteLine("-------------------------------------");Console.WriteLine($"{group.Key.Id}?{group.Key.Name},?Total?score:?{group.Sum(x?=>?x.Score)}");foreach?(var?result?in?group){Console.WriteLine($"{result.StudentId}??{result.StudentName}\n{result.CourseName}??{result.Score}");}
}
增加了 itemAction
,在最后將 key 的信息再同步回 group 內的各個數據,此時我們再來運行一下我們的示例,結果如下:
可以看到現在我們的數據就都有 Id 和 Name 了~~
More
我們也可以增加一個 IEqualityComparer
的重載來支持自定義的 comparer
public?static?IEnumerable<IGrouping<TKey,?T>>?GroupByEquality<T,?TKey>(this?IEnumerable<T>?source,Func<T,?TKey>?keySelector,IEqualityComparer<TKey>?keyComparer,Action<TKey,?T>??keyAction?=?null,?Action<T,?TKey>??itemAction?=?null)?where?TKey?:?notnull
{return?GroupByEquality(source,?keySelector,?keyComparer.Equals,?keyAction,?itemAction);
}
References
https://github.com/dotnet/runtime/blob/main/src/libraries/System.Linq/src/System/Linq/Grouping.cs
https://github.com/dotnet/runtime/blob/main/src/libraries/System.Linq/src/System/Linq/Lookup.cs
https://github.com/WeihanLi/WeihanLi.Common/blob/05ba92b5439bfa8623ae9b3133bf78daf4a8f6b4/src/WeihanLi.Common/Extensions/EnumerableExtension.cs#L275
https://github.com/WeihanLi/WeihanLi.Common/blob/dev/samples/DotNetCoreSample/GroupByEqualitySample.cs#L10