1. 为什么说 C# 集合是每个 .NET 开发者的必修课
先聊一个我踩过的坑。刚转 C# 那会儿,我写代码处理一批订单数据,图省事全塞 ArrayList 里,遍历时各种强转、拆箱,代码又臭又长不说,还时不时冒出一个 InvalidCastException。后来一位老同事看了我的代码,丢下一句话:你根本没用对集合。那之后我才意识到,C# 集合这套东西,看着简单,用好了是效率利器,用不好就是连环坑。
C# 集合本质上是 .NET 框架提供的一系列数据结构,用来存储、组织、检索和操作一组对象。它的核心价值在于,你不需要自己手写数组扩容、链表插入、哈希查找这些底层逻辑,直接挑一个合适的集合类型就能干活。而且泛型引入之后,集合从“装一堆 object”进化成了“强类型容器”,类型安全有了、性能也上来了,代码可读性更是直线提升。
这篇文章我打算把 C# 集合全体系拆开讲清楚:从最基础的数组、List、Dictionary,到 HashSet、Queue、Stack、LinkedList,再到并发集合和不可变集合,每个类型我都会说清楚它是什么、适合什么场景、怎么用、有什么坑。不管你是刚入门 C# 的新手,还是写了好几年业务代码想系统梳理一遍的开发者,这篇文章都能帮你把集合这块的知识补全。
有一点先说明:我不会像 MSDN 那样把每个 API 都列一遍,重点关注的是“什么时候该用它、什么时候不该用它”。理解了这层,你才算真正玩转了 C# 集合。
2. 集合家族全景图:先认清每个成员的定位
2.1 数组、List 与 ArrayList:从“死板”到“灵活”
数组是所有集合的根基,它在内存中是连续分配的,通过索引访问元素的速度极快——O(1) 时间复杂度的随机访问是它最大的优势。但它的缺点也很明显:长度固定,声明时就必须确定大小。你没法往一个已经创建的数组里塞更多元素,只能新建更大的数组然后把旧数据复制过去。
实际开发中,如果数据量是确定的、只读的(比如一周七天的名称、一年的月份列表),直接用数组是最干净的选择:
string[] weekDays = { "周一", "周二", "周三", "周四", "周五", "周六", "周日" }; for (int i = 0; i < weekDays.Length; i++) { Console.WriteLine(weekDays[i]); }List 就是数组的“升级版”。它在内部其实仍然是一个数组,但当你 Add 元素导致容量不够时,它会自动扩容——通常是按当前容量的两倍来扩,然后复制旧数据到新数组。这个扩容机制决定了 List 的 Add 操作均摊时间复杂度接近 O(1),但如果频繁触发扩容,会有一定的性能损耗。
那 ArrayList 又是哪来的?它是 .NET 1.0 时代的产物,内部存储的是 object。也就是说,你把一个 int 放进去,它会被装箱成 object;取出来用的时候,又得拆箱回 int。装箱拆箱的性能开销不小,而且类型安全性差,你完全可以往同一个 ArrayList 里塞字符串、整数、对象,取出来时完全不知道该强转成什么类型。现在.NET 生态里,除非是在维护老代码或者做某些反射、序列化等需要 object 容器的场景,否则一律建议用 List 代替 ArrayList。
// 不推荐:类型不安全 + 装箱拆箱 ArrayList list = new ArrayList(); list.Add(42); list.Add("hello"); int x = (int)list[0]; // 推荐:强类型,无装箱,性能更好 List<int> numbers = new List<int>(); numbers.Add(42);2.2 Dictionary 与哈希:用空间换时间的典型代表
Dictionary<TKey, TValue> 是 C# 里最常用的哈希表实现,底层依赖键的哈希码来组织数据。当你插入一个键值对时,它会先计算键的 GetHashCode(),然后根据哈希值确定存储位置;查找时同样计算哈希码,直接定位到目标位置附近,因此理想情况下查找、插入、删除的时间复杂度都是 O(1)。
这里有个关键点经常被忽略:作为键的类型,必须正确重写 Equals 和 GetHashCode。具体规则是——如果两个对象 Equals 返回 true,那么它们的 GetHashCode 必须返回相同的值。反过来说,如果两个对象的哈希码相等,Equals 不一定返回 true,这被称为哈希冲突,Dictionary 内部用链表或开放寻址法来处理冲突。
实际开发中,业务对象的默认哈希实现(引用相等)往往不满足需求。比如你希望按订单编号来去重或查找订单,那就得自己写一个键类型,或者干脆用字符串、int 这类值类型做键,省心很多:
Dictionary<string, int> stockMap = new Dictionary<string, int>(); stockMap["apple"] = 10; stockMap["banana"] = 5; if (stockMap.TryGetValue("apple", out int count)) { Console.WriteLine($"苹果库存:{count}"); }用 TryGetValue 而不是直接用索引器访问,是为了避免键不存在时抛出 KeyNotFoundException。这是一个高频坑点,我下面专门整理在常见问题里。
2.3 Set 家族:什么时候用 HashSet,什么时候用 SortedSet
HashSet 是一个不包含重复元素的集合,同样基于哈希表实现,所以添加、删除、查找元素的时间复杂度约等于 O(1)。它的典型使用场景是去重和集合运算,比如交集、并集、差集。
假设你在做一个用户标签系统,要找出同时具有“VIP”和“活跃用户”两个标签的用户,或者要合并两个渠道进来的用户名单,HashSet 就非常顺手:
HashSet<int> setA = new HashSet<int> { 1, 2, 3, 4 }; HashSet<int> setB = new HashSet<int> { 3, 4, 5, 6 }; setA.IntersectWith(setB); // setA = { 3, 4 },交集 setA.UnionWith(setB); // 并集 setA.ExceptWith(setB); // 差集SortedSet 和 HashSet 类似,也是不重复元素的集合,但内部用红黑树实现,元素会按自然顺序(或你指定的比较器)排序。它的插入、删除、查找是 O(log n),比 HashSet 慢,但好处是遍历时能得到有序序列。如果你需要有序去重存储,比如按分数排序的排行榜去重,SortedSet 是合适的选择。
2.4 队列与栈:先进先出和后进先出的两个极端
Queue 是先进先出(FIFO)的集合,就像排队买奶茶,先来的人先拿。它非常适合任务调度的场景,比如按顺序处理待办消息、广度优先搜索的节点遍历。常用方法就是 Enqueue(入队)和 Dequeue(出队),Peek 用于查看队首元素但不移除。
Stack 则是后进先出(LIFO),就像一叠盘子,后放上去的反而先被拿走。它适合用来实现撤销操作、括号匹配检测、深度优先搜索等。常用方法是 Push(压栈)、Pop(弹栈)、Peek(查看栈顶)。
我举一个实际例子:在做表达式解析时,比如判断一串括号是否合法,用 Stack 是最直接高效的思路:
public static bool IsValidBrackets(string input) { Stack<char> stack = new Stack<char>(); foreach (char c in input) { if (c is '(' or '[') { stack.Push(c); } else if (c is ')' or ']') { if (stack.Count == 0) return false; char top = stack.Pop(); if ((c == ')' && top != '(') || (c == ']' && top != '[')) return false; } } return stack.Count == 0; }2.5 LinkedList 与双向链表的设计取舍
LinkedList 是双向链表,每个节点都知道自己的前驱和后继,因此从头部或尾部插入、删除元素的时间复杂度是 O(1)。但它的随机访问是 O(n)——你得从头或尾一个个遍历才能找到中间某个位置的元素。
日常开发里,LinkedList 的使用频率远低于 List 和 Dictionary,但它有不可替代的场景。比如你需要频繁在序列中间插入或删除节点——模拟一个播放列表,在正在播放的歌曲后面随时插入下一首,LinkedList 就很合适。相比之下,List 在中间插入需要把后面的元素全部后移,代价很高。
不过我得实话实说:绝大多数业务场景中,List 够用了,而且 List 的缓存友好性更好(内存连续),遍历性能往往优于 LinkedList。LinkedList 用不好反而会带来更多的内存分配和更差的局部性。别为了“炫技”而强行上链表。
3. 深入原理:泛型、迭代器与内存分配那些事
3.1 泛型集合为什么性能更好
泛型的关键好处在生产“专用代码”上。List 和 ArrayList 相比,前者在运行时就是直接存储 int 值,后者存储的是装箱后的 object 引用。值类型在装箱时会分配在托管堆上,导致额外的内存分配 + 引用层间接访问 + GC(垃圾回收)压力增加。这些都是真实存在的性能成本,在高频调用的代码路径里尤其明显。
引用类型方面的差异稍微小一些,但泛型集合还能提供编译期类型检查,杜绝运行时类型转换异常。比如 ArrayList 里你拿出来的对象是 Cat 还是 Dog,只有运行时才知道;但 List 在编译时就锁死了。
从框架层面看,.NET 的泛型是“真泛型”,不是仅仅把 object 换了个名字。它在 JIT 编译阶段会为每个值类型参数生成专门的本地代码,避免了装箱和类型检查的开销。所以,从 C# 2.0 引入泛型之后,老式的非泛型集合类(ArrayList、Hashtable、SortedList 等)基本就被判了“死刑”。
3.2 理解迭代器模式:foreach 背后的秘密
面试的时候经常问“foreach 和 for 有什么区别”,这个话题也跟集合相关。foreach 本质上是对迭代器模式的语法糖。C# 编译器会把 foreach 展开成这样的逻辑:
using (var enumerator = collection.GetEnumerator()) { while (enumerator.MoveNext()) { var item = enumerator.Current; // 循环体 } }这带来两个关键好处:第一,你不需要关心索引、长度等细节,代码更简洁;第二,它可以作用于任何实现了 IEnumerable / IEnumerable 的类型,包括自定义集合。
但用 foreach 的时候有个坑:如果你在 foreach 循环体内修改集合(Add 或 Remove),会抛出 InvalidOperationException。因为大部分集合内部维护了一个 _version 字段,每次修改都会递增;迭代器在 MoveNext 时会检查版本号是否变化,如果变了就立即抛异常。这就是“集合已修改;可能无法执行枚举操作”这个经典报错的来源。
3.3 容量、扩容与内存分配策略
List 内部的数组有一个 Capacity 属性,它表示当前数组能容纳的元素个数,而 Count 才是实际元素个数。当你 Add 引发 Count 超过 Capacity 时,List 会创建一块新数组(通常容量变成原来的 2 倍),然后把旧元素复制过去。这个过程会产生大量的内存拷贝和临时对象。
如果你事先知道数据规模,建议用带初始容量的构造函数:
List<int> bigList = new List<int>(100000);这样可以大幅减少扩容次数,提升性能。同样,Dictionary<TKey, TValue> 也支持在构造时指定容量,比如:
var dict = new Dictionary<string, int>(1000);这等于提前告诉字典“赶紧把桶(bucket)数量分配好,别让我一次次重建内部结构”。我在处理几十万行数据的 Excel 导入时,如果不指定容量,程序能明显卡顿;指定容量后,导入速度提升非常显著。所以,一句话:拿不准规模的时候,至少给一个大致的预估值。
4. 并发场景下的集合选择:线程安全没那么简单
4.1 加锁集合与并发集合的本质差异
早期 .NET 提供了 SynchronizedCollection、Hashtable 的 SyncRoot 等方式来做线程安全集合,它们的做法是在内部的每个操作上都加锁。这种做法虽然保证单个操作的原子性,但对复合操作(比如先判断再删除)仍然无能为力。
后来 .NET 4.0 推出了 System.Collections.Concurrent 命名空间下的并发集合,核心设计思路是“细粒度并发”和“无锁或低锁实现”:
- ConcurrentDictionary<TKey, TValue>:多线程环境下安全地读写字典,支持 TryAdd、TryUpdate、GetOrAdd、AddOrUpdate 这些原子操作。
- ConcurrentQueue :线程安全队列,生产者消费者模式的首选。
- ConcurrentStack :线程安全栈。
- ConcurrentBag :一个无序、允许重复元素的线程安全集合,适合“各干各的再汇总”的场景。
- BlockingCollection :包装器,提供阻塞和限流能力,配合生产者消费者非常方便。
4.2 生产者-消费者经典模式实例
举一个典型的后台任务采集场景:一个线程负责从硬件设备读取实时数据(生产者),另一个线程负责把数据写入数据库或展示到界面(消费者)。用 ConcurrentQueue 连接两者,写起来干净利落:
ConcurrentQueue<string> queue = new ConcurrentQueue<string>(); // 生产者线程 Task.Run(() => { for (int i = 0; i < 1000; i++) { queue.Enqueue($"数据-{i}"); Thread.Sleep(10); } }); // 消费者线程 Task.Run(() => { while (true) { if (queue.TryDequeue(out string item)) { Console.WriteLine($"处理: {item}"); } else { Thread.Sleep(5); } } });这里有一个容易忽略的点:使用普通 List 加 lock 也能实现类似功能,但在高并发下锁竞争会非常激烈,性能明显下降。ConcurrentQueue 内部使用分段无锁队列设计,吞吐量在多数场景下远超“一个大锁包住所有操作”的方案。
4.3 该用同步集合还是并发集合的判断标准
不是所有多线程场景都必须上并发集合。如果你的数据量不大、操作频率不高,用 lock 包裹一个 List 也未尝不可,代码更简单、更好维护。但如果是高频读写、对吞吐量和延迟有要求,并发集合几乎是唯一合理选择。
另外特别提醒:并发集合保证的是“单个操作”的线程安全,它并不保证“一系列操作”的原子性。如果你需要先检查存在、再修改数据,这类复合逻辑还是需要自己加锁或用 ConcurrentDictionary 的原子方法(TryUpdate、AddOrUpdate 等)。
5. 不可变集合与内存效率:函数式风格带来的改变
5.1 不可变集合到底解决了什么
System.Collections.Immutable 命名空间提供了 ImmutableArray 、ImmutableList 、ImmutableDictionary<TKey, TValue>、ImmutableHashSet 等类型。它们的核心特征是:任何修改操作都不会改动原集合,而是返回一个修改后的新集合。原集合保持不变,所以可以安全地共享给多个线程,不需要额外加锁。
这种设计在配置系统、规则引擎、多线程共享状态等场景中特别有价值。比如应用配置在启动时加载一次,后续多个线程并发读取,用不可变集合就能彻底避免“读取时被修改”的问题。
var config = new Dictionary<string, string> { ["host"] = "localhost", ["port"] = "8080" }.ToImmutableDictionary(); // 任何地方读取都安全 string host = config["host"];5.2 不可变集合的性能代价与取舍
不可变集合并不是零成本的。ImmutableList 用树形结构实现修改操作,时间复杂度是 O(log n),远不如 List 的 O(1) 快。ImmutableArray 稍好,它是对数组的包装,修改会创建新数组。
所以性能敏感的迭代场景,或者修改极其频繁的代码路径里,用不可变集合是不明智的。但在读取远多于写入、共享并发读取多的场景下,它能省去大量锁同步开销,整体收益反而更高。开发中你大概率不需要到处都用不可变集合,但要清楚它的存在以及适合用在哪里,关键时刻能解决并发设计上的大麻烦。
5.3 只读与不可变的混淆点
很多新手会把 ReadOnlyCollection 和真正不可变集合搞混。ReadOnlyCollection 只是包了一层“只读视图”,底层指向的还是同一个可变集合。如果有人持有原始 List 的引用,依然能修改数据。真正的不可变集合,则是从根本上保证“谁都无法修改”。这在做 API 设计时很重要:返回 ReadOnlyCollection 只能“防君子”,真正想做到线程安全的数据共享,还是得上 Immutable 集合,或者返回副本。
6. 选择困难症指南:不同场景到底该用哪个集合
说到最后,很多人最关心的其实是“给我一个选择表”。我整理了一份基于多年开发经验的决策表:
| 场景需求 | 推荐集合 | 理由 |
|---|---|---|
| 固定数量、频繁随机访问 | 数组(T[]) | 内存连续,O(1) 随机访问,零额外开销 |
| 动态长度、尾部增删 | List | 灵活、自动扩容、遍历性能良好 |
| 需要频繁在中间插入/删除 | LinkedList | 插入删除 O(1) |
| 键值映射、按 key 快速查找 | Dictionary<TKey, TValue> | O(1) 查找,前提是键的哈希实现正确 |
| 去重 + 快速查找 | HashSet | 唯一元素,O(1) 操作 |
| 有序去重 | SortedSet | 红黑树,自动排序 |
| 先进先出任务队列 | Queue | FIFO |
| 后进先出回溯/撤销 | Stack | LIFO |
| 多线程生产者消费者 | ConcurrentQueue 或 BlockingCollection | 线程安全、高性能 |
| 多线程安全字典 | ConcurrentDictionary<TKey, TValue> | 高并发下更安全更高效 |
| 只读共享 | ImmutableArray 或 ImmutableDictionary<TKey, TValue> | 线程安全、不可变 |
这张表是我在实际项目里反复验证过的,不是从文档里抄出来的。选型的思考顺序是:先考虑数据形态(线性?键值?集合?),再考虑操作模式(增删改查的频率),最后考虑并发需求。三者都定下来,集合类型基本就呼之欲出了。
7. 实操分享:一个上位机数据采集场景中的集合应用
7.1 场景背景
我做过一个 C# 上位机项目,需要从多路传感器实时采集数据,然后按照时间窗口聚合、显示、存储。这个场景对集合的要求非常典型:既要高频写入,又要定期读取统计,还得保证数据不会在多个线程之间串线。
一开始我以为用 List 加锁就行,结果压力测试时发现 UI 线程频繁卡顿。后来我重新梳理了数据流,采用了三个集合组合的方案:
ConcurrentQueue 负责接收原始数据。生产线程(传感器驱动回调)把数据入队,处理线程从队列里消费数据,天然形成缓冲,不会丢失。
Dictionary<string, List > 按传感器 ID 分组存储数据。处理线程从队列取出每条数据后,追加到对应传感器的 List 里,方便后续统计。
ConcurrentDictionary<string, SensorSnapshot> 缓存最新快照,实时界面刷新直接读它,避免每次都要遍历全部历史数据。
7.2 关键代码架构
简化后的伪代码结构大致这样:
// 原始数据队列 private ConcurrentQueue<SensorData> _rawQueue = new(); // 按传感器分组的最近窗口数据 private Dictionary<string, List<SensorData>> _groupedData = new(); // 最新值缓存,UI 直接读取 private ConcurrentDictionary<string, SensorSnapshot> _latest = new(); private void SensorCallback(SensorData data) { _rawQueue.Enqueue(data); } private void ProcessingLoop() { while (_running) { while (_rawQueue.TryDequeue(out var data)) { lock (_groupLocker) { if (!_groupedData.TryGetValue(data.SensorId, out var list)) { list = new List<SensorData>(); _groupedData[data.SensorId] = list; } list.Add(data); } _latest[data.SensorId] = new SensorSnapshot(data); } Thread.Sleep(1); } }这里我特别使用 ConcurrentQueue 作为唯一的生产者消费者通道,处理线程做数据分组和缓存更新。注意 _groupedData 依然是普通 Dictionary,因为对它访问时只用 lock 保护;而 _latest 是并发字典,UI 线程和数据处理线程都在读写它,用并发集合方便很多。
7.3 实际调优心得
经历了这个项目之后,我对“如何用集合才高效”有了很深的体会。总结下来几个关键点,都是文档里不会明说的:
第一,不要在 UI 线程里遍历大数据集,否则界面必然卡死。把集合操作放到后台线程去,UI 线程只读最新快照。
第二,ConcurrentQueue 的 Enqueue 和 TryDequeue 是线程安全的,你不需要再加额外的锁。但如果你在多个生产者往同一个队列写数据的同时,消费者还要查询队列长度,这个长度可能只是近似值,因为数据在飞,时刻在变。
第三,List 的容量扩容是翻倍式的,如果你知道每天大概会有多少条数据,最好初始化时直接给个大容量,避免处理高峰期的扩容风暴。
第四,分组统计时千万别在 foreach 里反复用 LINQ 的 Where 去过滤数据,时间复杂度直接拉满。正确做法是维护一个按 key 索引的分组字典,让查询变成 O(1)。
8. 常见问题与避坑速查表
8.1 高频报错与解决方案
先列几个我见过的、几乎每个 C# 开发者都会遇到的高频问题:
| 问题 | 原因 | 解决方案 |
|---|---|---|
| Dictionary 抛出 KeyNotFoundException | 索引器读取不存在的键 | 使用 TryGetValue 或 ContainsKey 预判 |
| InvalidOperationException: 集合已修改 | foreach 循环中修改集合 | 使用 ToList() 先复制,或在 for 循环中操作 |
| List 扩容导致 GC 压力 | 频繁 Add 且容量不足 | 构造时指定预估容量 |
| ArrayList 装箱拆箱性能差 | 旧式非泛型集合 | 换成 List |
| ConcurrentDictionary 取值时出现“意外旧值” | 复合操作不是原子的 | 使用 TryUpdate、GetOrAdd 或自行加锁 |
| HashSet 自定义类型不去重 | 未重写 Equals / GetHashCode | 正确重写这两者,或实现 IEqualityComparer |
8.2 添加元素时的高频错误:误用索引器
Dictionary 的索引器在键不存在时会抛出异常,很多人第一步就踩这个坑。正确做法是:
if (!dict.TryAdd(key, value)) { // 键已存在,处理冲突 }也可以先 TryGetValue 拿到已有值再决定是更新还是跳过。场景不同取舍不同,但目标都是避免无意义的异常抛出。
8.3 数组转 List、List 转数组的细节
这两者互换非常常用,但细节决定成败:
int[] array = { 1, 2, 3 }; List<int> list = array.ToList(); List<int> backToArray = new List<int> { 4, 5, 6 }; int[] arr = backToArray.ToArray();ToArray / ToList 都会创建全新对象,如果在大循环里反复转换,会带来大量内存分配。能直接用 IEnumerable 的地方就不要转来转去,最典型的就是 foreach 和 LINQ 链式调用。
8.4 排序和查找的隐藏陷阱
List 的 Sort 方法默认使用元素的 IComparable 实现,对于自定义类,如果没实现正确,排序结果可能不是你想要的。用 LINQ 的 OrderBy 更容易写对,但要注意它不会改变原集合,只是返回一个新的有序序列。
查找时还要注意区分“找索引”和“找元素”。List 的 IndexOf 用默认相等比较器,FindIndex 则接受 Predicate 委托,两者语义不同,别用混。IndexOf 找的是“和给定对象相等的元素”,FindIndex 找的是“满足条件的元素”。举个例子,你想找列表中第一个大于 10 的元素的位置,必须用 FindIndex,IndexOf 做不到。
9. 进阶玩法:自定义集合与扩展方法
9.1 扩展方法让集合操作更优雅
C# 的扩展方法非常适合扩展集合的操作体验。比如,你经常会从一个集合里随机取一个元素,写成扩展方法就很顺手:
public static class CollectionExtensions { public static T RandomElement<T>(this IList<T> list) { if (list == null || list.Count == 0) throw new ArgumentException("集合不能为空"); int index = Random.Shared.Next(list.Count); return list[index]; } } // 调用 var item = myList.RandomElement();还有批量添加的扩展方法:一次性把多个元素 AddRange 进来,或者在添加前先做个去重判断。这些封装能让业务代码更干净,也更便于复用。
9.2 实现自定义集合:记住 IEnumerable 与 ICollection
当你需要自定义集合类型(比如一个日志队列、一个带提醒的字典),大概率需要实现 IEnumerable 以便支持 foreach,实现 ICollection 以便拥有 Count、Add、Remove 等方法。
一个基本骨架如下:
public class CustomCollection<T> : IEnumerable<T> { private readonly List<T> _items = new(); public void Add(T item) => _items.Add(item); public IEnumerator<T> GetEnumerator() => _items.GetEnumerator(); IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); }别小看这个骨架,很多中间件、框架里的集合扩展就是从这个起点开始的。核心思维永远是:你要告诉调用方“这个集合怎么用”,而不是把内部细节全部暴露出去。
9.3 与 LINQ 的结合:集合操作的艺术
LINQ 是把集合操作提升到新高度的关键。Where、Select、GroupBy、OrderBy 这种链式写法,让数据处理变得像流水线一样直观。举一个实际场景,从一堆订单里提取出金额大于 1000 的订单,并按用户分组统计总额:
var result = orders .Where(o => o.Amount > 1000) .GroupBy(o => o.UserId) .Select(g => new { UserId = g.Key, TotalAmount = g.Sum(x => x.Amount) }) .OrderByDescending(x => x.TotalAmount) .ToList();这段代码放在以前非 LINQ 时代,得写一堆循环和中间变量。现在一行链式调用就完成了,可读性还很高。但也要警惕滥用:LINQ 在某些情况下会产生额外的中间集合分配,性能敏感的高频循环里不如手动实现来得快。
10. 常用操作速查:开发中随用随查的清单
整理一份最常用的集合操作速查,方便你日常开发直接翻:
10.1 List 常用操作
var list = new List<int>(); list.Add(1); // 添加到末尾 list.AddRange(new[] { 2, 3 }); // 批量添加 list.Insert(0, 0); // 指定索引插入 list.Remove(2); // 删除第一个值为2的元素 list.RemoveAt(0); // 按索引删除 list.RemoveAll(x => x > 2); // 按条件删除 list.Contains(3); // 是否包含 list.IndexOf(3); // 查找索引 list.Find(x => x > 1); // 查找第一个满足条件的元素 list.FindAll(x => x > 1); // 找出所有满足条件的元素 list.Sort(); // 排序 list.Reverse(); // 反转10.2 Dictionary 常用操作
var dict = new Dictionary<string, int>(); dict.Add("key1", 1); // 添加 dict.TryAdd("key2", 2); // 安全添加 dict["key1"] = 10; // 更新 dict.TryGetValue("key1", out int val); // 安全读取 dict.Remove("key1"); // 删除 dict.ContainsKey("key2"); // 判断键存在 dict.ContainsValue(10); // 判断值存在(性能较差,慎用) dict.Keys; // 获取所有键 dict.Values; // 获取所有值10.3 HashSet 常用操作
var set = new HashSet<int> { 1, 2, 3 }; set.Add(4); // 添加,如果已存在则返回 false set.Remove(2); // 移除 set.Contains(3); // 判断存在 set.UnionWith(new[] { 4, 5 }); // 并集 set.IntersectWith(new[] { 3, 4 }); // 交集 set.ExceptWith(new[] { 3 }); // 差集 set.SymmetricExceptWith(new[] { 5 }); // 对称差集10.4 Queue 与 Stack 常用操作
var queue = new Queue<int>(); queue.Enqueue(1); queue.Enqueue(2); int head = queue.Peek(); // 查看队首,不移除 int first = queue.Dequeue(); // 取出队首并移除 var stack = new Stack<int>(); stack.Push(1); stack.Push(2); int top = stack.Peek(); // 查看栈顶 int popped = stack.Pop(); // 弹出栈顶并移除这些操作不需要死记,写多了自然就熟。关键是能意识到还有哪些方法可用,遇事不要总是绕远路。
11. 我的实践总结与最后建议
做了这么多年 .NET 开发,我最大的感受是:集合选型不是一件可以“临时拍脑袋”的事。产品的性能瓶颈、代码的可维护性、并发场景下的稳定性,往往在你写下第一行集合代码时就已注定。选错了集合,后面修修补补的成本非常高。
我给新人的建议是:先熟练掌握 List、Dictionary、HashSet 这三个最常用的类型,把它们的增删改查和常见坑位背熟。然后逐步拓展到 Queue、Stack、LinkedList,以及并发集合和不可变集合。不要一上来就追求“掌握所有类型”,而是要在实际场景中反复体会“为什么这种场景用这个集合更好”。
如果你正打算系统整理自己的 C# 集合知识,我建议自己画一张脑图,把每个集合的核心特征、适用场景、常见坑位都列出来,项目里遇到相关需求时就翻一翻。这套动作坚持一段时间,你会发现自己的代码质量和定位问题的速度都跟以前不一样了。
最后分享一个小技巧:新写的代码里,优先考虑用“接口或者抽象类型”来暴露集合,比如 IReadOnlyList 、IEnumerable ,而不要直接返回 List 。这能让你后续替换实现(比如从 List 换成 ImmutableArray)时,不必改动调用方。好的集合使用习惯,会让你的代码在未来的很长一段时间里都保持灵活和健壮。