.NET没有原生FrozenSet或FrozenDictionary,真正冻结需用ImmutableArray.Builder构建后ToImmutable(),配合readonly struct元素和Span/Memory视图实现内存布局固定与零开销只读访问。

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









