01-玩转LangChain:从模型调用到Prompt模板与输出解析的完整指南
02-玩转 LangChain Memory 模块:四种记忆类型详解及应用场景全覆盖
03-全面掌握 LangChain:从核心链条构建到动态任务分配的实战指南
04-玩转 LangChain:从文档加载到高效问答系统构建的全程实战
05-玩转 LangChain:深度评估问答系统的三种高效方法(示例生成、手动评估与LLM辅助评估)
06-从 0 到 1 掌握 LangChain Agents:自定义工具 + LLM 打造智能工作流!
07-【深度解析】从GPT-1到GPT-4:ChatGPT背后的核心原理全揭秘
01-C#与游戏开发的初次见面:从零开始的Unity之旅
02-C#入门:从变量与数据类型开始你的游戏开发之旅
03-C#运算符与表达式:从入门到游戏伤害计算实践
04-从零开始学C#:用if-else和switch打造智能游戏逻辑
05-掌握C#循环:for、while、break与continue详解及游戏案例
06-玩转C#函数:参数、返回值与游戏中的攻击逻辑封装
07-Unity游戏开发入门:用C#控制游戏对象移动
08-C#面向对象编程基础:类的定义、属性与字段详解
09-C#封装与访问修饰符:保护数据安全的利器
10-如何用C#继承提升游戏开发效率?Enemy与Boss案例解析
11-C#多态性入门:从零到游戏开发实战
12-C#接口王者之路:从入门到Unity游戏开发实战 (IAttackable案例详解)
13-C#静态成员揭秘:共享数据与方法的利器
14-Unity 面向对象实战:掌握组件化设计与脚本通信,构建玩家敌人交互
15-C#入门 Day15:彻底搞懂数组!从基础到游戏子弹管理实战
16-C# List 从入门到实战:掌握动态数组,轻松管理游戏敌人列表 (含代码示例)
17-C# 字典 (Dictionary) 完全指南:从入门到游戏属性表实战 (Day 17)
大家好!欢迎来到我们 “C# 学习与游戏开发实践 - 50 天精通之旅” 的第 17 天!在前两天,我们学习了如何使用数组(Array
)和列表(List
)来存储和管理一组数据。它们在很多场景下都非常有用,但当我们需要根据某个“标识符”快速查找对应信息时,数组和列表的效率就不那么理想了(需要遍历查找)。
想象一下查字典或者通讯录,我们不会一页一页翻,而是根据首字母或姓名直接定位。今天,我们就来学习 C# 中实现这种高效查找的关键数据结构 —— 字典(Dictionary)。字典以**键值对(Key-Value Pair)**的形式存储数据,能够让你像查字典一样,通过唯一的“键”(Key)快速找到对应的“值”(Value)。
本文将带你深入理解 C# Dictionary
的核心概念、基本操作、应用场景,并通过一个游戏开发中常见的“玩家属性表”案例,让你掌握如何在实际项目中运用字典来高效管理数据。无论你是 C# 新手还是希望巩固数据结构知识的开发者,本文都将为你提供清晰、实用的指导。
字典的核心思想非常直观,就是存储成对的信息:一个键(Key)和一个值(Value)。
类比:
这种“键 -> 值”的映射关系使得我们可以通过键快速定位到值,而不需要像遍历数组或列表那样逐个检查元素。
在 C# 中,字典由泛型类 System.Collections.Generic.Dictionary
实现。
Dictionary
: 这是一个泛型类,意味着你可以在创建字典时指定键和值的具体数据类型。TKey
: 代表键的数据类型。例如,string
(字符串)、int
(整数)、Guid
等。TKey
类型必须是可哈希的(通常意味着它需要正确实现 GetHashCode()
和 Equals()
方法,内置类型如 int
, string
等都已满足)。TValue
: 代表值的数据类型。可以是任何 C# 类型,如 int
, float
, string
, bool
, 甚至是你自己定义的类或结构体。要使用 Dictionary
,通常需要在代码文件顶部添加 using System.Collections.Generic;
指令。
字典、数组和列表都是常用的集合类型,但它们在存储方式、访问效率和使用场景上有所不同。
特性 | 数组 (Array) | 列表 (List) | 字典 (Dictionary |
---|---|---|---|
存储结构 | 固定大小的连续内存块 | 动态大小的连续内存块 (内部是数组) | 基于哈希表 (Hash Table) |
元素访问 | 通过索引 (整数, 从 0 开始) | 通过索引 (整数, 从 0 开始) | 通过唯一的键 (TKey) |
查找效率 | 索引访问: O(1) 值查找: O(n) |
索引访问: O(1) 值查找: O(n) |
键查找: 平均 O(1), 最坏 O(n) |
添加/删除 | 不支持动态增删 (大小固定) | 添加/末尾删除: 平均 O(1) 中间插入/删除: O(n) |
平均 O(1), 最坏 O(n) |
元素顺序 | 有序 (按索引) | 有序 (按添加顺序) | 通常无序 (不保证迭代顺序) |
主要用途 | 存储固定数量、类型统一的数据 | 存储可变数量、类型统一的数据 | 快速查找、映射、关联数据 |
核心优势:字典最大的优势在于其平均 O(1) 的查找、添加和删除效率。这是通过内部的哈希表实现的。简单来说,字典会计算键的哈希码(一个整数),并使用这个哈希码来快速定位到值存储的大致位置,从而避免了逐个比较。(注意:哈希冲突可能导致最坏情况下的 O(n) 效率,但在实际应用中很少发生,尤其对于内置类型作为键)。
掌握字典的基本 CRUD (Create, Read, Update, Delete) 操作是使用它的基础。
(请确保在使用以下代码示例前,已在文件顶部添加 using System;
和 using System.Collections.Generic;
)
你可以创建一个空字典,或者在创建时就初始化一些键值对。
// 1. 创建一个空字典,键是字符串(string),值是整数(int)
Dictionary<string, int> playerScores = new Dictionary<string, int>();
Console.WriteLine("Created an empty dictionary. Count: " + playerScores.Count); // 输出: 0
// 2. 使用集合初始化器创建并填充字典
// 键是字符串(string),值是字符串(string)
Dictionary<string, string> itemDescriptions = new Dictionary<string, string>
{
{ "Potion", "Restores 50 HP" },
{ "Sword", "A basic weapon" },
{ "Key", "Opens the old gate" }
};
Console.WriteLine($"Created a dictionary with {itemDescriptions.Count} items."); // 输出: 3
向字典中添加新的键值对主要有两种方法:
Add()
方法用于添加一个键值对。如果尝试添加一个已经存在的键,它会抛出 ArgumentException
异常。
playerScores.Add("Alice", 100);
playerScores.Add("Bob", 95);
Console.WriteLine($"Added Alice and Bob. Count: {playerScores.Count}"); // 输出: 2
try
{
playerScores.Add("Alice", 110); // 尝试再次添加 "Alice"
}
catch (ArgumentException ex)
{
Console.WriteLine($"Error adding duplicate key: {ex.Message}"); // 会捕获到异常
}
[]
索引器 []
提供了一种更简洁的方式来添加或更新键值对。
// 使用索引器添加 Charlie
playerScores["Charlie"] = 88;
Console.WriteLine($"Added Charlie using indexer. Score: {playerScores["Charlie"]}"); // 输出: 88
// 使用索引器更新 Alice 的分数 (因为 "Alice" 已存在)
playerScores["Alice"] = 105;
Console.WriteLine($"Updated Alice's score using indexer: {playerScores["Alice"]}"); // 输出: 105
Add()
vs 索引器 []
的关键区别:
Add()
: 只用于添加新键,重复会报错。[]
: 可用于添加新键,或更新现有键的值。同样,访问与特定键关联的值也有两种主要方式:
[]
最直接的方式,但如果键不存在于字典中,它会抛出 KeyNotFoundException
异常。
// 直接访问 Bob 的分数
int bobScore = playerScores["Bob"];
Console.WriteLine($"Bob's score (using indexer): {bobScore}"); // 输出: 95
try
{
int davidScore = playerScores["David"]; // 尝试访问不存在的键 "David"
}
catch (KeyNotFoundException ex)
{
Console.WriteLine($"Error accessing non-existent key: {ex.Message}"); // 会捕获到异常
}
TryGetValue()
是更安全、推荐的方式。它尝试获取键对应的值,如果键存在,返回 true
并通过 out
参数输出值;如果键不存在,返回 false
,不会抛出异常。
// 安全地尝试获取 Alice 的分数
if (playerScores.TryGetValue("Alice", out int aliceScore))
{
Console.WriteLine($"Successfully got Alice's score using TryGetValue: {aliceScore}"); // 输出: 105
}
else
{
Console.WriteLine("Key 'Alice' not found.");
}
// 尝试获取不存在的键 "David"
if (playerScores.TryGetValue("David", out int davidScore))
{
Console.WriteLine($"David's score: {davidScore}");
}
else
{
Console.WriteLine("Key 'David' not found using TryGetValue. (Expected)"); // 会执行这里
}
建议优先使用 TryGetValue()
来访问字典元素,以避免潜在的异常。
修改现有键对应的值,最常用的方法是使用索引器 []
。
// 假设 Bob 完成了一个任务,分数增加
if (playerScores.ContainsKey("Bob")) // 最好先检查键是否存在
{
playerScores["Bob"] = playerScores["Bob"] + 10; // 读取旧值,计算新值,再写回
// 或者直接 playerScores["Bob"] = 105;
Console.WriteLine($"Updated Bob's score after task: {playerScores["Bob"]}"); // 输出: 105 (假设原为95)
}
else
{
Console.WriteLine("Cannot update score for Bob, key not found.");
}
Remove()
方法根据键来删除一个键值对。如果成功删除,返回 true
;如果键不存在,返回 false
。
// 删除 Charlie
bool removed = playerScores.Remove("Charlie");
if (removed)
{
Console.WriteLine("Successfully removed Charlie.");
}
else
{
Console.WriteLine("Charlie not found, could not remove.");
}
Console.WriteLine($"Dictionary count after removing Charlie: {playerScores.Count}"); // 输出: 2 (假设之前有 Alice, Bob, Charlie)
Clear()
方法会移除字典中所有的键值对。
playerScores.Clear();
Console.WriteLine($"Dictionary count after Clear(): {playerScores.Count}"); // 输出: 0
ContainsKey()
是检查字典中是否包含特定键的最高效方法 (接近 O(1))。
// 重新填充 itemDescriptions 用于演示
itemDescriptions = new Dictionary<string, string>
{
{ "Potion", "Restores 50 HP" }, { "Sword", "A basic weapon" }
};
if (itemDescriptions.ContainsKey("Potion"))
{
Console.WriteLine("Dictionary contains the key 'Potion'."); // 会输出
}
if (!itemDescriptions.ContainsKey("Shield"))
{
Console.WriteLine("Dictionary does not contain the key 'Shield'."); // 会输出
}
ContainsValue()
用于检查字典中是否存在某个值。注意:这个操作效率较低,因为它需要遍历字典中的所有值 (O(n))。
if (itemDescriptions.ContainsValue("A basic weapon"))
{
Console.WriteLine("Dictionary contains the value 'A basic weapon'."); // 会输出
}
有多种方式可以遍历字典中的所有条目:
这是最常用的方式,可以同时访问键和值。
Console.WriteLine("\nIterating through KeyValuePairs:");
foreach (KeyValuePair<string, string> kvp in itemDescriptions)
{
Console.WriteLine($"Key: {kvp.Key}, Value: {kvp.Value}");
}
// 输出:
// Key: Potion, Value: Restores 50 HP
// Key: Sword, Value: A basic weapon
如果你只关心键,可以遍历 Keys
属性。
Console.WriteLine("\nIterating through Keys:");
foreach (string itemKey in itemDescriptions.Keys)
{
Console.WriteLine($"Key: {itemKey}");
// 如果需要值,可以通过键再次查找:
// Console.WriteLine($"Value: {itemDescriptions[itemKey]}");
}
// 输出:
// Key: Potion
// Key: Sword
如果你只关心值,可以遍历 Values
属性。
Console.WriteLine("\nIterating through Values:");
foreach (string itemValue in itemDescriptions.Values)
{
Console.WriteLine($"Value: {itemValue}");
}
// 输出:
// Value: Restores 50 HP
// Value: A basic weapon
重要提示:不要依赖字典的迭代顺序。虽然在较新的 .NET 版本中,Dictionary
通常会保持元素的插入顺序,但这并非所有 .NET Framework 或 .NET Core/5+ 版本的保证行为。如果需要保证顺序,应考虑使用 SortedDictionary
或在迭代前对键或键值对进行排序。
字典之所以如此重要,是因为键值对的结构天然适用于解决很多编程问题:
这是字典最核心的用途。当你需要根据一个唯一标识符(键)快速找到相关信息(值)时,字典是理想选择。
int
) 查找用户的详细信息对象 (UserObject
)。string
) 获取物品的属性 (ItemStats
)。int
) 查找对应的描述文本 (string
)。字典天然地表示了“从 A 到 B”的映射关系。
string
) 映射到中文翻译 (string
)。string
) 映射到对应的处理程序 (ActionDelegate
)。string
) 映射到配置值 (string
或 object
)。字典非常适合用来统计集合中各项出现的次数。
string
,值:次数 int
)。int
或 string
,值:次数 int
)。string
,值:数量 int
)。// 示例:统计字符频率
string message = "hello world, hello csharp!";
Dictionary<char, int> charFrequency = new Dictionary<char, int>();
foreach (char c in message)
{
if (char.IsLetterOrDigit(c)) // 只统计字母和数字
{
char lowerChar = char.ToLower(c); // 不区分大小写
if (charFrequency.TryGetValue(lowerChar, out int count))
{
charFrequency[lowerChar] = count + 1; // 已存在,计数加 1
}
else
{
charFrequency.Add(lowerChar, 1); // 不存在,添加新条目,计数为 1
}
}
}
Console.WriteLine("\nCharacter Frequency:");
foreach (var kvp in charFrequency)
{
Console.WriteLine($"'{kvp.Key}': {kvp.Value}");
}
对于那些计算成本高昂且结果可能重复使用的操作,可以用字典来缓存结果。
string
或 object
,值:查询结果 DataSet
或 List
)。Tuple
,值:路径点列表 List
)。string
,值:响应内容 string
或 byte[]
)。在游戏开发中,管理玩家或角色的各种属性(如生命值、魔法值、力量、敏捷度等)是一项常见任务。这些属性种类繁多,数据类型也可能不同。使用字典可以提供一种灵活的方式来存储和访问这些属性。
假设我们需要为一个 RPG 游戏的角色存储以下属性:
我们希望能够通过属性名称(字符串)来快速获取或修改对应的属性值。
我们可以使用 Dictionary
来实现这个需求。键是属性名称(string
),值是属性值(object
)。使用 object
作为值类型可以容纳不同的数据类型,但需要注意类型转换。
// 创建玩家属性字典
Dictionary<string, object> playerAttributes = new Dictionary<string, object>();
// 添加初始属性
playerAttributes.Add("Name", "Arin the Brave");
playerAttributes.Add("Level", 1);
playerAttributes.Add("Health", 100.0f);
playerAttributes.Add("Mana", 50.0f);
playerAttributes.Add("Strength", 10);
playerAttributes.Add("Agility", 8);
playerAttributes.Add("IsAlive", true);
Console.WriteLine("\n--- Initial Player Attributes ---");
foreach (var attribute in playerAttributes)
{
Console.WriteLine($"{attribute.Key}: {attribute.Value} (Type: {attribute.Value.GetType().Name})");
}
访问和修改属性时,需要使用属性名称作为键。由于值是 object
类型,取出后通常需要进行类型转换才能使用。推荐使用 TryGetValue
结合类型检查或模式匹配。
// 读取并可能修改生命值
string healthKey = "Health";
if (playerAttributes.TryGetValue(healthKey, out object healthValue) && healthValue is float currentHealth)
{
Console.WriteLine($"\nCurrent Health: {currentHealth}");
// 模拟受到伤害
float damage = 15.5f;
currentHealth -= damage;
// 检查是否存活
if (currentHealth <= 0)
{
currentHealth = 0;
playerAttributes["IsAlive"] = false; // 更新存活状态
Console.WriteLine("Player has fallen!");
}
// 更新字典中的生命值
playerAttributes[healthKey] = currentHealth;
Console.WriteLine($"Health after taking {damage} damage: {playerAttributes[healthKey]}");
Console.WriteLine($"Is player alive? {playerAttributes["IsAlive"]}");
}
else
{
Console.WriteLine($"Attribute '{healthKey}' not found or has incorrect type.");
}
// 升级:增加等级和力量
if (playerAttributes.TryGetValue("Level", out object levelValue) && levelValue is int currentLevel &&
playerAttributes.TryGetValue("Strength", out object strValue) && strValue is int currentStrength)
{
playerAttributes["Level"] = currentLevel + 1;
playerAttributes["Strength"] = currentStrength + 2; // 升级加 2 点力量
Console.WriteLine($"\nPlayer Leveled Up! New Level: {playerAttributes["Level"]}, New Strength: {playerAttributes["Strength"]}");
}
使用 Dictionary
管理属性的优点:
缺点:
object
,需要手动进行类型检查和转换,容易出错(运行时错误)。int
, float
, bool
)存入 object
会发生装箱(Boxing),取出时需要拆箱(Unboxing),这会带来一定的性能损耗。TryGetValue
和类型检查/转换。Dictionary
不能直接在 Unity 编辑器的 Inspector 面板中方便地查看和编辑。替代方案:
创建专门的 PlayerStats
类或结构体:
public class PlayerStats
{
public string Name;
public int Level;
public float Health;
public float Mana;
public int Strength;
public int Agility;
public bool IsAlive;
// ... 其他方法,如 TakeDamage(), LevelUp() ...
}
// 使用: PlayerStats stats = new PlayerStats(); stats.Health -= 10;
使用枚举作为键(如果值类型统一): Dictionary
public enum AttributeType { Health, Mana, Strength, Agility }
Dictionary<AttributeType, float> numericAttributes = new Dictionary<AttributeType, float>();
// numericAttributes.Add(AttributeType.Health, 100.0f);
// float health = numericAttributes[AttributeType.Health];
float
或 int
)。Unity 的 ScriptableObject: 可以创建 ScriptableObject 资源来定义属性模板或存储具体角色的属性集,方便在编辑器中管理和复用。
结论: 对于属性集合相对固定、性能和类型安全要求较高的场景(这在游戏开发中很常见),通常推荐使用专门的类或结构体。Dictionary
更适用于属性集非常动态、或者需要快速原型设计的场景,但要留意其缺点。
[]
访问一个不存在于字典中的键。ContainsKey(key)
检查键是否存在。TryGetValue(key, out value)
进行安全的访问。Add(key, value)
: 要求 key
必须是新的,否则抛出 ArgumentException
。dictionary[key] = value
:
key
已存在,会覆盖原有的值。key
不存在,会添加新的键值对。字典的值 (TValue
) 可以是值类型(如 int
, float
, struct
)或引用类型(如 string
, class
)。这会影响修改值的方式:
值类型 (Value Types): 当你从字典中获取一个值类型的值时,你得到的是该值的副本。修改这个副本不会影响字典中存储的原始值。必须将修改后的副本重新赋值给字典中的对应键。
Dictionary<string, int> counters = new Dictionary<string, int> { { "A", 1 } };
int countA = counters["A"]; // countA 是 1 (副本)
countA = countA + 1; // countA 变成 2
Console.WriteLine(counters["A"]); // 输出 1 (字典中的值未变)
counters["A"] = countA; // 必须写回
Console.WriteLine(counters["A"]); // 输出 2 (字典中的值已更新)
引用类型 (Reference Types): 当你从字典中获取一个引用类型的值时,你得到的是指向该对象的引用。通过这个引用修改对象的内部状态(如类的属性),会直接反映在字典存储的对象上,无需将引用重新赋值回字典。
public class MyData { public string Name; }
Dictionary<int, MyData> dataMap = new Dictionary<int, MyData> { { 1, new MyData { Name = "Initial" } } };
MyData data1 = dataMap[1]; // data1 是指向 MyData 对象的引用
data1.Name = "Modified"; // 修改了引用所指向对象的 Name 属性
Console.WriteLine(dataMap[1].Name); // 输出 "Modified" (字典中的对象状态已改变)
Dictionary
的高效依赖于 TKey
类型的 GetHashCode()
和 Equals()
方法。GetHashCode()
用于快速定位,Equals()
用于处理哈希冲突(多个不同的键算出了相同的哈希码)。
int
, string
, float
等),它们的哈希实现通常很好,性能有保障。TKey
),你需要确保正确地重写 (override) GetHashCode()
和 Equals()
方法。
GetHashCode()
应尽可能地为不同的对象生成不同的哈希码,且对于同一个对象多次调用应返回相同的值。Equals()
用于精确比较两个对象是否相等。a.Equals(b)
为 true
,那么 a.GetHashCode()
必须等于 b.GetHashCode()
。再次强调:不要依赖 Dictionary
的迭代顺序。如果你需要按键排序,请使用 SortedDictionary
,或者在迭代前获取 Keys
或 KeyValuePairs
,将它们排序后再进行遍历。
// 按键排序遍历
var sortedKeys = itemDescriptions.Keys.ToList(); // 获取键并转为列表
sortedKeys.Sort(); // 对键进行排序
Console.WriteLine("\nIterating by sorted keys:");
foreach (string key in sortedKeys)
{
Console.WriteLine($"Key: {key}, Value: {itemDescriptions[key]}");
}
今天我们深入学习了 C# 中的 Dictionary
,这是一个极其有用的数据结构,尤其是在需要高效查找和管理关联数据的场景中。
以下是本文的核心要点:
Add
, []
)、安全访问 (TryGetValue
)、不安全访问 ([]
)、更新 ([]
)、删除 (Remove
, Clear
)、检查存在性 (ContainsKey
, ContainsValue
) 以及多种遍历方式 (KeyValuePair
, Keys
, Values
)。Dictionary
灵活管理动态属性,但也认识到其在类型安全和性能上的缺点,并了解了替代方案(如专用类/结构体、枚举作键、ScriptableObject)。KeyNotFoundException
(优先使用 TryGetValue
),理解 Add
与索引器 []
在处理重复键时的不同行为,区分值类型和引用类型作为值时的修改方式差异,了解自定义键类型时正确实现 GetHashCode/Equals
的重要性,并且不要依赖字典的迭代顺序。字典是 C# 集合框架中的利器,熟练掌握它将大大提升你处理数据的能力。希望通过今天的学习,你对 Dictionary
有了更深入的理解!明天,我们将继续探索 C# 中其他有用的数据结构:队列(Queue)与栈(Stack)。
感谢阅读,如果你有任何疑问或想法,欢迎在评论区留言交流!