☰
NPOI 双向字典 BidirectionalDictionary:BiMap 双向映射的实现原理与实战用法
2026/10/15 7:55:20 网站建设 项目流程
  • 后端

【免费下载链接】npoi

a .NET library that can read/write Office formats without Microsoft Office installed. No COM+, no interop.

项目地址:https://gitcode.com/gh_mirrors/np/npoi
点击查看免费下载

BidirectionalDictionary<TKey, TValue>是 NPOI 引入的一套「键值双向映射」集合实现(又称 BiMap / Two-way dictionary),允许在 O(1) 语义下同时按键查值、按值查键。本文以该模块的源码为据,完整讲解它的基本用法、只读包装、接口抽象、内部双字典同步机制,并结合 NPOI 在 HPSF(OLE2 文档属性)模块中的真实调用场景(CustomProperties.cs、Section.cs),帮助你在自己的项目中正确、高效地使用这一数据结构。

什么是双向字典(BiMap)

普通的Dictionary<TKey, TValue>只能从 Key 查到 Value。当业务上还需要「根据 Value 反查 Key」时,常见做法是再维护一个反向字典,并手工保证两个字典的一致性——插入、删除、修改都要同步,稍有不慎就会出现正反向数据不一致。

双向字典的核心约束是:Key 与 Value 都必须唯一(二者均非 null),并在此基础上提供Inverse反向视图。这样 Value 到 Key 的反查天然成立,且任何写操作都会由容器自身同时维护正向与反向两份映射。

在 NPOI 仓库中,该模块位于 main/Util/Collections/BidirectionalDictionary,由以下文件组成:

文件职责
BidirectionalDictionary.cs可变双向字典核心实现
ReadOnlyBidirectionalDictionary.cs只读双向字典包装
IBidirectionalDictionary.cs可变双向字典接口
IReadOnlyBidirectionalDictionary.cs只读双向字典接口
DictionaryDebugView.cs调试器可视化辅助

快速上手:创建、按键取值与按值反查

参考模块自带的官方示例(见 README-BidirectionalDictionary.md),一个「国家 → 首都」的双向映射可以这样使用:

using System.Collections.Generic; var countryCapitalDictionary = new BidirectionalDictionary<string, string>() { ["Italy"] = "Rome", ["India"] = "New Delhi", ["USA"] = "Washington, D.C.", }; Console.WriteLine(countryCapitalDictionary["Italy"]); // "Rome" Console.WriteLine(countryCapitalDictionary.Inverse["Rome"]); // "Italy"

关键点:

  • dict[key]按键取值,行为与普通Dictionary一致:键不存在时 get 抛KeyNotFoundException;
  • dict.Inverse返回一个类型为BidirectionalDictionary<TValue, TKey>的反向字典,Inverse["Rome"]即完成「值 → 键」的反查;
  • 通过集合初始化器批量写入时,每一个 key/value 对都会被同时注册到正反两个方向。

除了索引器,还可以使用Add(key, value)、TryAdd(key, value)显式添加。TryAdd在键已存在或值已存在时返回false而不是抛异常,适合批量导入场景:

var dict = new BidirectionalDictionary<string, string>(); bool ok1 = dict.TryAdd("Italy", "Rome"); // true bool ok2 = dict.TryAdd("France", "Rome"); // false:值 "Rome" 已存在

核心 API 与唯一性约束

构造方式

源码在 BidirectionalDictionary.cs 提供了多组构造函数,覆盖常见初始化需求:

构造函数说明
BidirectionalDictionary()空字典,默认容量与默认比较器
BidirectionalDictionary(int capacity)指定初始容量
BidirectionalDictionary(IDictionary<TKey,TValue>)从现有字典复制元素
BidirectionalDictionary(IEnumerable<KeyValuePair<TKey,TValue>>)从键值对序列复制
BidirectionalDictionary(IEqualityComparer<TKey>?, IEqualityComparer<TValue>?)分别指定键比较器与值比较器
BidirectionalDictionary(int, IEqualityComparer<TKey>?, IEqualityComparer<TValue>?)容量 + 双比较器
BidirectionalDictionary(IDictionary<TKey,TValue>, IEqualityComparer<TKey>?, IEqualityComparer<TValue>?)字典 + 双比较器

其中KeyComparer决定键的相等语义,ValueComparer决定值的相等语义(也即反查时的键比较语义),二者默认均为该类型的默认EqualityComparer<T>。例如希望「值反查时忽略大小写」,可以为 value 传入StringComparer.OrdinalIgnoreCase。

写操作的唯一性校验

Add的实现(BidirectionalDictionary.cs)完整体现了双向字典的约束:

public void Add(TKey key, TValue value) { if (key == null) throw new ArgumentNullException(nameof(key)); if (value == null) throw new ArgumentNullException(nameof(value)); if (ContainsKey(key)) throw new ArgumentException("The same key already exists.", nameof(key)); if (ContainsValue(value)) throw new ArgumentException("The same value already exists.", nameof(value)); _baseDictionary.Add(key, value); Inverse._baseDictionary.Add(value, key); }
  • Key 或 Value 为 null 时抛ArgumentNullException(类型参数声明为where TKey : notnull, where TValue : notnull);
  • Key 重复抛ArgumentException;
  • Value 重复同样抛ArgumentException——这是双向字典与普通字典最大的差异:值不允许重复,因为一旦两个 Key 映射到同一个 Value,反查结果就不再唯一。

索引器 setter(BidirectionalDictionary.cs)支持「改值」:当目标值已被其他键占用时抛ArgumentException("The value already exists.");否则同时更新正向映射与反向映射,保证两侧一致。

查询、删除与补充方法

  • ContainsKey(key)/ContainsValue(value):分别判断键、值是否存在,其中ContainsValue在源码中直接委托给Inverse.ContainsKey(BidirectionalDictionary.cs);
  • Remove(key)/Remove(key, out value):删除指定键及其对应值,正反两侧同步移除,返回是否成功;
  • RemoveValue(value):按值反查出 Key 并删除,同时返回被删除的 Key;
  • GetKey(value):按值反查 Key,找不到时返回default(TKey);
  • LastKey():返回Keys序列的最后一个键,用于「取当前最大 Key」类场景(NPOI 用它为自定义属性分配递增 ID);
  • Clear():同时清空正向与反向两个字典。

只读支持:AsReadOnly()与ReadOnlyBidirectionalDictionary

双向字典很容易被误改,模块为此提供了两种只读化方式(用法见 README-BidirectionalDictionary.md)。

对具体的BidirectionalDictionary实例:

var readOnlyDictionary = dictionary.AsReadOnly();

AsReadOnly()(BidirectionalDictionary.cs)返回一个包装当前字典的ReadOnlyBidirectionalDictionary<TKey, TValue>。

对IBidirectionalDictionary接口类型:

using System.Collections.ObjectModel; var readOnlyDictionary = new ReadOnlyBidirectionalDictionary<string, string>(dictionary);

ReadOnlyBidirectionalDictionary(ReadOnlyBidirectionalDictionary.cs)是只读包装器:

  • 所有写操作(Add、Remove、Clear、索引器 setter 等)均抛NotSupportedException,见 ReadOnlyBidirectionalDictionary.cs;
  • 只读包装同样暴露Inverse属性,且其Inverse也是只读包装——IReadOnlyBidirectionalDictionary.Inverse实现为Inverse.AsReadOnly()(BidirectionalDictionary.cs),保证只读语义在反向上不会意外泄漏;
  • 它通过构造参数持有内部的可变字典引用,其只读性来自「禁止调用写方法」,而非数据快照。

接口抽象:IBidirectionalDictionary 与 IReadOnlyBidirectionalDictionary

模块提供两个接口(见 README-BidirectionalDictionary.md):

  • IBidirectionalDictionary<TKey, TValue>:继承IDictionary<TKey, TValue>,额外声明Inverse(类型为IBidirectionalDictionary<TValue, TKey>)与ContainsValue(IBidirectionalDictionary.cs);
  • IReadOnlyBidirectionalDictionary<TKey, TValue>:继承IReadOnlyDictionary<TKey, TValue>,额外声明只读版Inverse与ContainsValue(IReadOnlyBidirectionalDictionary.cs)。

BidirectionalDictionary与ReadOnlyBidirectionalDictionary都同时实现这两个接口,因此你可以按「可变 / 只读」两种抽象层次编写面向接口的代码,例如方法签名只接收IReadOnlyBidirectionalDictionary以限制调用方误写。两个类的显式接口实现都带有[DebuggerBrowsable(DebuggerBrowsableState.Never)]标记,避免调试器展示重复的接口视图。

源码解析:双字典同步的底层原理

BidirectionalDictionary的内部结构非常清晰(BidirectionalDictionary.cs):

protected internal readonly Dictionary<TKey, TValue> _baseDictionary; // 正向:Key → Value public BidirectionalDictionary<TValue, TKey> Inverse { get; } // 反向:Value → Key

它并不在每次查询时临时计算反向关系,而是同时持有两个底层 Dictionary,并在每次写操作时同步更新:

  1. 构造时,主实例创建正向_baseDictionary,并通过私有构造(BidirectionalDictionary.cs)创建Inverse = new BidirectionalDictionary<TValue, TKey>(this);
  2. 反向实例的私有构造(BidirectionalDictionary.cs)将_baseDictionary初始化为inverse._baseDictionary.ToDictionary(pair => pair.Value, pair => pair.Key),并把ValueComparer与反向KeyComparer对齐;
  3. Add/Remove/Clear/ 索引器 setter 在修改_baseDictionary的同时,对Inverse._baseDictionary做对称操作。

由此可以推断几个特性:

  • 一致性由构造保证:两个方向共享同一对底层 Dictionary(互为对方的反向),任何一侧的修改天然反映到另一侧,不存在「反向数据过期」的问题;
  • 复杂度:ContainsValue实际是反向字典的ContainsKey,因此值反查与键查询一样高效;Inverse属性是构造时创建的对象引用,读取开销极低;
  • 空间代价:每个键值对在内存中存两份(正向一份、反向一份),换来的是双向 O(1) 级别的查询能力。

NPOI 中的实际应用:HPSF 文档属性的 ID/名称互查

该模块并非孤立的工具代码,NPOI 在 HPSF(OLE2 复合文档的属性系统,负责读写.doc/.xls的摘要信息与自定义属性)中已经把它用在了关键路径上。

场景一:CustomProperties 维护「属性 ID ↔ 属性名」

在 CustomProperties.cs 中,类使用BidirectionalDictionary<long, string>维护自定义属性的数字 ID 与名称之间的 1:1 映射:

private readonly BidirectionalDictionary<long, string> dictionary = new();
  • 写入属性时,先通过dictionary.ContainsValue(name)判断名称是否已被占用,再用dictionary.GetKey(name)反查已有 ID(CustomProperties.cs);
  • 按名称读取属性时,dictionary.ContainsValue(name) ? dictionary.GetKey(name) : null完成「名称 → ID → 属性值」的两级查找(CustomProperties.cs);
  • 删除属性时直接调用dictionary.RemoveValue(name)一次完成反查与删除(CustomProperties.cs);
  • 为新属性分配 ID 时用dictionary.LastKey()取当前最大 ID 再 +1(CustomProperties.cs)。

值得注意的是,该字段上方的注释保留了历史痕迹:NPOI 早期对应 Java 端实现使用的是TreeBidiDictionary<long, string>,如今已替换为本模块的BidirectionalDictionary。

场景二:Section 解析时维护「属性偏移量 ↔ 属性 ID」

在 Section.cs 中,解析属性段时使用BidirectionalDictionary<long, long>把每个属性记录的「段内偏移量」与「属性 ID」双向登记,随后:

  • 用offset2Id.ContainsValue(PID_CODEPAGE)与offset2Id.GetKey(...)反查出代码页属性(ID 固定为 1)的偏移量,提前读取 codepage(Section.cs);
  • 用offset2Id.Inverse.LastKey()在字典属性解析失败时计算兜底的新 ID(Section.cs);
  • 遍历offset2Id并借助偏移量排序计算每条属性的长度。

这两个场景正是「键、值双方都唯一」的双向映射典型用例,可以直接作为在 NPOI 生态内使用该数据结构的参考范式。

移植与兼容性:对 .NET Framework 4 的适配改动

模块入口处的 README.md 说明了它的来源:

From open source project BidirectionalDictionary, with minor changes for supporting dotnet framework 4.

即该模块移植自开源项目 BidirectionalDictionary,并针对 .NET Framework 4 做了小幅适配。仓库源码中可以找到对应的证据:Dictionary<TKey, TValue>的IEnumerable<KeyValuePair<...>>构造重载并非所有目标框架都可用,因此源码使用条件编译做了分支(BidirectionalDictionary.cs):

#if NETSTANDARD2_1_OR_GREATER || NET5_0_OR_GREATER public BidirectionalDictionary(IEnumerable<KeyValuePair<TKey, TValue>> collection) : this(new Dictionary<TKey, TValue>(collection)) { } #elif NETSTANDARD2_0 || NET472 || NET40 || NET45 public BidirectionalDictionary(IEnumerable<KeyValuePair<TKey, TValue>> collection) : this(new Dictionary<TKey, TValue>(collection?.ToDictionary(pair => pair.Key, pair => pair.Value))) { } #endif

在新框架(.NET Standard 2.1+ / .NET 5+)直接使用Dictionary的集合构造;而在 NET40/NET45/NET472/NET Standard 2.0 等旧框架上,则退化为先ToDictionary再构造。带比较器重载的版本也做了同样的分支处理(BidirectionalDictionary.cs)。这与 NPOI 主库的目标框架矩阵(net472;netstandard2.0;netstandard2.1;net8.0;net10.0,见 NPOI.Core.csproj)是配套的——即该模块在 NPOI 支持的所有框架下都可直接编译使用。

调试友好性

两个公开类都标注了调试器特性:

[DebuggerTypeProxy(typeof(DictionaryDebugView<,>))] [DebuggerDisplay("Count = {Count}")]

DictionaryDebugView(DictionaryDebugView.cs)在调试器的「监视 / 快速监视」窗口中把字典内容展开为键值对数组(DebuggerBrowsableState.RootHidden),使双向字典的当前内容一目了然,无需展开内部字段即可检查。

许可证与使用注意

该模块遵循 MIT 许可证,版权归原作者 Ihor Kuzmychov 所有(见 LICENSE.md,Copyright (c) 2022-2024)。NPOI 将其整合进主库 NPOI.Core.csproj 对应的程序集。

最后整理几条实践建议:

  1. 确认值域唯一:只有当业务上的 Value 天然唯一时才适合使用双向字典,否则Add会持续抛出ArgumentException;
  2. 优先TryAdd/TryGetValue编写防御性代码,避免依赖异常做流程控制;
  3. 对外暴露只读视图:把内部可变实例以AsReadOnly()的形式暴露给外部调用方,防止误写破坏正反一致性;
  4. 自定义比较器时注意对称性:ValueComparer同时决定反向字典的键比较语义,要保证正反两侧的相等判定逻辑一致;
  5. 适用场景参考 NPOI 自身:HPSF 中「ID ↔ 名称」「偏移量 ↔ ID」这类 1:1 互查,正是双向字典最理想的使用形态。
  • 后端

【免费下载链接】npoi

a .NET library that can read/write Office formats without Microsoft Office installed. No COM+, no interop.

项目地址:https://gitcode.com/gh_mirrors/np/npoi
点击查看免费下载
上一篇:MMDetection3D 中 Lyft 数据集使用全指南:数据准备、训练、评估与排行榜提交
下一篇:Faster-Whisper-GUI终极指南:三分钟掌握专业级语音转写与字幕生成

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询