简介:面向C#初中级开发者,这份PDF资源系统讲解了泛型列表与数组之间相互转换的常用方法,解决实际编码中需在这两种数据结构间切换时的操作困惑与选择依据。文档首先介绍列表转数组的ToArray方法,说明其会创建新数组并逐个复制元素;接着讲解数组转列表时使用List构造函数接收数组参数、生成新列表的实现方式,并配有简洁示例。同时专节分析了转换中的三处关键注意点:新数据结构带来的内存分配与拷贝开销、集合与数组必须保持类型一致,以及值类型复制内容、引用类型只复制引用的差异,这些提醒能帮助开发者规避误用。资源共1个PDF文件,大小约22KB,内容精炼,适合作为日常开发的速查资料。目前已有945人学习,无论是初学者入门还是开发者快速回顾,都能从中获得清晰的转换思路与调试参考。
1. 为什么总在List和数组之间折腾
做C#开发的朋友应该都有体会,List<T>和数组T[]这两兄弟几乎天天见。一个负责动态扩容、一个主打定长存储,各有各的脾气。实际项目里最常见的场景就是:从数据库查数据回来是List<Model>,要给前端返回JSON时可能只需要某个字段的数组;或者从XmlSerializer、JsonSerializer反序列化出来的是数组,但业务处理时需要频繁增删,只能转成List<T>来做。
这个转换需求本身不难,但很多新手容易踩坑,比如改了List里的数据发现数组也跟着变了、用foreach遍历数组时删元素直接抛异常、在循环里反复调用ToArray()导致性能雪崩等。我见过的面试题里也经常藏着这个点——不直接问"怎么转",而是问"ToList()和ToArray()底层做了什么",一下就筛掉一批人。
这篇文章我就把自己这几年在项目里实际用过的转换方式、踩过的坑、以及不同写法背后的性能差异一次性说清楚。内容不搞虚的,全是能直接抄走的代码和思路。适合刚接触C#的初学者,也适合写了两三年代码但没深究过底层实现的同学。
在动手写代码之前,我先理顺一下核心问题:List<T>和数组的本质区别是什么?只有理解了这一点,你才知道什么时候该转、什么时候不该转、转了之后会不会有副作用。
List<T>本质上是一个内部维护了T[]字段的动态数组,它通过EnsureCapacity()方法在容量不够时自动申请更大的新数组并把旧数据拷贝过去。这个过程叫扩容,默认是翻倍增长。数组则是定长的连续内存块,声明了int[5]就固定5个元素,想加第6个?对不起,得先new一个更大的数组再手动搬数据,Array.Resize()底层干的就是这个事儿,但它也不是原地修改,同样是新建数组然后拷贝。
理解了这层关系,转换的底层逻辑就清楚了:List<T>转数组,本质是把内部数组里的数据复制一份到新数组;数组转List<T>,本质是新建一个List实例,把数组作为内部存储或者逐个添加元素。既然是复制,那就涉及值类型和引用类型两种完全不同的行为,这个细节后面单开一节讲。
2. List转数组的几种写法及底层行为
2.1ToArray()是首选,但要知道它做了什么
List<T>.ToArray()是最直接的转换方式,一行代码搞定。它的底层实现非常干脆:如果有元素,就调用Array.Copy()把内部数组的内容拷贝到新数组;如果没有元素,直接返回Array.Empty<T>()的缓存实例,不会额外分配内存。
List<int> numbers = new List<int> { 1, 2, 3, 4, 5 }; int[] numsArray = numbers.ToArray();有一个值得注意的细节:ToArray()返回的是全新的数组实例,和List内部持有的那个数组没有任何关系。所以你在数组操作上做任何改边(比如排序、反转、修改元素值),都不会影响到原来的List。这一点恰恰是很多新手踩坑的源头——以为ToArray()返回的是引用,改了数组会同步改List,实际上数据早就是两份独立的拷贝了。反过来也成立,改了List里的元素值,数组里的对应项也不会跟着变(引用类型除外,后面讲)。
如果你只是想用数组的方式遍历而不修改数据,ToArray()没问题。但如果你需要把List内容暴露给外部并且希望外部修改能反向生效,应该直接用List本身,不要转数组。转成数组后再反向同步,就得自己写循环,纯属给自己找麻烦。
2.2 构造器传递法——new List<T>(array)的另一面
数组转List,最直接的方式是直接把数组传给List的构造函数:
int[] numsArray = new int[] { 1, 2, 3, 4, 5 }; List<int> numsList = new List<int>(numsArray);这个构造函数做的事情很有意思:它会把数组直接当作List的内部存储来用,而不是逐个添加元素。也就是说,如果数组的长度是5,List的容量初始就是5,不需要经历从容量4扩到8的扩容过程。此时如果往List里加第6个元素,就会触发扩容,内部数组会换成新的更大的数组,原来的数组对象还驻留在内存里,等GC回收。
List<int> numsList = new List<int>(numsArray); numsList.Add(6); // 触发扩容,内部数组从长度5扩展到长度10左右(容量翻倍)另外一个冷门但可靠的写法是手动循环添加,适合需要在添加过程中做额外处理的场景:
List<int> numsList = new List<int>(numsArray.Length); foreach (int num in numsArray) { numsList.Add(num); }给List构造函数传初始容量numsArray.Length可以避免添加过程中反复扩容带来的性能损耗。数据量小无所谓,但如果是一次性转一万个元素的数组,预分配容量和不管容量之间能差出几毫秒——在性能敏感的上位机通讯、数据采集场景里,这种细节值得注意。
2.3 Linq的ToList()和ToArray()——披着Linq外衣的同门师兄弟
很多人在接触Linq之后,习惯了写list.Where(...).ToArray()或者array.Select(...).ToList(),实际上ToList()和ToArray()都是System.Linq命名空间下的扩展方法。它们不是List的成员方法,List<T>自己只有ToArray(),没有ToList()。但数组也没没有ToArray()或ToList()——它们都是扩展方法。
using System.Linq; int[] numsArray = new int[] { 1, 2, 3, 4, 5 }; List<int> numsList = numsArray.ToList(); // 等价于 new List<int>(numsArray) List<int> anotherList = new List<int> { 1, 2, 3, 4, 5 }; int[] anotherArray = anotherList.ToArray(); // 等价于 anotherList.CopyTo(new int[anotherList.Count], 0)如果你只是简单转类型,ToList()和直接写new List<T>(array)是差不多的。但ToList()的好处是可以连着其他Linq操作一起链式写,代码更简洁。比如先过滤再转数组:
int[] filteredArray = numsArray.Where(n => n > 2).ToArray(); List<int> filteredList = numsArray.Where(n => n > 2).ToList();这里有一个性能细节:Where()返回的不是具体集合,而是一个延迟执行的IEnumerable<T>。此时ToArray()和ToList()触发了实际枚举,把符合条件的元素收集到新集合里。它们内部都经历过一个Buffer<T>结构,先遍历一遍源,把数据存入临时缓冲区,再一次性拷贝到目标数组或List。也就是说,你在Where之后调ToArray(),实际发生了两次拷贝——一次进Buffer,一次从Buffer到目标数组。小数据量感知不到,但面对几十万条数据的筛选转换,这个开销就实打实出现了。
2.4 值类型和引用类型的拷贝差异——坑在这里
这个问题最隐蔽,也是面试题里最喜欢挖的陷阱。
对于值类型(int、double、struct等),ToArray()和ToList()都是逐元素复制值,新集合里的元素和旧集合里的数据完全独立,改谁都影响不到另一个。
List<int> intList = new List<int> { 1, 2, 3 }; int[] intArray = intList.ToArray(); intArray[0] = 99; // intList[0] 还是 1对于引用类型(class、string等),复制的是引用地址,不是对象本身。此时两个集合共享同一个对象实例,通过数组修改对象的某个属性,List里的对象也会跟着变。这个行为有时有用(比如批量更新学生成绩),有时会坑人(比如转换后只想改自己的副本,结果原数据被动了一通)。
class Student { public string Name { get; set; } public int Score { get; set; } } List<Student> students = new List<Student> { new Student { Name = "Tom", Score = 80 } }; Student[] studentArray = students.ToArray(); studentArray[0].Score = 100; Console.WriteLine(students[0].Score); // 输出 100,因为两个集合引用同一个 Student 实例如果你确实需要深拷贝——即转换后两边完全独立修改——就要自己实现,比如用MemberwiseClone()、用Newtonsoft.Json序列化反序列化,或者手写复制构造函数。注意,数组本身并没有提供默认的深拷贝能力,ToArray()对引用类型做的是浅拷贝。
3. 数组转List的四种写法对比
数组转List的写法,除了前面提到的构造器传参数组和ToList()扩展方法,还有几种方式在不同场景下各有优势。我整理了一个对比表,方便你抉择:
| 方式 | 代码示例 | 特点 | 适用场景 |
|---|---|---|---|
| 构造器传递 | new List<int>(array) | 直接复用数组作为内部存储,不经历循环添加,速度快 | 通用的数组转List场景 |
LinqToList() | array.ToList() | 链式写法简洁优雅,可配合Where()等操作 | 需要筛选、排序后再转List |
| 手动循环Add | foreach (var item in array) list.Add(item) | 可在添加到List过程中做额外处理,例如去重、过滤、改造元素 | 添加过程需要复杂逻辑或无法用Linq一次搞定的场景 |
Array.AsEnumerable()后ToList() | array.AsEnumerable().ToList() | 其实和ToList()效果一样,多此一举的可能性大 | 几乎不用,知道即可 |
手动for循环配合预分配容量的写法,我之前测试过,转十万个int元素到List:
int[] bigArray = Enumerable.Range(1, 100000).ToArray(); List<int> list = new List<int>(bigArray.Length); foreach (int item in bigArray) { list.Add(item); }这个版本比不预先分配容量的版本快大约30%。原因就是List<T>在Add元素时,如果发现内部数组容量不够了,会申请当前容量两倍的新数组,并把旧数据全部拷贝一遍。预分配容量让List在初始状态就知道要装十万个元素,一次到位,中间不经历任何扩容拷贝。这个优化思路在数组转List时很关键,特别是面对数据量大的场景,能省下可感知的时间。
但也要说明白:new List<T>(array)本身就是一次性把数组作为内部存储的,不需要逐元素Add,所以它是最快的。手动循环唯一的价值在于你能在循环体里做额外处理。比如需要对数组元素做某些校验、记录日志、去重操作,循环体给了你最大的灵活性。
List<Student> matchedStudents = new List<Student>(); foreach (Student s in studentsArray) { if (s.Score >= 60) { matchedStudents.Add(s); } }4. 十大高频坑点和排查方案
4.1 坑点一:多线程下一边遍历一边修改集合
List<T>不是线程安全的,如果在多个线程里一边用foreach遍历一边调用Add或Remove,会抛出InvalidOperationException: Collection was modified。数组也有类似问题,foreach遍历数组时如果改数组元素本身,不会抛异常,但如果是Multi-Dimensional Array,遍历时改元素容易产生逻辑错误。
排查思路:如果必须并发操作,可以用ConcurrentBag<T>、ConcurrentQueue<T>,或者先用ToArray()做一次快照,再基于快照做修改。实际项目里我一般是这样处理的:
List<Data> dataList = new List<Data>(); // 假设 dataList 会被多个线程访问和修改 Data[] snapshot = dataList.ToArray(); // 先获取快照 foreach (Data d in snapshot) { // 基于快照做遍历和判断 }这里用ToArray()做快照的好处是,数组是固定长度的,不会被意外的修改打乱遍历过程。但要注意ToArray()本身也要在锁或者同步机制保护下执行,否则跨线程访问同一个List依然不安全。
4.2 坑点二:List<T>.Add()的扩容导致性能劣化
不停往List里添加元素时,如果初始容量设置得不合理,会反复触发扩容。扩容一次就是一次新数组分配加一次全部元素拷贝。数据量大时,扩容次数过多会明显拖慢程序。
排查思路:如果一开始就能预估数据量的上限,就显式设置容量:
List<int> numbers = new List<int>(100000);如果无法精确预估,也可以在数据量快到边界时手动调用TrimExcess()释放多余的内部数组空间。不过要注意TrimExcess()本身也会拷贝数组,频繁调用反而不划算。
4.3 坑点三:数组长度固定,使用下标索引越界
List转数组后,如果继续用原来的逻辑按索引访问,一旦索引超出数组边界会抛出IndexOutOfRangeException。而List在索引超界时会抛出ArgumentOutOfRangeException。两者异常类型不同,在捕获异常时要注意区分,否则排查半天找不到问题。
老代码里常见这种场景:原来是List<string>,可以list[list.Count - 1]获取最后一个元素,转成数组后写成了array[array.Length - 1],但中间某个地方索引被改成了硬编码,比如array[5],而数组实际只有3个元素,直接异常。
排查思路:转换后统一改用array[array.Length - 1],并把所有硬编码下标先数一遍数组的真实长度,再决定是否保留。能用foreach的尽量用foreach,它能避免索引越界这个坑,但代价是你在遍历时拿不到当前索引位置。要同时拿到索引和元素,可以用for配合Array.Length,或者用List<T>.FindIndex()按条件查找。
4.4 坑点四:IEnumerable<T>延迟执行导致时间点差异
ints.Where(n => n > 2)返回的是延迟执行的IEnumerable<T>,只有在调用ToList()、ToArray()或foreach时才真正执行查询。如果你把这个IEnumerable保存起来,源数组或源List在后面被修改了,等到真正枚举时看到的就是修改后的数据,而不是创建查询那一刻的数据。
示例:
List<int> list = new List<int> { 1, 2, 3, 4, 5 }; IEnumerable<int> query = list.Where(n => n > 2); // 不会立即执行 list.Add(6); int[] resultArray = query.ToArray(); // 结果包含6,因为枚举时list已经加上了6排查思路:如果希望查询在某个时刻固定下来,立刻执行ToList()或ToArray(),不要保留延迟执行的IEnumerable。
4.5 坑点五:ToArray()不是万能深拷贝
前面已经说了,ToArray()对引用类型做的是浅拷贝。因此转换后两个集合里的对象是同一批实例。要避免这个坑,建议:
- 如果只想改副本,不要直接用
ToArray()转换引用类型集合,手动做深拷贝; - 用序列化的方式构造新List,常见的是
JsonConvert.DeserializeObject<List<T>>(JsonConvert.SerializeObject(originalList))。
4.6 坑点六:忘记处理数组长度为0的情况
Array.Empty<T>()和new T[0]还是有区别的。Array.Empty<T>()返回的是缓存的静态空数组,多次调用返回同一个实例。这在做对比时会有影响:
int[] a = Array.Empty<int>(); int[] b = Array.Empty<int>(); Console.WriteLine(ReferenceEquals(a, b)); // True而new int[0]每次都是新建实例。如果你在代码里用ReferenceEquals做数组比较,或者把数组作为锁对象(lock),就要注意这个差异。锁空数组是个经典的反模式,因为多个空数组可能共享同一个实例,会导致锁冲突范围扩大。
4.7 坑点七:使用List<T>.ForEach不如foreach直观
很多人为了简洁,用list.ForEach(x => ...),但这个方法有个限制:如果循环体里要修改List本身(比如Remove或Add),会直接抛异常。foreach在修改时会抛异常,ForEach也一样。因为底层都是用枚举器遍历,枚举期间不允许集合变化。
如果需要边遍历边删,只能倒序遍历或先收集要删除的元素再统一删:
list.RemoveAll(x => x.Score < 60); // 推荐,一行搞定同样,你如果有数组转List后过滤的需求,优先用list.RemoveAll(),它内部是单次遍历配合原地压缩数组,比先Where()再转回List再Remove()高效得多。
4.8 坑点八:数组转List后以为可以随意Add,但没考虑原数组和List的关系
new List<T>(array)把数组当成内部存储,但这不代表原数组和List同步变化。比如:
int[] array = new int[] { 1, 2, 3 }; List<int> list = new List<int>(array); list[0] = 100; Console.WriteLine(array[0]); // 还是1因为List在构造时拷贝了数组内容到自己的内部数组,不是直接引用原数组。所以修改List不会影响原数组。反过来,修改原数组也不会影响List。这个行为和ToArray()是两个方向上的拷贝,都是值拷贝。
4.9 坑点九:foreach里调用list.RemoveAt或list.Remove引发的异常
List<T>.RemoveAt(i)在foreach里调用必抛异常,因为List<T>的版本号会在修改时递增,而foreach的枚举器会检查版本号,不一致就抛InvalidOperationException。
最稳妥的删除方式是倒着用for循环,或者用RemoveAll():
list.RemoveAll(n => n % 2 == 0);如果你确实需要删除后记录删除的元素,可以先收集再统一删除:
var toRemove = new List<int>(); foreach (int n in list) { if (n % 2 == 0) { toRemove.Add(n); } } foreach (int n in toRemove) { list.Remove(n); }4.10 坑点十:不同数据类型的隐式转换和显式转换
List<int>转成long[]不能直接转,需要先转换元素类型:
List<int> ints = new List<int> { 1, 2, 3 }; long[] longs = ints.Select(x => (long)x).ToArray();同理,List<string>转成int[]需要先解析:
List<string> strs = new List<string> { "1", "2", "3" }; int[] nums = strs.Select(int.Parse).ToArray();这里最容易出错的是试图用List<T>.ConvertAll<TOutput>()方法:
int[] nums = strs.ConvertAll(int.Parse).ToArray();ConvertAll确实能转,但它返回的是List<TOutput>,还需要再调一次ToArray()。如果你直接用string数组转int数组,用Array.ConvertAll则更合适:
string[] strArray = new string[] { "1", "2" }; int[] intArray = Array.ConvertAll(strArray, int.Parse);5. 实战场景下的选择建议
5.1 高频数据采集与上位机场景
做上位机或者工业通讯时,设备返回的数据往往是数组形式,比如波形数据、传感器采样点,转成List<T>做动态追加是常见需求。
我给一个建议做法:如果采样点数量固定,直接用数组;如果采样点不定长,必须用List<T>,但请设置初始容量估算值,防止反复扩容。设备一帧数据通常是几百到几千字节,争取一次就到位。
5.2 批量数据展示与表格绑定
WinForm或WPF里,List<T>可以直接作为DataGridView的DataSource,但有时你只需要把列表中的某个字段提取出来绑定下拉框:
string[] deviceNames = deviceList.Select(d => d.Name).ToArray(); comboBox.Items.AddRange(deviceNames);这种场景转换效率不是关键,但要注意ToArray()返回的是值类型的快照,后续修改deviceList里的元素不会影响deviceNames。如果你希望下拉框内容联动修改,别用数组,直接在SelectedIndexChanged里重新取数。
5.3 序列化和JSON传输
System.Text.Json处理List<T>和数组的序列化结果几乎无差别,但数组在序列化时不能携带额外的元信息,而List<T>的尺寸在运行时是可变的。如果业务中需要动态增删字段,优先用List<T>;如果数据本身就是定长结构(比如矩阵的一行),用数组反而更省空间。
5.4 接口返回和Dapper映射
从数据库读取数据时,Dapper默认返回List<T>,但有时接口要求返回数组,需要转换。这里建议在方法内部直接返回IEnumerable<T>,让调用方决定是转List还是转数组。这样灵活性更高,也避免了在业务层反复转换带来的额外开销。
5.5 数组和List混合操作
最让人头疼的情况是,一部分旧的API返回DataTable,你用DataTable里的某列转成List<T>,接着又要传给新API要求T[]。这里建议先转List<T>统一处理业务逻辑,最后再一次性ToArray()给到接口层。保持转换次数最小化,代码可读性也会更好。
6. 最后的实操建议
我给自己的项目定了几条规矩,在这里一并分享给你:
第一条,尽量少在业务逻辑层显式转Array或List。上游数据结构不要被下游API拖着走,写接口时优先选择IReadOnlyCollection<T>、IEnumerable<T>这类抽象集合类型,调用方按需转换。
第二条,做转换前先想清楚是浅拷贝还是深拷贝。对引用类型,除非明确知道只是读数据,否则别盲目ToArray()。
第三条,用List<T>超过一万个元素时,预分配容量。就当是开车出门前先瞄一眼油表,免得上高速再找加油站。
第四条,删除元素优先RemoveAll(),不要自己写foreach删除循环。简洁、高效、少出错。
第五条,如果你发现自己反复在List<T>和数组之间转来转去,那很可能是一个设计信号——你选的集合类型不对。该用HashSet<T>去重的时候别拿List硬扛,该用数组定长存储的时候别为了Add方便硬上List,转换只是方案,不是银弹。
花了半天时间整理这篇,都是这些年调接口、做上位机、处理工业数据时一点点踩出来的经验。C#的集合体系本身就是一组工具,没有哪个绝对好,关键是知道每种工具适合什么活、底层干了什么事。如果你在项目里遇到什么跟List和数组转换相关的奇葩问题,欢迎在评论区聊一聊,咱们一起看看还能挖出什么有意思的细节。
本文还有配套的精品资源,点击获取