DYNAMIC EXPLAINER / ARTICLE FLOW

把章节变成可单步观察的过程

01 / 17
STEP 01 / CODE / LOGIC

先说结论

Dictionary<TKey, TValue> 用唯一 key 定位 value。它适合回答“ID 为 205 的玩家数据是什么”,不适合回答“生命值最低的玩家是谁”,后者仍需要遍历或额外索引。

初始:{ 101 -> 80, 205 -> 60 }
查询 205:找到 60
受到 25 点伤害:205 -> 35
查询 999:不存在,不能假装得到一个真实玩家

分类:CSharp 基础数据结构

先说结论

Dictionary<TKey, TValue> 用唯一 key 定位 value。它适合回答“ID 为 205 的玩家数据是什么”,不适合回答“生命值最低的玩家是谁”,后者仍需要遍历或额外索引。

先跟踪两次查询

初始:{ 101 -> 80, 205 -> 60 }
查询 205:找到 60
受到 25 点伤害:205 -> 35
查询 999:不存在,不能假装得到一个真实玩家

TryGetValue(205, out hp) 同时完成存在性检查和取值。写 map[999] 会抛出缺键异常,而写 map[999] = 100 会新增一项;相同的方括号语法在读和写时语义不同。

常见误解

  • “查找很快”指按 key 查找的平均情况,不代表按 value 筛选也很快。
  • key 相等规则不稳定时,数据可能写得进去却再也查不回来。
  • 遍历顺序不应被当成业务排序;需要顺序时显式排序。

Dictionary 是什么

Dictionary<TKey, TValue> 可以理解成:

用一个 key,快速找到一个 value。

常见例子:

学生姓名 -> 分数
怪物 ID -> 怪物对象
资源路径 -> 资源缓存
物品 ID -> 物品配置

声明:

Dictionary<string, int> scores = new();

具体场景:根据玩家 ID 更新血量

假设服务器不断收到玩家扣血消息。为什么使用 Dictionary?因为消息给的是玩家 ID,业务需要直接定位对应状态,而不是每次从玩家列表头部扫描到尾部。

Dictionary<int, int> hpByPlayerId = new()
{
    [101] = 100,
    [205] = 80
};

int playerId = 205;
int damage = 30;

if (hpByPlayerId.TryGetValue(playerId, out int hp))
{
    hpByPlayerId[playerId] = Math.Max(0, hp - damage);
    Console.WriteLine(hpByPlayerId[playerId]); // 50
}
else
{
    Console.WriteLine($"未知玩家: {playerId}");
}

运行顺序是:用 205 查到 80,减去 30,再用同一个 key 覆盖成 50。如果 ID 不存在,TryGetValue 返回 false,不会把“找不到”误当成血量 0

这个场景也解释了 key 必须稳定的原因:玩家改昵称不应该让状态查找失效,所以应使用不变的玩家 ID,而不是显示名称。

含义:

key 是 string
value 是 int

写入:

scores["Alice"] = 90;
scores["Bob"] = 80;

读取:

int aliceScore = scores["Alice"];

key 不能重复

如果写:

scores["Alice"] = 90;
scores["Alice"] = 95;

最终结果是:

"Alice" -> 95

第二次会覆盖第一次。

所以字典不是存多个 Alice,而是:

一个 key 对应一个 value。

Add 和 [] 的区别

Add

scores.Add("Alice", 90);

[]

scores["Alice"] = 90;

区别:

Add:如果 key 已存在,会报错。
[]:如果 key 不存在,新增;如果 key 已存在,覆盖。

ContainsKey

直接读取不存在的 key 会报错:

int score = scores["Cindy"]; // KeyNotFoundException

所以可以先判断:

if (scores.ContainsKey("Alice"))
{
    Debug.Log(scores["Alice"]);
}

不过这会查两次:

第一次:ContainsKey 查有没有
第二次:[] 再查一次取值

更推荐 TryGetValue

TryGetValue

if (scores.TryGetValue("Alice", out int score))
{
    Debug.Log(score);
}
else
{
    Debug.Log("没找到 Alice");
}

含义:

尝试从字典里找 Alice。
如果找到了,返回 true,并把 value 放到 score 里。
如果没找到,返回 false。

out int score 可以理解成:

声明一个变量 score,让 TryGetValue 把结果填进去。

找不到就创建

“找不到就创建”是一种常见模式:

Dictionary<string, List<int>> skillsByUnit = new();

if (!skillsByUnit.TryGetValue("monster_01", out List<int> skills))
{
    skills = new List<int>();
    skillsByUnit["monster_01"] = skills;
}

skills.Add(1001);

含义:

我要给 monster_01 添加一个技能 ID。
先查这个怪有没有技能列表。
如果没有,就创建一个空列表,并放回字典。
然后 Add 技能。

这个模式叫:

按 key 分组收集数据。

计数器写法

统计每种怪物出现次数:

Dictionary<string, int> monsterCounts = new();

string monsterType = "Slime";

if (!monsterCounts.TryGetValue(monsterType, out int count))
{
    count = 0;
}

monsterCounts[monsterType] = count + 1;

也可以简写:

monsterCounts.TryGetValue(monsterType, out int count);
monsterCounts[monsterType] = count + 1;

因为 int 找不到时,out count 会是默认值 0

TryAdd

bool added = scores.TryAdd("Alice", 90);

含义:

如果 Alice 不存在,添加并返回 true。
如果 Alice 已存在,不覆盖,返回 false。

scores["Alice"] = 90 不同:

[] 会新增或覆盖。
TryAdd 只新增,不覆盖。

遍历 Dictionary

遍历 key 和 value:

foreach (KeyValuePair<string, int> pair in scores)
{
    Debug.Log($"{pair.Key}: {pair.Value}");
}

也可以用 var

foreach (var pair in scores)
{
    Debug.Log($"{pair.Key}: {pair.Value}");
}

只遍历 key:

foreach (string name in scores.Keys)
{
    Debug.Log(name);
}

只遍历 value:

foreach (int score in scores.Values)
{
    Debug.Log(score);
}

遍历时不要直接删除

错误示例:

foreach (var pair in scores)
{
    if (pair.Value < 60)
    {
        scores.Remove(pair.Key); // 危险
    }
}

Dictionaryforeach 时被修改,通常会报错:

Collection was modified

安全写法:先收集要删除的 key。

List<string> removeKeys = new();

foreach (var pair in scores)
{
    if (pair.Value < 60)
    {
        removeKeys.Add(pair.Key);
    }
}

foreach (string key in removeKeys)
{
    scores.Remove(key);
}

string key 的大小写问题

默认情况下:

Dictionary<string, int> scores = new();

scores["Alice"] = 90;
Debug.Log(scores.ContainsKey("alice")); // false

如果希望忽略大小写:

Dictionary<string, int> scores = new(StringComparer.OrdinalIgnoreCase);

这样:

scores["Alice"] = 90;
Debug.Log(scores["alice"]); // 90

资源路径、配置 key、名字 key 有时会用:

new(StringComparer.OrdinalIgnoreCase)

但不是所有 key 都应该忽略大小写,要看业务语义。

key 要稳定

适合当 key 的类型:

int
long
string
enum
稳定的 struct

不太适合随便拿会变化的对象字段当 key。

比如:

Dictionary<Vector3, Unit> unitsByPosition;

通常不太好,因为浮点数位置会变化,而且精度问题多。

更好的 key:

Dictionary<int, Unit> unitsByRuntimeId;
Dictionary<string, Unit> unitsByInstanceId;

key 的核心要求:

稳定
唯一
可比较
语义清楚

Dictionary 快的是按 key 查

ContainsValue 是存在的:

scores.ContainsValue(90)

但它要遍历整个字典,复杂度是 O(n)

Dictionary 快的是:

scores.ContainsKey("Alice")
scores.TryGetValue("Alice", out int score)

如果经常要从 value 反查 key,通常需要额外维护反向字典:

location -> asset
assetInstanceId -> location

最重要的写法

添加或覆盖:

dict[key] = value;

只新增不覆盖:

dict.TryAdd(key, value);

安全查找:

if (dict.TryGetValue(key, out var value))
{
    // 使用 value
}

找不到就创建:

if (!dict.TryGetValue(key, out var list))
{
    list = new List<int>();
    dict[key] = list;
}

list.Add(item);

计数器:

dict.TryGetValue(key, out int count);
dict[key] = count + 1;

删除:

dict.Remove(key);

一句话总结:

List 是一排东西,靠下标和顺序访问。
Dictionary 是一张映射表,靠 key 快速定位 value。