首页 / C# 入门教程 / List<T> 泛型列表

C# 入门教程

List<T> 泛型列表

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

C#C# 入门教程编程语言List泛型

23. List 泛型列表

本节目标:掌握 List 的增删改查与遍历,理解容量(Capacity)自动扩容的机制。

上一章讲了集合的接口谱系,这章落地到最常用的那个成员——List<T>(泛型列表)。如果说数组是”定长铁盒”,List<T> 就是”能伸缩的口袋”,几乎天天都在用。

创建 List

List<T> 是泛型动态列表,T 是元素类型。用集合表达式或构造函数都能建。

List<string> names = ["小明", "小红"];
// 或者先建一个空列表,后面再添加
List<int> scores = new List<int>();
Note

尖括号里的 T 是”类型参数”。List<string> 表示只能装 string,装错类型编译就报错,这叫类型安全(type safety),能提前拦住很多 bug。老的 ArrayList 装的是 object,取出来还要强转,既慢又容易出错。所以新代码清一色用 List<T>

为什么 List 比数组更常用

数组定长,想加元素得手动搬;List<T> 把这些脏活全包了。它的底层其实也是个数组,只是当装不下时,框架自动新建一个更大的数组、把旧数据搬过去,你完全无感。对外表现出来的,就是一个”长度随心所欲”的容器。

也正因如此,List<T> 是业务代码里出现频率最高的集合。你存用户、存订单、存日志,几乎都是它。

增加元素

Add 在末尾追加一个;AddRange 一次加一批。

List<string> names = [];
names.Add("小明");
names.Add("小红");
names.AddRange(["小刚", "小美"]);
foreach (var n in names)
{
    Console.WriteLine(n);
}
Tip

AddRange 接收任何 IEnumerable<T>,所以数组、另一个 List 都能整批塞进来。比起写循环一个个 AddAddRange 更短也更清楚地表达了”加一批”的意图。

读取与索引

List 用从 0 开始的索引访问,和数组一样。Count 是当前元素个数。

List<string> names = ["小明", "小红", "小刚"];
Console.WriteLine(names[0]);      // 小明
Console.WriteLine(names.Count);   // 3
Tip

别把 CountLength 搞混:ListCount 属性,数组用 Length 属性。这是新手常写错的地方之一。记忆窍门:List 是”数(Count)“有几个,数组是”长(Length)“多少。

修改元素

直接用索引赋值就能改。

List<int> scores = [60, 70, 80];
scores[1] = 99;
Console.WriteLine(scores[1]);     // 99

删除元素

Remove 删第一个匹配值;RemoveAt 按索引删;Clear 一次性全清空。

List<string> names = ["小明", "小红", "小刚"];
names.Remove("小红");     // 按值删
names.RemoveAt(0);        // 删索引 0
foreach (var n in names)
{
    Console.WriteLine(n); // 小刚
}

在中间插入:Insert

除了末尾追加,还能在任意位置插。Insert(索引, 元素) 会把该位置及之后的元素整体后移。

List<string> names = ["小明", "小刚"];
names.Insert(1, "小红");   // 插到索引 1 的位置
foreach (var n in names) Console.Write($"{n} "); // 小明 小红 小刚
Warning

InsertRemoveAt 都要把后面的元素搬动,位置越靠前、搬得越多,开销越大(O(n))。如果频繁在头部增删,且很在意性能,LinkedList<T> 或下章的 Queue/Stack 可能更合适。日常数据量不大时,直接用 List 最简单。

判断与定位

Contains 返回 bool,常在删除或查找前先判断。IndexOf 则返回某个值第一次出现的索引,找不到返回 -1

List<int> scores = [60, 70, 80];
if (scores.Contains(70))
{
    Console.WriteLine($"70 在索引 {scores.IndexOf(70)}");
}

遍历的几种姿势

foreach 最省心;for 循环能顺手拿到索引;ForEach 方法直接对每个元素执行一个动作。

List<int> scores = [60, 70, 80];
for (int i = 0; i < scores.Count; i++)
{
    Console.WriteLine($"第{i}个:{scores[i]}");
}
scores.ForEach(s => Console.WriteLine($"分数:{s}"));
Warning

for 遍历时,若在循环里删元素,索引会错位。比如删掉索引 1 后,后面的元素前移,但 i 还在自增,于是跳过一个元素。要边遍历边删,推荐从后往前,或改用 RemoveAll

按条件删除:RemoveAll

RemoveAll 接收一个条件,把所有匹配的元素一次性删掉,比手写循环干净。

List<int> scores = [60, 70, 80, 90];
scores.RemoveAll(s => s < 75);   // 删掉不及格的
foreach (var s in scores) Console.Write($"{s} "); // 80 90

容量 Capacity 是什么

Capacity 是内部数组目前能装多少;Count 是实际装了多少。当 Count 追上 Capacity,List 会自动扩容(通常翻倍)并把旧数据复制过去。

List<int> nums = [];
for (int i = 0; i < 5; i++)
{
    nums.Add(i);
    Console.WriteLine($"Count={nums.Count}, Capacity={nums.Capacity}");
}

你会观察到 Capacity 大致走 0→4→4→4→8 这样的路线:从空开始,第一次 Add 时分配 4 个位置;装到第 5 个时翻倍到 8。

Tip

若提前知道大概要装多少,用 new List<int>(100) 指定初始容量,能少几次扩容、跑得更快。数据量大、又知道规模时,这是个值得养成的习惯。

排序

List 自带 Sort 方法,原地升序排列。倒序可以先 Sort 再 Reverse。

List<int> nums = [3, 1, 2];
nums.Sort();
foreach (var n in nums) Console.Write($"{n} "); // 1 2 3
Note

Sort 是原地排序,会改动原 List。想要”不破坏原列表、排好序的新列表”,用 LINQ 的 OrderBy(后续章节讲),它返回新的序列而不动原数据。

查找:Find 与 FindIndex

和数组类似,List 也有按条件找元素的方法。Find 返回第一个匹配的元素,FindIndex 返回它的索引。

List<int> scores = [60, 70, 80, 90];
int firstPass = scores.Find(s => s >= 80);
int idx = scores.FindIndex(s => s >= 80);
Console.WriteLine($"第一个≥80的是 {firstPass},索引 {idx}"); // 80,2

数组与 List 互转

ToArray 把 List 变成数组;集合表达式或构造函数又能把数组变成 List。

List<int> nums = [1, 2, 3];
int[] arr = nums.ToArray();
List<int> back = [.. arr];
Note

[.. arr] 是 C# 12 的集合表达式展开语法(spread),把数组里的元素平铺进新集合。比起 new List<int>(arr) 更简洁,也是现代 C# 14 推荐的写法。

List 与数组怎么选

场景选哪个
长度运行期才确定、要频繁增删List<T>
长度铁定不变、追求极致性能数组 T[]
要和底层 API / 数值计算交互数组 T[]
业务里存一批同类型对象List<T>
Warning

List<T> 不是线程安全的。多个线程同时 Add/Remove 可能破坏内部结构。多线程场景下要么自己加锁,要么用并发集合(如 ConcurrentBag<T>)。初学阶段大多在单线程里用,记住这个坑即可。

实战示例:管理一批学生成绩

把前面学的串起来,用 Student 这样的自定义类型体会 List 的真实用法。这里 Student 用主构造器(C# 12 起支持)定义,简洁明了。

List<Student> students =
[
    new Student("小明", 88),
    new Student("小红", 92),
    new Student("小刚", 75),
];

// 找出所有及格的(>= 60)
var passed = students.FindAll(s => s.Score >= 60);
foreach (var s in passed)
{
    Console.WriteLine($"{s.Name} 及格,分数 {s.Score}");
}

// 给所有人加 5 分鼓励(但不能超 100)
students.ForEach(s => s.Score = Math.Min(100, s.Score + 5));
Console.WriteLine($"加分后人数:{students.Count}");

record Student(string Name, int Score);

这个例子把”创建、条件查找、批量更新”合在一起,正是 List 在日常业务里最典型的样子。

最佳实践清单

把零散的注意点汇总成一条清单,写 List 时照着过一遍:

  1. 知道规模就传初始容量new List<T>(n) 减少扩容次数。
  2. 遍历中别用 for 边走边删:用 RemoveAll 或从后往前。
  3. Count 是元素数,Capacity 是内部容量,两者别混。
  4. 需要不可变快照时用 ToList()/ToArray() 固定下来,避免后续被改。
  5. 方法参数优先写 IEnumerable<T>IList<T>,别写死 List<T>,给调用方留余地。
  6. Insert/RemoveAt 位置靠前时开销大,大量中间增删要考虑别的集合。
Tip

还有一个常见误区:有人用 for (int i = 0; i <= list.Count; i++) 遍历,上限多写了个 =,结果访问到 list[Count] 触发 ArgumentOutOfRangeException。记住上限是 < list.Count(或 < list.Count - 1 当你需要访问 i+1 时),这是索引循环最容易踩的线。

索引越界:最常见的运行时异常

用索引访问不存在的位置,会抛出 ArgumentOutOfRangeException。比如列表只有 3 个元素(索引 0、1、2),你却去读 list[3],程序当场报错。

List<string> names = ["小明", "小红", "小刚"];
// Console.WriteLine(names[3]);   // 崩溃:索引超出范围
int idx = 2;
if (idx >= 0 && idx < names.Count)   // 先判断再取,最稳
{
    Console.WriteLine(names[idx]);
}

排查这类报错有个固定思路:先看异常信息里的”索引”和”计数”,再回代码确认是不是循环上限写错、或删元素后没更新索引。养成”取之前先判断是否越界”的习惯,能少踩很多坑。

List 与数组到底差在哪(再深入)

一句话:List 是”披着数组外衣的动态容器”。它内部持有一个私有数组,所有 Add/Remove/Insert 最终都作用在这块内部数组上。区别只在”要不要你手动管扩容”——List 替你管了,数组要你自己管。

这意味着 List 的读写性能和数组几乎一样快(都是连续内存、O(1) 下标访问),唯一额外开销是”偶尔扩容搬一次家”。日常业务里这点开销可以忽略;只有在极热路径、且长度确定时,才值得退回裸数组。所以默认用 List,是性能和开发效率的最佳平衡点。

小结

List<T> 是集合家族里最常用的成员,增删改查都很顺手,类型安全又免扩容烦恼。理解 CountCapacity 的区别,能帮你写出更高效的代码:知道规模就预设容量,遍历中别乱删。下一章我们看按键存取的 Dictionary<K,V>