08-03-不可变-ImmutableDictionary-TKey-TValue-与ImmutableHashSet-T-持久化哈希树

ImmutableDictionary 与 ImmutableHashSet:哈希桶树、结构共享与原子快照

系列 :C# 与常用数据结构源码剖析 · 不可变集合篇

阅读时间 :约 60 分钟

前置知识 :哈希表、平衡树、持久化数据结构、比较器

源码基线System.Collections.Immutable 8.0.0,对应 dotnet/runtime v8.0.0 / commit 5535e31a712343a63f5d7d796cd874e563e5ac14;固定源码为 ImmutableDictionary_2.csImmutableHashSet_1.cs

版本边界 :公共契约以目标 TFM 的 reference assembly 和官方 API 文档为准。本文的内部树、桶、节点名称与优化路径只指上述 8.0.0/v8.0.0 基线,不是跨版本承诺;本文也不把它们想当然地写成 HAMT/CHAMP。


一、先纠正数据结构:这不是 32 路 HAMT

不可变哈希容器经常用 Hash Array Mapped Trie 实现,但"持久化 + 哈希"不能推导出某个 .NET 类一定使用 HAMT。在上述 System.Collections.Immutable 8.0.0/dotnet/runtime v8.0.0 的两份源码中,ImmutableDictionary<TKey,TValue>ImmutableHashSet<T> 可分别概括为共享下列高层结构:

  1. 先通过键或元素的相等比较器计算 32 位哈希码。

  2. 用哈希码作为整数键,在平衡二叉树中定位一个哈希桶。

  3. 哈希码相同的不同键/元素进入同一冲突桶,再用相等比较器区分。

  4. 修改时重建受影响的树路径和桶,其他不变节点与旧版本共享。

    复制代码
                          hash = 25
                         /         \
                  hash = 8       hash = 91
                     |               |
              [hash bucket]    [hash bucket]
              key A -> value   key X -> value
              key B -> value   // A/B 同 hash,但不相等

因此,原文中"每次取 5 bit、32 路分支、bitmap + popcount"并不是这个实现的源码模型,"百万元素只需四层"也不能用来分析它。本文使用"哈希桶树"这个教学名称,但私有类名和精确平衡实现仍应通过目标 tag 核对。

二、不可变的真正含义

ImmutableDictionaryAddSetItemRemove 不会就地修改原对象,而是返回一个代表新内容的字典。旧引用仍然指向旧快照:

复制代码
ImmutableDictionary<int, string> v1 =
    ImmutableDictionary<int, string>.Empty;

ImmutableDictionary<int, string> v2 = v1.Add(1, "one");
ImmutableDictionary<int, string> v3 = v2.SetItem(1, "ONE");

Console.WriteLine(v1.Count); // 0
Console.WriteLine(v2[1]);    // one
Console.WriteLine(v3[1]);    // ONE

这不等于每次都复制全部 n 个键值对。新版本可以共享未变的子树、桶内部持久化节点以及比较器。只有从根到目标哈希码的路径、发生改变的冲突桶和平衡修复所需节点发生更新。这就是结构共享。

复制代码
old root                         new root
   /   \                           /   \
  A     B       Add/Remove        A     B'
       / \         ---->               / \
      C   D                           C   D'

A 和 C 被新旧版本共享;只复制受影响路径。

但不可变只适用于"集合自身的键值关系"。它不会递归冻结 TKeyTValueT 对象。如果值是一个可变 List<int>,修改该列表后,所有共享同一列表引用的字典版本都会观察到新内容。

复制代码
var mutable = new List<int> { 1 };
var snapshot = ImmutableDictionary<string, List<int>>.Empty
    .Add("numbers", mutable);

mutable.Add(2);
// snapshot["numbers"] 现在也包含 2。字典不可变,值并不是深层不可变。

如果快照需要真正隔离,值也应使用不可变模型、防御性副本或清晰的所有权转移。

三、哈希桶树的不变式

可以用三层不变式理解整个容器。第一层是树:节点按有符号 32 位哈希码的确定顺序组织,左子树、当前节点、右子树满足搜索树顺序,并维持高度平衡。

第二层是桶:树中每个哈希码最多对应一个桶,桶内包含所有具有该哈希码的键值对或集合元素。它们可能相等,也可能只是哈希冲突;需要再用相等比较器区分。

第三层是版本:已发布给不可变容器的节点不能再被就地修改。新操作只能创建新路径,并让新根指向旧子树和新节点的组合。Builder 可以在独占的可变阶段里复用节点,但发布为不可变快照后必须遵守这个边界。

四、比较器是容器语义的一部分

ImmutableDictionary<TKey,TValue> 至少涉及键比较器和值比较器。键比较器决定哈希码和键相等,因此决定字典中"同一个键"的意义。值比较器用于需要判断已有值与新值是否相同的操作语义。应在创建空集合时就选定比较器:

复制代码
ImmutableDictionary<string, int> scores =
    ImmutableDictionary.Create<string, int>(StringComparer.OrdinalIgnoreCase);

scores = scores.Add("Player", 10);
// scores.Add("player", 20) 会遇到已有键。

一个正确的相等比较器必须满足:若 Equals(a,b) 为真,则 GetHashCode(a) 必须等于 GetHashCode(b)。哈希相同不必推出相等,冲突桶就是为了处理这种情况。比较结果还必须在键存活期间稳定;不能依赖会改变的全局状态。

WithComparers 一类 API 表面上只是换比较器,语义上却可能改变键等价类。例如从 ordinal 换到 ordinal-ignore-case 时,原来两个不同键可能合并为冲突。具体 API 对这种冲突如何处理应查目标版本契约,不能把更换比较器当成 O(1) 字段赋值。

五、Add、SetItem 和 Remove

Add(key,value) 表示"添加一个不存在的键"。已存在等价键时,若不满足目标 API 允许的幂等情况,它会通过异常报告冲突。这适合重复就是错误的注册边界。

SetItem(key,value) 表示 upsert:键不存在时添加,键存在时替换值。如果操作没有造成逻辑变化,实现可能返回原实例,但调用者不应用引用相等代替集合内容语义。

Remove(key) 在键存在时从冲突桶中移除该键值对。桶中还有其他冲突键时,替换该桶;桶变空时,从哈希树中移除整个哈希码节点并修复平衡。键不存在时,结果在内容上与原字典一致。

下面是结构化伪代码,不是任何 runtime tag 的逐字源码:

复制代码
// 伪代码:用于解释路径复制和桶更新。
Node SetItem(Node node, int hash, TKey key, TValue value)
{
    if (node.IsEmpty)
        return NewNode(hash, Bucket.Single(key, value));

    if (hash < node.Hash)
        return Balance(node.WithLeft(SetItem(node.Left, hash, key, value)));

    if (hash > node.Hash)
        return Balance(node.WithRight(SetItem(node.Right, hash, key, value)));

    Bucket updated = node.Bucket.SetItem(key, value, _keyComparer);
    return node.WithBucket(updated);
}

WithLeftWithRightWithBucket 在不可变路径上产生新节点,未走过的子树引用被原样复用。Balance 只对被修改路径做局部旋转。真实源码还包含变更结果、计数、已冻结节点与各种快路径,需按固定 tag 阅读。

六、ImmutableHashSet 的同与不同

ImmutableHashSet<T> 只保存唯一元素,没有独立 TValue。在目标实现中,它与不可变字典共享"哈希码平衡树 + 冲突桶"的整体思路,但桶的元素和更新语义不同。它使用 IEqualityComparer<T> 同时定义元素的哈希和相等。

复制代码
ImmutableHashSet<string> tags =
    ImmutableHashSet.Create<string>(StringComparer.OrdinalIgnoreCase);

tags = tags.Add("Boss");
tags = tags.Add("boss"); // 逻辑内容仍只有一个等价元素

集合的并集、交集、差集和对称差集都会返回新集合,但新实例可以共享未变结构。具体算法是否会利用另一个同类集合的内部结构、哪个操作会转到 Builder,都属于需按版本核验的优化。

七、Builder:在可变阶段批量修改

连续写 map = map.SetItem(...) 在语义上完全正确,但每步都要产生一个可独立发布的不可变根。当业务是"在单一所有者中执行一批修改,然后发布一个快照"时,Builder 可以减少中间版本和部分路径分配。

复制代码
ImmutableDictionary<string, Player>.Builder builder = snapshot.ToBuilder();

foreach (PlayerDelta delta in deltas)
{
    if (delta.IsRemoved)
        builder.Remove(delta.Id);
    else
        builder[delta.Id] = delta.Player;
}

ImmutableDictionary<string, Player> next = builder.ToImmutable();

Builder 是可变对象,不继承不可变快照的无锁并发读特性。不应让多个线程无同步共享一个 Builder。一个安全模式是:更新线程独占 Builder,读者只能看到上一个已发布的 ImmutableDictionary,整批更新完成后才替换快照引用。

ToImmutable() 之后 Builder 仍可以继续修改,但已返回的快照不会被后续修改污染。这需要实现在节点上维护可变/冻结所有权边界,并在必要时恢复路径复制;不能把 Builder 理解成直接篡改已发布树。

八、枚举顺序不是哈希容器的持久化契约

不可变集合在枚举过程中不会被修改,因此枚举器不需要像可变 Dictionary 一样对后续写做 fail-fast 版本检测。这非常适合长时间读快照:写者创建新根,已经获得旧根的枚举器继续读旧结构。

但枚举顺序由内部哈希码树、哈希比较器和冲突桶布局决定,不是插入顺序、键排序或跨运行时稳定顺序的承诺。字符串哈希策略、比较器或私有实现改变时,顺序可以改变。

如果需要确定性序列化、跨设备哈希或可读 diff,应在输出边界按明确比较规则排序,不能依赖当前枚举的偶然顺序。排序会产生自己的时间和快照内存成本,应只在真正需要规范化的边界做。

九、复杂度:平衡树高度与冲突桶同时存在

设不同哈希码的数量为 h,目标哈希码桶内有 c 个冲突元素。在目标实现模型下,查找、添加或删除首先支付 O(log h) 的平衡树定位,再支付冲突桶中相等比较的成本。当哈希分布良好时,c 很小;当大量键返回同一哈希码时,操作可退化为对长冲突桶的线性搜索。

操作 良好哈希分布的模型 极端冲突的边界
按键/元素查找 O(log h + c) 可退化至 O(n)
Add / SetItem / Remove 树路径 + 桶更新 桶搜索/更新可 O(n)
枚举 O(n) O(n)
Builder 批量更新 减少中间分配 不会修复坏哈希函数

表中没有写"O(1) 哈希查找",因为该源码模型的一级索引是哈希码平衡树,不是可直接按桶余数定位的可变数组。也不能写成 O(log_32 n),因为这不是 32 路 trie。

渐近复杂度不说明分配、节点跳转和比较器成本。不可变树为单次更新分配新路径,局部性通常不如连续 Dictionary Entry 数组。结构共享节省了整体复制,却不代表"无分配"或"与可变字典速度相同"。必须根据版本数量、更新批次、键分布和目标 runtime 实测。

十、键不变式:不可变字典也怕可变键

字典不修改自己,不意味它能防止外部修改键对象。键加入后,任何影响 GetHashCodeEquals 的状态都必须保持不变。否则键仍然存储在旧哈希码对应的树节点中,但查找会计算新哈希码并走向另一个节点。

复制代码
public sealed class BadKey
{
    public string Name { get; set; } = "";
    public override int GetHashCode() => Name.GetHashCode();
    public override bool Equals(object? obj) =>
        obj is BadKey other && Name == other.Name;
}

这种键即使放入 ImmutableDictionary也不安全。更好的键是不可变值对象、封装的整数 ID 或不可变字符串。若业务实体的名称会变,使用稳定 ID 作键,将名称放在值中。

另一个反例是所有键都返回常量哈希码。它可以满足"相等键哈希相同"的最低正确性契约,却会把全部元素放入一个冲突桶,使查找与更新退化。正确的哈希函数还应尽量使典型键均匀分布,但不要为了均匀而破坏等价契约。

十一、原子发布与多步更新

不可变对象非常适合快照发布:写者在私有局部变量中构建完整新字典,然后一次替换共享根引用。读者只需读取一次根引用,整个操作期间都使用同一快照。

复制代码
private ImmutableDictionary<string, Player> _players =
    ImmutableDictionary<string, Player>.Empty;

public ImmutableDictionary<string, Player> Snapshot() =>
    Volatile.Read(ref _players);

public void Publish(ImmutableDictionary<string, Player> next) =>
    Volatile.Write(ref _players, next);

这个例子只适合单写者,或写者已由外部串行化的系统。若两个写者都读取同一旧快照,各自添加不同键并先后 Volatile.Write,后发布者会覆盖前者的更新。不可变保证快照不被就地破坏,不保证 read-modify-write 组合自动原子。

多写者可以用 CAS 循环,从当前快照计算新快照,只有当根仍是原来那个引用时才替换,失败则基于新根重算。标准不可变帮助 API 提供了对应的原子更新模式,但具体重试回调可能执行多次,不能在其中直接发送邮件、扣款或产生不可重复的副作用。

复制代码
// 结构化模式:实际项目优先使用目标版本的 ImmutableInterlocked API。
while (true)
{
    var before = Volatile.Read(ref _players);
    var after = before.SetItem(player.Id, player);

    if (ReferenceEquals(
        Interlocked.CompareExchange(ref _players, after, before),
        before))
        break;
}

多个键需要共同满足业务不变式时,应在一个局部快照上完成所有 SetItem/Remove,最后只发布一次根。不要每改一个键就发布,否则读者可能看到业务上无效的中间状态。

十二、GC 成本与版本保留

结构共享降低了"每次快照复制整个字典"的成本,但新路径、新桶和新根仍是托管对象。高频单项更新可以产生大量短命节点;Builder 能降低批处理中间分配,却不会把最终不可变结构变成连续数组。

只要某个旧快照仍被引用,它独有的节点以及通过共享节点可达的键值对就不能被 GC 回收。这是版本快照正确性的必然结果。无界保留每一个历史版本,会使内存随业务历史增长,并不是"因为共享就几乎免费"。

需要 undo/redo 时应设定版本上限、检查大对象值和深层可变值,并用堆快照定位保留根。需要长期审计时,增量事件日志或周期 checkpoint 可能比在内存中保留所有集合根更合适。

十三、与 Dictionary、ConcurrentDictionary 和 FrozenDictionary 的区别

容器 更新模型 并发读/写 快照 适合场景
Dictionary<TKey,TValue> 原地可变 写需外部同步 需复制 单所有者、高频可变更新
ConcurrentDictionary<TKey,TValue> 共享可变 提供并发原子 API 枚举语义需按契约理解 多线程持续按键更新
ImmutableDictionary<TKey,TValue> 返回新根 已发布快照可并发读,写者需协调 原生版本 读多写少、原子快照、undo
FrozenDictionary<TKey,TValue> 构建后冻结 只读发布 没有增量新版本 API 构建一次、长期查询

FrozenDictionary 面向"准备阶段可以付出构建成本,之后大量只读查询",不是可持久化更新树。它的 API 可用性和实现优化要查目标 .NET 版本。每次数据变化都重建 Frozen 容器,可能让构建成本主导。

ConcurrentDictionary 适合多写者对共享当前状态做按键更新,但一系列不同键的更新不自动变成一个原子快照。ImmutableDictionary 可以在私有新根上完成多键变更后一次发布,但写者之间必须用 CAS 重试或串行所有权解决更新丢失。

十四、Unity 边界

Unity 项目首先要核对目标 Editor 版本、API Compatibility Level、引用程序集、包版本、后端和平台。桌面当前 .NET SDK 支持某个 System.Collections.Immutable API,不代表目标 Unity Mono/IL2CPP Player 中存在同一 API 表面和同一私有实现。应用最小 asmdef 探针编译并在目标设备构建运行,不要把 CoreCLR 源码直接视为 Unity 源码。

不可变快照适合将纯托管配置、寻路图索引或游戏规则表发布给工作线程。但集合不可变不会让 GameObjectTransformTexture 等 Unity 对象变成可从后台线程访问,也不会解决原生对象销毁后托管包装器的生命周期。快照的键值应是可跨线程安全读取的纯数据。

节点树和路径复制会产生托管分配。在每帧更新成千上万键的热路径中,不能仅因为"结构共享"就假设 GC 可忽略。对每个目标设备的 Player 实测更新批次、分配、托管堆、帧时间与驻留旧版本数量。如果只需要主线程可变状态,普通 Dictionary 与明确帧边界可能更简单。

十五、故障反例

反例一:忽略返回值。

复制代码
var map = ImmutableDictionary<string, int>.Empty;
map.Add("score", 10); // 错误:新字典被丢弃

所有修改操作都返回新容器,必须保存 map = map.Add(...),或者在表达式链中使用结果。编译器不会因为你忽略了返回值而报错。

反例二:将 Builder 发布给读者。 Builder 是可变批处理工具,多线程无同步读写会破坏安全边界。发布 ToImmutable() 返回的快照,而不是 Builder。

反例三:值是可变对象却声称快照完全隔离。 容器只冻结映射关系,不会深拷贝。设计不可变值模型或在发布边界复制。

反例四:多写者用普通赋值替换根。 两个写者基于同一旧根生成新根,后赋值会丢失前更新。使用单写者、锁或原子 CAS 帮助 API。

反例五:依赖枚举顺序生成网络签名。 哈希集合顺序不是跨版本规范。在签名编码中按明确的稳定比较器排序,并固定数字、文本与转义格式。

十六、可复现正确性与性能实验

正确性测试应先覆盖持久化语义:生成 v0,逐步 Add/SetItem/Remove 得到 v1、v2、v3,每一步都保留旧引用,最后验证每个版本的内容与当时期望完全相同。同时对照一个简单的可变 Dictionary 副本作为参考模型。

比较器测试应包含:大小写等价键;不同键返回相同哈希码;值为 default;删除冲突桶的首项、中间项和最后一项;从多元素桶删至空桶;用 Builder 批量更新后确认旧快照未变。还应用故意常量哈希比较器覆盖最坏冲突路径,但不要把它的性能当作正常负载结论。

复制代码
public sealed class ConstantHashComparer : IEqualityComparer<int>
{
    public bool Equals(int x, int y) => x == y;
    public int GetHashCode(int value) => 0;
}

并发发布测试应让多个读者持续读取根快照,验证跨多键的业务不变式;写者在局部完成整批变更后发布。对多写者 CAS 循环,记录重试次数,验证每个逻辑更新恰好一次出现,并保证重算函数无不可重复副作用。

性能实验应区分:单项更新、批量 Builder 更新、稳态查找、全量枚举、保留多个旧版本,以及全部丢弃旧版本。同时记录时间、分配字节、GC 次数与驻留堆,固定 SDK/runtime、CPU、键分布、比较器、初始规模、更新批次和随机种子。

对比 DictionaryConcurrentDictionaryImmutableDictionary 和目标版本支持的 Frozen 容器时,必须使用等价业务契约。如果一个方案每次保留版本,另一个方案就地更新且不提供快照,单纯对比每秒操作数不是公平比较。不要发布没有原始报告和可运行代码的固定倍数。

十七、源码审查清单

阅读一个特定版本的源码时,可依次问:

  1. 代码属于哪个 dotnet/runtime tag/commit,目标应用实际加载哪个程序集版本?
  2. 一级树节点的整数键是什么,它如何平衡,空节点如何表示?
  3. HashBucket 如何区分第一项和额外冲突项,桶本身使用什么持久化结构?
  4. 键比较器和值比较器分别用在哪些 API 分支?
  5. Add、SetItem、Remove 如何区分无变化、添加、更新与冲突?
  6. 树路径何时复制,哪些子树直接共享,平衡旋转会创建哪些节点?
  7. Builder 如何标识节点所有权/冻结状态,ToImmutable 后继续修改为何不污染旧快照?
  8. 枚举器如何穿过树与桶,是否有结构体枚举和接口枚举的不同路径?
  9. 更换比较器时是否需要重建,新比较器使旧键等价时如何处理?
  10. 哪些结论是公共契约,哪些只是该 tag 的分配或快路径优化?

结语

ImmutableDictionaryImmutableHashSet 的核心不是一个可以从"持久化哈希容器"猜出来的 HAMT 标签。在可核验的目标 .NET 实现中,它们通过按 32 位哈希码组织的平衡树定位桶,用桶内相等比较解决哈希冲突,通过路径复制和未变子树共享保留旧版本。

不可变快照带来的主要收益是清晰的发布边界:读者拿到一个根就能稳定遍历,写者可以在私有新根上完成多步更新后一次发布。但它不会深度冻结键值对象,不会自动解决多写者更新丢失,也不会消除节点分配、GC 和最坏哈希冲突。

正确选型需要回到业务语义:需要高频原地更新就评估普通或并发字典,构建一次长期只读就评估 Frozen 容器,需要版本、原子快照和读者隔离时再选不可变容器。最后用固定 runtime tag 核对源码,用坏哈希和并发发布测试边界,用真实更新批次和版本保留模式测量成本。


下一篇:不可变集合综合对比与选型

相关推荐
hansang_IR1 小时前
【题解】[APIO2023] 赛博乐园 / cyberland
c++·算法·图论
洛阳纸贵2 小时前
MATLAB-matlab基础知识
学习·算法·matlab
落羽的落羽2 小时前
【AI】快速理解AI应用的相关名词概念
linux·c++·人工智能·python·计算机网络·算法
Nil2082 小时前
leetcode 17电话号码的字母组合
算法·leetcode·职场和发展
203号居民4 小时前
LeetCode hot 100 — 25. K 个一组翻转链表
算法·leetcode·链表
典典分享指南4 小时前
一品牌多产品线的 GEO 内容矩阵:从策略到落地
java·c#·bash·symfony
今夜有雨.4 小时前
C# 学习文档(零基础入门)
开发语言·学习·c#
ocean21034 小时前
2025-2026年AI算法与模型研发面试高频知识点洞察
人工智能·算法·面试
Nil2085 小时前
leetcode 78子集
数据结构·算法·leetcode