集合与 IEnumerable
本教程共 100 篇 · 第 22 篇 · 更新于 2026-07-31 · 约 11 分钟阅读
22. 集合与 IEnumerable
本节目标:理解集合相比数组的优势,认识 IEnumerable
、ICollection 、IList 这一层接口谱系。
上一章我们和数组打交道,发现它有个绕不开的硬伤:长度写死,不能随便加元素。这章登场的主角是”集合(collection)“,它专治这个痛点,也是你日后写得最多的代码之一。
数组的局限
数组大小固定,建好之后不能随便加元素。想塞进第 101 个,只能新建一个更大的数组,再把旧数据一个一个搬过去。
int[] scores = [90, 85, 78];
// 想再加一个成绩?数组长度写死了,只能重新建数组再复制
这种”手动扩容”既麻烦又容易出错。更要命的是,你写的工具方法如果只认 int[],那别人想传 List<int> 就办不到,代码会变得很死板。
集合登场
集合是一类能动态增减元素的容器,位于 System.Collections.Generic 命名空间。最常用的是 List<T>、Dictionary<K,V>、HashSet<T>。它们背后其实也用数组,但会在装满时自动扩容、复制数据,对使用者完全透明——你只管 Add,扩容的脏活框架替你干了。
Tip日常开发几乎都用集合,而不是裸数组。数组只在长度确定不变、又追求极致性能(如数值计算、和底层 API 交互)时才上场。一句话:能动态就上集合,长度铁定不变才考虑数组。
为什么用泛型集合,而非老 ArrayList
在泛型(generic)出现之前,.NET 有个 ArrayList,它装的是 object,什么都能塞。但这带来两个毛病:一是**装箱(boxing)**开销(值类型塞进去要包成 object,取出来要拆箱),二是取出来还得强制转换,类型不安全,写错类型得过到运行时才炸。
泛型集合 List<int> 直接在编译期锁定”只能装 int”,装错编译就报错,运行也不装箱。这就是为什么新代码一律用泛型版本。
Note
List<int>在内存里存的是连续的int,没有装箱;而老的ArrayList存int时每个都要包成object。同样一百个数,泛型版本又快又省内存。所以”泛型”不是语法糖,是真能影响性能和正确性的设计。
IEnumerable:能挨个遍历
IEnumerable<T>(可枚举)是所有集合的共同祖先。只要一个类型实现了它,就能用 foreach 遍历。你可以把它理解成”我能一个一个往外拿元素”。
List<string> names = ["小明", "小红"];
IEnumerable<string> items = names; // List 实现了 IEnumerable
foreach (var name in items)
{
Console.WriteLine(name);
}
Note
foreach背后调用的就是IEnumerable<T>的GetEnumerator方法。你不用手写,编译器替你生成了遍历逻辑,并在遍历结束时释放枚举器。数组也实现了它,所以数组也能 foreach。
延迟执行:IEnumerable 的一个隐藏特性
IEnumerable<T> 还有一个容易忽略但很重要的特性——延迟执行(lazy evaluation)。它描述的往往不是”已经算好的结果”,而是”一套怎么算出来的规则”。直到你用 foreach 真正去取,它才动手算。
IEnumerable<int> doubles = [1, 2, 3].Where(x => x > 1);
// 到这里还没真正筛选,只是记下规则
foreach (var n in doubles)
{
Console.WriteLine(n); // 到这里才执行筛选,输出 2 3
}
这对 LINQ 特别关键:你可以把一串查询拼好,但它不会立刻跑,等资源真正被消费时才执行,省下不必要的计算。后面学 LINQ 会反复用到这个直觉。
ICollection:能数个数、能增删
ICollection<T> 在 IEnumerable<T> 之上,多了 Count 属性,以及 Add、Remove、Contains 等能力。可以说它是”能管理的集合”。
List<int> nums = [1, 2, 3];
ICollection<int> coll = nums;
coll.Add(4);
Console.WriteLine($"个数:{coll.Count}"); // 4
Console.WriteLine($"含 2 吗:{coll.Contains(2)}"); // True
IList:能按索引存取
IList<T> 进一步支持按索引读写,比如 list[0]、Insert、RemoveAt。List<T> 就是它的典型实现。
IList<string> list = ["a", "b", "c"];
list[0] = "A";
list.Insert(1, "X");
foreach (var s in list)
{
Console.Write($"{s} ");
}
// 输出:A X b c
该用哪个接口做参数
写方法时,参数类型越”窄”越灵活。只遍历就写 IEnumerable<T>,需要增删再放宽到 ICollection<T>,需要按索引改再放宽到 IList<T>。
// 只遍历,调用方传数组、List、HashSet 都可以
void PrintAll(IEnumerable<string> items)
{
foreach (var item in items)
{
Console.WriteLine(item);
}
}
Tip新手常把参数写成
List<T>,这限制了调用方只能用 List。用IEnumerable<T>能让数组、HashSet 等都能传进来,代码更通用、更松耦合。这也是”面向接口编程”最朴素的好处:方法不依赖某个具体类型,只依赖”能遍历”这个能力。
一图看清谱系
IEnumerable<T>:可遍历(foreach)ICollection<T>:可遍历 + 计数 + 增删IList<T>:可遍历 + 计数 + 增删 + 索引访问IDictionary<K,V>:按键存取(后续章节细讲)
IEnumerable<T> 在最底层,被上面每个接口继承。所以一个 List<T> 同时是这三种接口,赋值给谁都行。
Note这些接口大多有非泛型版本(如
IList)。新代码一律用泛型版本,类型更安全,运行更快,也避免了装箱拆箱的开销。看见非泛型版本,基本可以断定是老代码。
只读视图:IReadOnlyCollection / IReadOnlyList
有时你只想把集合”借出去看”,不想对方改。可以用只读接口,对方拿到的引用只能遍历、不能增删改。
List<int> nums = [1, 2, 3];
IReadOnlyList<int> readOnly = nums;
Console.WriteLine(readOnly[0]); // 能读
// readOnly.Add(4); // 编译错误:只读接口没有 Add
只读接口不改变底层数据,只是”收走了修改权限”。这在把内部集合暴露给别的模块时很有用——既让对方能读,又防止它乱改你的数据。
为什么这么设计
接口把”能做什么”和”具体怎么实现”分开。集合种类繁多(List、HashSet、Queue、Stack…),但都遵守同一套约定,于是 foreach、LINQ 才能通吃所有集合。理解这一层,后面学 LINQ 会轻松很多——你写的 Where、Select 接收的就是 IEnumerable<T>,所以任何集合都能直接喂进去。
Warning不要把
IEnumerable<T>当成”一定能反复遍历”。有些数据源(比如读网络流、读数据库)只能往前走一遍,遍历第二次可能没数据或重新发起昂贵查询。需要多次使用时,先ToList()或ToArray()把结果固定下来。
非泛型接口与历史包袱
你可能偶尔在老代码里撞见没有尖括号的 IEnumerable、ICollection。它们是泛型出现前的产物,元素类型退化为 object,既有装箱损耗又缺类型安全。现代 C# 14 开发里,请坚持用带 <T> 的泛型版本;遇到老 API 返回非泛型接口,尽快用 Cast<T>() 或 OfType<T>() 转成泛型版本再处理。
动手体会:返回 IEnumerable 的方法
理解接口谱系最好的方式,是亲手写一个”返回 IEnumerable<T>”的方法。注意这里返回的是接口,不是具体的 List<T>——调用方既能传 List 进来,也能拿到一个可遍历的结果,彼此都不被具体类型绑死。
// 返回接口而非具体 List,灵活性最高
IEnumerable<int> GetEvens(IEnumerable<int> source)
{
foreach (var n in source)
{
if (n % 2 == 0) yield return n; // 逐个产出偶数
}
}
List<int> nums = [1, 2, 3, 4, 5, 6];
foreach (var e in GetEvens(nums))
{
Console.Write($"{e} "); // 2 4 6
}
这里用到了 yield return(第 61 章会专门讲),它让方法变成一个”按需产出”的枚举器。重点是:方法签名返回 IEnumerable<int>,所以传数组、HashSet 都行,返回的也能被任何 foreach 消费。这就是面向接口编程带来的松耦合。
集合选择的决策思路
面对一堆数据,怎么选容器?给你一条朴素的心法:
- 要先减后增、还要按下标取? 选
List<T>。 - 只要”在里面吗”和去重? 选
HashSet<T>。 - 要按键秒查? 选
Dictionary<K,V>。 - 要按规矩排队或回退? 选
Queue<T>或Stack<T>。 - 长度铁定不变、又追求极致性能? 才考虑裸数组。
永远从”数据怎么用”倒推容器,而不是随手 new List<T>()。选对了,代码既快又省心。
Tip初学时
.Count和.Length、各种集合的方法名容易记混。一个记忆法:Count是”数出来的”(动态集合要数一数),Length是”天生就有的长”(数组定长)。多用几次自然就熟了。
小结
集合解决了数组不能动态扩容的痛点,而 IEnumerable<T>、ICollection<T>、IList<T> 这一层接口谱系,把”能遍历、能管理、能索引”逐层叠加。写方法时把参数尽量”收窄”到最小的接口(通常就是 IEnumerable<T>),代码会更灵活、更通用。下一章我们钻进最常用的 List<T>。