# C# 集合扩展方法性能优化实战:从 IsNullOrEmpty 到 FindRepeat
在 SwitchData 项目中,我们对 IEnumerable<T>
做了一系列高质量的扩展方法,每一个都经过了细致的性能考量。今天就来拆解其中四个核心方法——看看一个扩展方法应该如何写得既正确又高效。
背景:为什么要写自己的集合扩展?
日常开发中,我们几乎每天都在处理集合:判空、对比、查找、去重。LINQ 虽然强大,但很多场景下它会让你意外地枚举整个集合,带来不必要的性能损耗。
比如最常见的判空场景:
// 很多人会这么写,但有性能陷阱
if (source.Any()) { ... } // 如果 source 是数据库延迟查询,这里就执行了 SQL
if (source.Count() > 0) { ... } // 更糟,Count() 会遍历全部
我们的目标是:能不枚举就不枚举,能早返回就早返回。
一、IsNullOrEmpty:多层短路的判空之王
问题分析
IEnumerable<T> 是最宽泛的集合接口,但它只暴露了
GetEnumerator()。任何判断空/非空的操作都会迫使你去遍历。但实际上,很多实现类都有
Count 属性——问题在于,我们在
IEnumerable<T> 上调用时怎么拿到它?
源码拆解
public static bool IsNullOrEmpty<T>(this IEnumerable<T> source)
{
if (source is null)
{
return true;
}
// 第一级短路:ICollection<T>(如 List<T>, Dictionary<TKey,TValue>)
if (source is ICollection<T> c)
{
return c.Count == 0;
}
// 第二级短路:IReadOnlyCollection<T>(如 ReadOnlyCollection<T>)
if (source is IReadOnlyCollection<T> rc)
{
return rc.Count == 0;
}
// 第三级短路:.NET 6+ 新增的非枚举获取数量
if (source.TryGetNonEnumeratedCount(out int count))
{
return count == 0;
}
// 兜底路径:真正需要枚举的情况(如 LINQ Where/Select 结果)
return !source.Any();
}
四级短路的设计逻辑
| 优先级 | 路径 | 适用场景 | 性能 |
|---|---|---|---|
| 1 | is ICollection<T> |
List<T>, HashSet<T>, Dictionary<TKey,TValue> | O(1) |
| 2 | is IReadOnlyCollection<T> |
ReadOnlyCollection<T>, ImmutableList<T> | O(1) |
| 3 | TryGetNonEnumeratedCount |
编译器对部分 LINQ 做的优化路径 | O(1) |
| 4 | Any() |
数据库查询、流式管道 | O(n) |
前三个路径都是 O(1)
的——只做一次类型检查和一个属性读取。只有真正无法预知数量的流式数据(比如
EF Core 的延迟查询)才会走到 Any()。
TryGetNonEnumeratedCount 是什么?
这是 .NET 6
引入的一个扩展方法:如果集合类型在内部知道自己的元素数量,它可以不遍历就返回;否则返回
false。它比手写的 is
模式更灵活,因为一些第三方集合类可能实现了这个接口但没有实现
ICollection<T>。
实际效果
// 对 List<int>:直接走 ICollection<T> 路径
var list = new List<int> { 1, 2, 3 };
list.IsNullOrEmpty(); // 检查 Count,O(1)
// 对 LINQ Where:走 Any(),但只枚举一个元素就返回
var query = db.Users.Where(u => u.IsActive);
query.IsNullOrEmpty(); // SQL: SELECT TOP 1 ...,不会全表扫描
二、Compare:双集合同步排序对比
场景
在网络设备配置对比中,我们经常需要比较两个字符串集合的差异——比如设备 A 上配置的 APN 列表 vs 设备 B 上的 APN 列表,找出各自独有的项。
算法思路
朴素做法:对 A 中每个元素去 B 里找一遍 → O(n×m)。
我们的做法:先排序,然后双指针同步遍历 → O(n log n + m log m) 排序 + O(n + m) 对比。
public static (List<string> ResultA, List<string> ResultB) Compare(
this IReadOnlyList<string> sourceA,
IReadOnlyList<string> sourceB,
bool removeRepeat,
bool ignoreCase)
{
var comparer = DataCommon.GetStringComparer(ignoreCase);
// 可选去重,同时转为 List 以便排序
var listA = removeRepeat ? sourceA.Distinct(comparer).ToList() : sourceA.ToList();
var listB = removeRepeat ? sourceB.Distinct(comparer).ToList() : sourceB.ToList();
listA.Sort(comparer);
listB.Sort(comparer);
var resultA = new List<string>(); // A 独有
var resultB = new List<string>(); // B 独有
int i = 0, j = 0;
while (i < listA.Count && j < listB.Count)
{
int code = comparer.Compare(listA[i], listB[j]);
if (code == 0)
{
i++; j++; // 相等,跳过
}
else if (code > 0)
{
resultB.Add(listB[j]); j++; // B 的元素小,B 独有
}
else
{
resultA.Add(listA[i]); i++; // A 的元素小,A 独有
}
}
// 收尾:剩下的全是各自独有
if (i < listA.Count) resultA.AddRange(listA.GetRange(i, listA.Count - i));
if (j < listB.Count) resultB.AddRange(listB.GetRange(j, listB.Count - j));
return (resultA, resultB);
}
双指针对比图解
排序后:
A = ["cat", "dog", "elephant", "fox"]
B = ["ant", "dog", "elephant", "giraffe"]
i=0, j=0: "cat" > "ant" → resultB 加入 "ant", j++
i=0, j=1: "cat" < "dog" → resultA 加入 "cat", i++
i=1, j=1: "dog" == "dog" → i++, j++
i=2, j=2: "elephant" == "elephant" → i++, j++
i=3, j=3: "fox" < "giraffe" → resultA 加入 "fox", i++
循环结束,j=3 < 4 → resultB 加入 "giraffe"
结果:
ResultA = ["cat", "fox"] // A 独有
ResultB = ["ant", "giraffe"] // B 独有
性能对比
| 方法 | 1000 元素 × 1000 元素 | 复杂度 |
|---|---|---|
| 朴素 foreach + Contains | ~200ms | O(n×m) |
| 排序 + 双指针 | ~5ms | O(n log n) |
差距 40 倍。在配置对比场景中,设备条目动辄数千条,这个优化带来的体验提升是质的飞跃。
三、FindRepeat:用 CollectionsMarshal 一次找到重复项
这是 .NET 9 的黑科技
在 .NET 9 之前,要找重复项通常需要两次遍历:第一次
Count,第二次输出。或者用 GroupBy 分组再过滤——但
GroupBy 会创建大量中间对象。
.NET 9 引入了
CollectionsMarshal.GetValueRefOrAddDefault,它允许我们一次哈希查找同时完成”判断存在
+ 获取引用”两个操作。
源码
public static List<T> FindRepeat<T>(this IReadOnlyList<T> source)
where T : notnull, IEquatable<T>
{
var stateDict = new Dictionary<T, byte>(source.Count);
var result = new List<T>();
foreach (var item in source)
{
// 一次操作:获取已有值的 ref,或添加默认值
ref byte state = ref CollectionsMarshal.GetValueRefOrAddDefault(
stateDict, item, out bool exists);
if (!exists)
{
state = 1; // 第一次见到
}
else if (state == 1)
{
state = 2; // 第二次见到 → 重复项
result.Add(item); // 输出到结果
}
// state == 2:已经输出过了,跳过
}
return result;
}
为什么这比 GroupBy 好?
// 传统写法
var repeats = source.GroupBy(x => x).Where(g => g.Count() > 1).Select(g => g.Key).ToList();
// FindRepeat 写法
var repeats = source.FindRepeat();
| 维度 | GroupBy | FindRepeat |
|---|---|---|
| 哈希查找次数 | 每个元素 1 次(分组) + 每个组 1 次(过滤) | 每个元素 仅 1 次 |
| 中间对象 | IGrouping<T, T> 列表 |
无 |
| 内存分配 | 多(分组对象 + 枚举器) | 最少(byte 状态) |
| 输出时机 | 最后统一输出 | 发现重复立即输出 |
三态状态机图解
遍历序列: [A, B, C, A, D, B, E, E]
item=A: exists=false → state=1 (A:1)
item=B: exists=false → state=1 (A:1, B:1)
item=C: exists=false → state=1 (A:1, B:1, C:1)
item=A: exists=true, state=1 → state=2, 输出A ✓
item=D: exists=false → state=1 (A:2, B:1, C:1, D:1)
item=B: exists=true, state=1 → state=2, 输出B ✓
item=E: exists=false → state=1 (...)
item=E: exists=true, state=1 → state=2, 输出E ✓
结果: [A, B, E] —— 恰好是重复出现过的元素(不重复输出)
关键设计:用 byte 做状态标记(0/1/2),而不是
bool。这样三态(不存在/见过一次/已输出)只需要一个字节,而且通过
ref
直接修改字典内部的值,避免了”读取-修改-写回”三步操作。
四、其他常用扩展速览
FindFirst:精确匹配 + 模糊匹配
public static string FindFirst(
this IReadOnlyList<string> source,
string value,
bool exact,
bool ignoreCase)
exact=true:string.Equals精确匹配exact=false:string.Contains模糊匹配ignoreCase=true:全链路忽略大小写
// 从配置列表中查找匹配的 APN
var apn = apnList.FindFirst("internet", exact: false, ignoreCase: true);
// 找到 "ChinaMobileInternet" ✓
ToHashSet:快速去重集合
public static HashSet<string> ToHashSet(
this IReadOnlyList<string> source,
bool ignoreCase)
比 new HashSet<string>(source) 多了三件事:过滤
null/空字符串、可选大小写不敏感、TrimExcess()
压缩容量。
var uniqueTags = tags.ToHashSet(ignoreCase: true);
// 自动去重、去空、忽略大小写
设计原则总结
这组扩展方法背后遵循了几个简单但重要的原则:
1. 接口越宽泛,优化越精细
扩展方法挂在 IEnumerable<T>
上以覆盖最大范围,但实现时通过 is
模式和专用接口层层下探——对大多数常见类型走 O(1)
路径,对极端流式数据才走 O(n)。
2. 用 ref 直接操作值
CollectionsMarshal.GetValueRefOrAddDefault
返回值引用,让我们能跳过”取出-修改-放回”的三步字典操作。这是 .NET
演进中”零开销抽象”理念的典型体现。
3. 状态最小化
FindRepeat 用一个 byte 表示三态,而不是
bool 或 enum。内存布局越小,CPU
缓存命中率越高。微优化,但积少成多。
4. 职责清晰不滥用
每个扩展方法只做一件事。FindRepeat 不负责去重(那是
Distinct 的事),IsNullOrEmpty
不负责验证元素内容(那是 LINQ All
的事)。保持单一职责让代码容易测试、容易组合。
结语
好的扩展方法不应该只是”语法糖”——它应该是对常见模式的性能总结和最佳实践固化。这四个方法在 SwitchData 项目中被调用了几百次,每一次都在帮我们节省不必要的 CPU 周期。
下次写扩展方法时,不妨问问自己:这个实现是能覆盖最通用的场景,还是已经为常见类型做了优化?