为什么 FrozenSet
和 FrozenDictionary
不是 .NET 原生类型
.NET 没有内置的
FrozenSet或
FrozenDictionary类型——这是常见误解的源头。所谓“冻结对象”,在 C# 中实际指**构建后不可修改、且内部结构被优化为只读访问的集合**。真正的不可变集合来自
System.Collections.Immutable包,但要注意:它的
ImmutableArray<t></t>、
ImmutableList<t></t>等仍是“不可变”(即每次修改返回新实例),而非“冻结”(即内存布局固定、无写时复制开销)。
真正接近“冻结”语义的是
ImmutableArray<t>.AsReadOnly()</t>返回的
ReadOnlyArray<t></t>,或更直接地使用
System.Runtime.CompilerServices.IsExternalInit配合
init属性 +
readonly struct手动建模;但若目标是高性能只读集合访问(如配置缓存、枚举映射表),应优先考虑
ImmutableArray<t></t>的预构建 +
AsFrozen()模式(需手动实现)或转向
Span<t></t>/
Memory<t></t>驱动的只读视图。
用 ImmutableArray<t>.Builder</t>
构建一次性不可变数组
这是最常用、也最贴近“冻结”效果的做法:先用可变 builder 填充数据,再调用
ToImmutable()得到不可变快照。它不支持后续修改,且底层是紧凑数组,无链表/树结构开销,访问性能等同原生数组。
ImmutableArray.CreateBuilder<int>()</int>创建 builder,支持
Add、
Insert、
Clear等操作 填充完成后调用
builder.ToImmutable()→ 返回
ImmutableArray<int></int>,其
IsDefault和
Length访问都是 O(1),索引访问无装箱、无虚调用 避免在循环中反复调用
builder.Add(x)后又立即
ToImmutable()—— 这会触发多次数组拷贝;应一次性填完再冻结 若已知大小,用
ImmutableArray.CreateBuilder<int>(capacity)</int>预分配,减少扩容拷贝
如何让 ImmutableList<t></t>
或 ImmutableHashSet<t></t>
“真正冻结”
ImmutableList<t></t>底层是平衡树,
ImmutableHashSet<t></t>是哈希 trie,二者都为高效更新设计,但代价是内存碎片和访问间接跳转。若集合构建后永不修改,它们就“过重”了。
此时应转换为更轻量的只读形态:
对键值对场景,用ImmutableArray<keyvaluepair tvalue>>.ToImmutable()</keyvaluepair>+ 自定义只读包装器,比
ImmutableDictionary节省内存 40%+(实测 10k 条目) 对去重集合,用
ImmutableHashSet<t>.ToImmutableArray()</t>再转
ImmutableArray<t></t>,然后用
Array.BinarySearch(需先排序)或
HashSet<t>.Contains</t>初始化一个临时
HashSet<t></t>作查找加速 —— 注意这不是“冻结”,但能模拟冻结后的 O(1) 查找 不要依赖
AsReadOnly()方法返回的
IReadOnlyList<t></t>接口:它只是编译期防护,运行时仍可能被反射或强制转型绕过;真正安全靠的是类型本身不可变(如
ImmutableArray<t></t>)
冻结集合的陷阱:引用类型字段仍可变
即使你用了
ImmutableArray<person></person>,如果
Person是 class,其内部字段仍可被修改 —— “冻结”只作用于集合容器本身,不递归冻结元素。 若需深度冻结,元素类型必须是
readonly struct,或使用
record class(注意:record class 默认可变字段仍可改,要用
init属性 +
private set封装) 常见错误:
var frozen = ImmutableArray.Create(new Person { Name = "A" }); → 后续 frozen[0].Name = "B"依然合法(如果
Name是 public set) 验证方式:对元素类型启用
[ImmutableObject(true)]并配合静态分析器(如 Microsoft.CodeAnalysis.FxCopAnalyzers),但该特性仅作提示,不强制执行 最稳妥路径:用
record struct(C# 10+)定义元素,天然只读且栈语义,与
ImmutableArray组合时零堆分配、零 GC 压力
真正冻结的关键不在“不可变接口”,而在**数据生命周期与内存布局的确定性**:builder 一次性构建、struct 元素杜绝副作用、避免泛型集合的装箱与虚方法分发。这些点稍不注意,所谓的“冻结”就只剩心理安慰。
