C# 泛型、相等与 LINQ 延迟执行
前置:值、方法与接口。本页讨论 .NET 10 的内存集合 LINQ;IQueryable 的数据库提供者可能翻译表达式,不应把本页运行次数结论直接移植到数据库查询。
泛型集合与变型
List<T> 表达可变有序序列,HashSet<T> 表达去重,Dictionary<TKey,TValue> 表达键到值。泛型让编译器知道元素类型;List<object> 接受字符串,不意味着 List<string> 能赋给它,否则别人就能向字符串列表塞整数。只生产值的 IEnumerable<out T> 支持引用类型的协变,例如把 IEnumerable<string> 赋给 IEnumerable<object>;值类型不会因此免去装箱。需要约束时使用 where T : ... 表达调用要求,不用无依据的强制转换。
字典查不存在键会抛 KeyNotFoundException;用 TryGetValue 一次表达存在性和取值。GetValueOrDefault 适合缺项本来就对应默认值的场景,无法区分“键不存在”和“键值就是默认值”。本项目计数从0开始,两者恰好等价。
Where、Select、枚举与 ToArray
构造 Where/Select 通常只是创建迭代规则,消费才执行委托。完整 Lab.cs 在下方,首先建立 [1,2,3] 的查询,再加入4,随后 Sum 与 Count 都看到新元素,各次消费会重新访问源;它不是建立查询时的快照。ToArray 消费一次并存储结果;后来源列表加入5时,数组仍保持原结果。由此会出现两类常见错误:为了日志调用 Count 后再 foreach,意外做两遍昂贵工作;把可变源的延迟查询交给其他层,真正消费时状态已经变了。
对于一次消费的纯转换,保留延迟通常减少中间数组;对于反复消费且要求稳定结果,物化能明确时间边界。不是所有算子都逐项输出:OrderBy 必须先读入并排序,GroupBy 保留分组数据,ToDictionary 会立即枚举并可能因重复键失败。不要因代码链式写法就称为固定内存流式处理。枚举中修改 List 常会触发 InvalidOperationException;跨线程并发修改也不能因使用 LINQ 自动安全。
相等、排序与键的不变量
C#字典的键依赖相等与哈希契约:Dictionary按哈希查找,不能在插入后修改参与相等比较的字段;相等键的哈希必须一致。
HashSet/Dictionary 依赖相等比较与 hashCode 的一致性:相等值必须具有相同哈希,但哈希相同不保证相等。插入后改变影响键相等性的字段,会让查询落入错误桶;优先使用不可变键。字符串业务需明确大小写与文化:文件分类代号采用 StringComparer.Ordinal,排序稳定按代码单元规则;用户界面的人名排序可能需要文化规则,不能混称“字母顺序”。
项目用 SortedDictionary 保证输出有序,查找与插入为树的比较成本;若只最终排序,Dictionary 累加后排序键也可行。选择由查询模式决定,不在没有数据前宣布哪一种更快。OrderBy(x=>x.Name) 与 OrderBy(x=>x.Name,StringComparer.Ordinal) 对文化环境的依赖不同,跨机器报告应显式约定。
完整延迟执行与分配实验
以下文件与 Lab.csproj 放在 examples/csharp/ledger,可直接运行,不依赖测试框架:
Lab.cs
using System.Diagnostics;
var visits = 0;
var source = new List<int> { 1, 2, 3 };
var query = source.Where(n => { visits++; return n > 1; });
source.Add(4);
if (query.Sum() != 9 || query.Count() != 3 || visits != 8) throw new Exception("deferred query contract");
var snapshot = query.ToArray();
source.Add(5);
if (snapshot.Sum() != 9 || query.Sum() != 14) throw new Exception("snapshot contract");
Console.WriteLine($"deferred visits={visits}; snapshot=9; live=14");
const int count = 100000;
static long Stream(int n) => Enumerable.Range(0, n).Where(x => x % 2 == 0).Select(x => (long)x * x).Sum();
static long Materialize(int n) => Enumerable.Range(0, n).Where(x => x % 2 == 0).Select(x => (long)x * x).ToArray().Sum();
if (Stream(count) != Materialize(count)) throw new Exception("unequal semantics");
foreach (var (name, work) in new (string, Func<int, long>)[] { ("stream", Stream), ("array", Materialize) }) {
work(count); // warm up; these measurements are exploratory, not a statistical benchmark
var bytes = GC.GetAllocatedBytesForCurrentThread();
var clock = Stopwatch.StartNew();
var result = work(count);
clock.Stop();
Console.WriteLine($"{name}: sum={result} ticks={clock.ElapsedTicks} bytes={GC.GetAllocatedBytesForCurrentThread() - bytes}");
}Lab.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup><OutputType>Exe</OutputType><TargetFramework>net10.0</TargetFramework><ImplicitUsings>enable</ImplicitUsings><Nullable>enable</Nullable><EnableDefaultCompileItems>false</EnableDefaultCompileItems></PropertyGroup>
<ItemGroup><Compile Include="Lab.cs"/></ItemGroup>
</Project>dotnet run --project Lab.csproj -c Release第一行固定 deferred visits=17; snapshot=9; live=14;后两种实现 sum 都是166661666700000。ticks 和 bytes 是本机观测值,不能照抄为性能保证。GC.GetAllocatedBytesForCurrentThread 统计当前线程累计分配,不是存活内存或进程RSS,也不适合跨 await 后假定仍在同一线程。Stopwatch 有自身开销,示例只预热一次、测一次;正式比较应固定编译配置、输入和重复次数,观察分布,必要时使用专门基准工具。
练习与独立解答
- 在构造查询后修改源,预测两次 Sum;把 ToArray 放在修改之前与之后比较。2. 使用相同金额按分类聚合,要求大小写敏感且稳定排序。3. 列举一个“看起来流式却仍缓冲全部输入”的算子。
参考解法
查询消费时才读源;提前 ToArray 得到旧值,稍后物化得到新值,但数组元素若为引用对象仍可共享状态。分类聚合先校验边界,使用 Ordinal 键比较,最终按同一规则排序;不要只对输出使用一种比较而对去重使用另一种。OrderBy 要保存全部数据再排序,是典型需要缓冲的算子;它不能用于无穷序列的完整排序。
依据:LINQ 查询执行、泛型协变与逆变、中文 LINQ 概览。核对2026-10-17。
继续阅读
- 开发实践
- 知识专题
- 知识专题