List<T> 泛型列表
本教程共 100 篇 · 第 23 篇 · 更新于 2026-07-31 · 约 12 分钟阅读
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 都能整批塞进来。比起写循环一个个Add,AddRange更短也更清楚地表达了”加一批”的意图。
读取与索引
List 用从 0 开始的索引访问,和数组一样。Count 是当前元素个数。
List<string> names = ["小明", "小红", "小刚"];
Console.WriteLine(names[0]); // 小明
Console.WriteLine(names.Count); // 3
Tip别把
Count和Length搞混:List用Count属性,数组用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
Insert和RemoveAt都要把后面的元素搬动,位置越靠前、搬得越多,开销越大(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 时照着过一遍:
- 知道规模就传初始容量:
new List<T>(n)减少扩容次数。 - 遍历中别用
for边走边删:用RemoveAll或从后往前。 Count是元素数,Capacity是内部容量,两者别混。- 需要不可变快照时用
ToList()/ToArray()固定下来,避免后续被改。 - 方法参数优先写
IEnumerable<T>或IList<T>,别写死List<T>,给调用方留余地。 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> 是集合家族里最常用的成员,增删改查都很顺手,类型安全又免扩容烦恼。理解 Count 与 Capacity 的区别,能帮你写出更高效的代码:知道规模就预设容量,遍历中别乱删。下一章我们看按键存取的 Dictionary<K,V>。