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