- 后端
【免费下载链接】npoi
a .NET library that can read/write Office formats without Microsoft Office installed. No COM+, no interop.
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,并在每次写操作时同步更新:
- 构造时,主实例创建正向
_baseDictionary,并通过私有构造(BidirectionalDictionary.cs)创建Inverse = new BidirectionalDictionary<TValue, TKey>(this); - 反向实例的私有构造(BidirectionalDictionary.cs)将
_baseDictionary初始化为inverse._baseDictionary.ToDictionary(pair => pair.Value, pair => pair.Key),并把ValueComparer与反向KeyComparer对齐; 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 对应的程序集。
最后整理几条实践建议:
- 确认值域唯一:只有当业务上的 Value 天然唯一时才适合使用双向字典,否则
Add会持续抛出ArgumentException; - 优先
TryAdd/TryGetValue编写防御性代码,避免依赖异常做流程控制; - 对外暴露只读视图:把内部可变实例以
AsReadOnly()的形式暴露给外部调用方,防止误写破坏正反一致性; - 自定义比较器时注意对称性:
ValueComparer同时决定反向字典的键比较语义,要保证正反两侧的相等判定逻辑一致; - 适用场景参考 NPOI 自身:HPSF 中「ID ↔ 名称」「偏移量 ↔ ID」这类 1:1 互查,正是双向字典最理想的使用形态。
- 后端
【免费下载链接】npoi
a .NET library that can read/write Office formats without Microsoft Office installed. No COM+, no interop.
相关推荐
GoDS双向映射实现:双HashMap的协同工作机制
GoDS双向映射实现:双HashMap的协同工作机制 你是否在开发中遇到过需要同时通过键找值和通过值找键的场景?传统的 Map(映射) 只能实现单向查找,而Go
后端marshmallow 字段名自动拐化:用 Schema.on_bind_field 实现 camelCase / snake_case 双向映射
marshmallow 字段名自动拐化:用 Schema.on_bind_field 实现 camelCase / snake_case 双向映射 导读 HTT
后端序列化GoDS BidiMap双向映射:键值互查的实现原理
GoDS BidiMap双向映射:键值互查的实现原理 你是否在开发中遇到过需要同时通过键找值和通过值找键的场景?比如在处理用户ID与用户名的映射关系时,既需要根
后端
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考