Dictionary<TKey, TValue> 把值存放在唯一的键下,无论有多少条目,按键查找值的时间都大致恒定。它就是 C# 中的哈希表:从姓名到号码的电话簿、从 ID 到记录的缓存、每个单词的计数,都可以用它。
创建字典并读取值
输出:
12
2.50
2
True
False
两种初始化写法效果相同。["key"] = value 写法(C# 6)使用索引器,所以重复的键会覆盖;{ key, value } 写法调用 Add,所以重复的键会在该行运行时抛出异常。
ContainsKey 是一次哈希查找,速度很快。ContainsValue 则必须扫描每一个条目,因为值没有索引。
Add、索引器与 TryAdd
放入条目有三种方式,它们的区别只在于键已经存在时会发生什么:
输出:
26
Caught ArgumentException
True
False
31
Add 遇到重复键就抛出异常,这其实是一项功能:它能暴露本应唯一却出现重复的数据。你的意思是“插入或更新”时用索引器;希望第一个值生效时用 TryAdd(.NET Core 2.0 及更高版本)。
键不能是 null。Add(null, ...) 或 dict[null] 会抛出 ArgumentNullException。只要值的类型允许,值可以是 null。
KeyNotFoundException 与 TryGetValue
用索引器读取不存在的键会抛出 KeyNotFoundException。这是最常见的字典错误,修复方法几乎总是 TryGetValue。
输出:
Caught KeyNotFoundException
Found ana@example.com
Missing, value is null: True
no email
TryGetValue 只做一次哈希查找,并用 bool 报告是否成功。if (dict.ContainsKey(k)) { var v = dict[k]; } 这种写法也能用,但会查找两次键。键不存在时,out 变量会得到该类型的默认值(null、0、false)。
在 .NET Core 2.0 及更高版本中,还有 GetValueOrDefault(key, fallback),键不存在时返回备用值:emails.GetValueOrDefault(103, "no email")。
更新和删除条目
输出:
2
True
False
1
0
如果字典中还没有 milk,cart["milk"] += 1 会抛出 KeyNotFoundException,因为它先读后写。Remove 遇到不存在的键会返回 false 而不是抛出异常,所以不需要先检查 ContainsKey。
遍历:KeyValuePair、Keys 与 Values
对字典做 foreach 会得到一个个 KeyValuePair<TKey, TValue>,每一项都有 Key 和 Value。
输出:
Ana: 88
Ben: 72
Chloe: 95
Ana Ben Chloe
Total 255
77
pair.Value 是只读的,所以更新值要通过索引器写入。最后一个循环遍历的是键的 List<string> 副本,这样总是安全的;直接遍历 scores.Keys 并覆盖已有的值,在 .NET Core 3.0 及更高版本中是允许的,但在 .NET Framework 上会抛出 InvalidOperationException。
在对同一个字典做 foreach 时添加新键,在所有版本上都会抛出 InvalidOperationException。在枚举过程中删除,在 .NET Framework 上会抛出异常,从 .NET Core 3.0 起是允许的。需要在所有环境下运行的代码,会先收集要删除的键,等循环结束后再删除。
在 C# 7 和 .NET Core 2.0 或更高版本中,可以在循环头中解构 KeyValuePair:
foreach (var (name, score) in scores)
{
Console.WriteLine($"{name}: {score}");
}
用字典计数
统计出现次数是教科书级的用法。用 TryGetValue 读取当前计数(键不存在时得到 0),再写回去。
输出:
the 3
cat 1
and 2
dog 1
bird 1
同样的结构也可以用来分组:Dictionary<string, List<Order>>,用 TryGetValue 取出列表,不存在就新建一个并存进去,然后 Add 到列表中。一次性的计数和分组,用 LINQ 一个表达式就能搞定:words.GroupBy(w => w).ToDictionary(g => g.Key, g => g.Count())。参见 LINQ。
用比较器实现不区分大小写的键
字符串键默认精确比较:"Apple" 和 "apple" 是两个键。给构造函数传入一个 IEqualityComparer<string> 就能改变这一点。
输出:
False
text/html
1
对于 HTTP 头、文件扩展名和用户名这类标识符,StringComparer.OrdinalIgnoreCase 是正确的选择。存储前对每个键调用 .ToLower() 也行,但很容易在某个地方忘掉。
如果键是你自己定义的类,字典会调用键的 GetHashCode 和 Equals。没有重写这两个方法的类按引用比较,所以两个字段相同的不同对象是不同的键。如何编写这对方法,参见 HashSet。
顺序、排序与 SortedDictionary
Dictionary 不对枚举顺序做任何承诺。实际上,只添加过条目的字典会按插入顺序枚举,但在 Remove 之后,后面的 Add 可能复用腾出的位置,从而出现在更靠前的地方。代码永远不应该依赖这个顺序。
需要顺序时,在使用的地方排序,或者使用有序集合:
输出:
Cairo 210
Lima 340
Oslo 520
By value, highest first:
Oslo 520
Lima 340
Cairo 210
Berlin, Cairo, Lima, Oslo
SortedDictionary<TKey, TValue> 始终让键保持有序(它是一棵平衡树),所以查找和插入是 O(log n),而不是 O(1)。经常需要按键的顺序枚举时用它;只需要排一次序时,用 LINQ 对普通字典排序即可。SortedList<TKey, TValue> 是第三种选择,占用内存更少,但数据量大时插入很慢。
速查
| 任务 | 代码 |
|---|---|
| 创建 | new Dictionary<string, int>() |
| 插入或覆盖 | d[k] = v |
| 插入,重复时抛出异常 | d.Add(k, v) |
| 仅在不存在时插入 | d.TryAdd(k, v) |
| 读取,不存在时抛出异常 | d[k] |
| 安全读取 | d.TryGetValue(k, out var v) |
| 键是否存在 | d.ContainsKey(k) |
| 删除 | d.Remove(k)(返回 bool) |
| 大小 | d.Count |
| 键、值 | d.Keys、d.Values |
| 按键排序 | d.OrderBy(p => p.Key) 或 SortedDictionary |
| 忽略大小写 | new Dictionary<string, T>(StringComparer.OrdinalIgnoreCase) |
常见错误
- 用
d[k]读取不存在的键。 会抛出KeyNotFoundException;请用TryGetValue。 - 对可能已存在的键调用
Add。 会抛出ArgumentException;请用索引器或TryAdd。 - 在遍历字典的
foreach中添加键。 会抛出InvalidOperationException;先收集要做的修改,循环结束后再应用。 - 依赖枚举顺序。 请排序,或使用
SortedDictionary。 - 把对象作为键插入后又修改它的字段。 它的哈希码会改变,这个条目就再也找不到了。
- 先
ContainsKey再用索引器。 查找了两次;TryGetValue只查找一次。
常见问题
C# 中 Dictionary.Add 和索引器有什么区别?
dict.Add(key, value) 插入一个新条目,如果键已经存在就抛出 ArgumentException。dict[key] = value 在键不存在时插入条目,存在时覆盖值,遇到重复键也绝不会抛出异常。TryAdd(key, value) 只在键不存在时插入,否则返回 false。
C# 中 TryGetValue 是怎么工作的?
键存在时,dict.TryGetValue(key, out var value) 返回 true 并设置 value;键不存在时返回 false,并把 value 设为其类型的默认值。它只查找一次,而先 ContainsKey 再 dict[key] 要查找两次,而且它永远不会抛出 KeyNotFoundException。
C# 中如何遍历 Dictionary?
foreach (KeyValuePair<string, int> pair in dict) 会依次给出每个条目,通过 pair.Key 和 pair.Value 访问。只想遍历键或值时,用 dict.Keys 或 dict.Values。不要在这个循环里向字典添加键:那会抛出 InvalidOperationException。
C# 的 Dictionary 是有序的吗?
不保证任何顺序。一个只添加过条目的字典通常按插入顺序枚举,但执行 Remove 之后,新条目可能填进腾出的位置,顺序就变了。需要顺序时就排序:dict.OrderBy(p => p.Key),或者使用总是按键枚举的 SortedDictionary<TKey, TValue>。
如何让 Dictionary 的键不区分大小写?
给构造函数传入一个比较器:new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)。这样在查找、Add 和 ContainsKey 中,"Apple" 和 "apple" 就是同一个键。比较器在创建字典时就固定了。
C# 中的 KeyValuePair 是什么?
KeyValuePair<TKey, TValue> 是枚举字典时,字典为每个条目交给你的结构体。它的 Key 和 Value 属性都是只读的,所以不能通过它修改条目;请改写 dict[pair.Key] = newValue(在循环结束后,或者遍历键的副本时)。