首页 / C# 入门教程 / 集合与 IEnumerable

C# 入门教程

集合与 IEnumerable

本教程共 100 篇 · 第 22 篇 · 更新于 2026-07-31 · 约 11 分钟阅读

C#C# 入门教程编程语言集合IEnumerable

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,没有装箱;而老的 ArrayListint 时每个都要包成 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 属性,以及 AddRemoveContains 等能力。可以说它是”能管理的集合”。

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]InsertRemoveAtList<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 会轻松很多——你写的 WhereSelect 接收的就是 IEnumerable<T>,所以任何集合都能直接喂进去。

Warning

不要把 IEnumerable<T> 当成”一定能反复遍历”。有些数据源(比如读网络流、读数据库)只能往前走一遍,遍历第二次可能没数据或重新发起昂贵查询。需要多次使用时,先 ToList()ToArray() 把结果固定下来。

非泛型接口与历史包袱

你可能偶尔在老代码里撞见没有尖括号的 IEnumerableICollection。它们是泛型出现前的产物,元素类型退化为 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 消费。这就是面向接口编程带来的松耦合。

集合选择的决策思路

面对一堆数据,怎么选容器?给你一条朴素的心法:

  1. 要先减后增、还要按下标取?List<T>
  2. 只要”在里面吗”和去重?HashSet<T>
  3. 要按键秒查?Dictionary<K,V>
  4. 要按规矩排队或回退?Queue<T>Stack<T>
  5. 长度铁定不变、又追求极致性能? 才考虑裸数组。

永远从”数据怎么用”倒推容器,而不是随手 new List<T>()。选对了,代码既快又省心。

Tip

初学时 .Count.Length、各种集合的方法名容易记混。一个记忆法:Count 是”数出来的”(动态集合要数一数),Length 是”天生就有的长”(数组定长)。多用几次自然就熟了。

小结

集合解决了数组不能动态扩容的痛点,而 IEnumerable<T>ICollection<T>IList<T> 这一层接口谱系,把”能遍历、能管理、能索引”逐层叠加。写方法时把参数尽量”收窄”到最小的接口(通常就是 IEnumerable<T>),代码会更灵活、更通用。下一章我们钻进最常用的 List<T>