简介:在.NET开发中,List 与数组各有适用场景,动态调整大小与固定长度有时需要互相切换;这份PDF资料面向C#初中级开发者,集中讲解两者之间的转换方法,并配有可直接套用的代码示例。内容从生成数组和生成List两个方向展开,分别介绍调用ToArray()把List元素复制进新数组,以及利用List 构造函数将数组包装为可增删的集合,并给出字符串、整数等常用类型的示例。文档不仅讲解基本写法,还围绕内存分配、类型一致性以及值类型拷贝/引用类型复制等细节做了专门提醒,有助于规避常见坑点,让读者在动态集合与固定数组之间做出更合理的选择。资源共1个PDF文件,大小22KB,内容精炼,便于离线速查;目前已有945人学习,值得想要夯实C#集合基础的开发者下载阅读。 我最早接触这个需求是在写C#上位机的时候,串口那边解析完数据拿到的是一串byte[],可业务逻辑里又要按帧塞进List 去做缓冲;等数据攒够了,又得调List.ToArray()转回数组丢给解析函数。一来二去,List和数组的转换就成了我日常敲得最频繁的几行代码之一。别看这俩都是集合,用起来天差地别,转错了轻则性能白丢,重则直接改出隐蔽Bug。今天就把我这几年在C#里做List和数组互转的经验一次说清楚,从基础API到底层原理,再到真正踩过的坑,全部摊开来讲。
这篇文章适合刚学C#的新手,也适合写WPF、WinForm、上位机、服务端的老哥们——只要你代码里出现过List<T>和T[],都值得花几分钟把这里面的门道过一遍。
1. 为什么要在List和数组之间来回折腾
先说个最现实的场景。我在做C#上位机时,设备通过串口或者TCP源源不断回传字节流,通常我会用一个List<byte>做接收缓存区。原因很简单:数组定长,我事先不知道一帧数据有多长,用数组就得反复扩容、搬数据,麻烦。而List<byte>内部自动扩容,Add方法一调就完事。等到完整帧收齐,需要交给协议解析层时,解析接口往往定义成byte[]参数,这时候就得把List<byte>转成byte[]。
反过来的场景也常见。比如数据库查询返回一个List<User>,你要把它绑定到某个第三方图表控件上,控件的数据源只吃User[];或者是SQL参数化查询里,某些ORM需要一个数组类型的参数。这些时候,就得从List<T>转成T[]。
还有一种常见需求是从数组里做“动态增删”。数组本身定长,你想在中间插一个元素非常痛苦。我一般会把数组先转成List<T>,操作完再转回数组。C#里List<T>对增删改查的支持比数组丰富得多,RemoveAll、Insert、Find这些方法用起来远比数组手动循环舒服。
说到底,这俩类型本来就各有侧重:
- 数组:连续内存、固定长度、遍历访问速度极快,作为函数参数和底层接口的标准形态。
- List:动态扩容、自带一堆集合操作方法,适合在运行期频繁增减元素。
而转换就是把两者的优势串起来用。C#为这种转换提供了极其简单的语法,但简单背后藏着性能和执行时机的差异,很多人没注意。
2. 最基础的转换方法及底层原理
2.1 List转数组:一句ToArray就完了?
最无脑的写法:
List<int> list = new List<int> { 1, 2, 3, 4, 5 }; int[] array = list.ToArray();这行代码的底层逻辑不复杂:ToArray内部会分配一个全新的数组,长度等于List当前的Count,然后把内部存放元素的数组(List底层其实就是一个数组,叫_items)复制一份到新数组里。也就是说,你拿到的array和原来的list完全脱离关系,改数组元素,不会影响List里的内容。
这个过程的时间复杂度是O(n),空间开销是额外一份数组。元素是值类型时,直接位复制,很快;元素是引用类型时,复制的是引用(指针),同样很快。除非List当前容量远大于元素个数,否则ToArray的开销基本可以忽略。
但有一种情况需要注意:ToArray返回的是浅拷贝。如果List里装的是自定义类对象,数组和List共享的是同一批对象引用,你通过数组修改某个对象的属性,List里的那个对象也跟着变。这不算Bug,属于引用类型语义的正常表现,但新手容易误解成“转完就彻底分家”。
2.2 数组转List的三种姿势
数组转List比List转数组花样多一点,我按推荐程度排一下。
姿势一:List构造器
int[] array = { 1, 2, 3 }; List<int> list = new List<int>(array);这是最简单也最标准的方式。List的构造函数接受一个IEnumerable<T>,它会把传入的集合元素复制到内部数组里。和ToArray一样,这也是O(n)分配+复制。
姿势二:Linq的ToList
using System.Linq; int[] array = { 1, 2, 3 }; List<int> list = array.ToList();ToList本质上也是在内部调用List<T>的构造函数,效果和姿势一完全一样。唯一区别是它属于LINQ扩展方法,需要using System.Linq。很多人习惯这么写,没毛病。
姿势三:手动循环Add
int[] array = { 1, 2, 3 }; List<int> list = new List<int>(); foreach (var item in array) { list.Add(item); }这种方法效率最低。因为循环里每次Add都可能触发内部数组扩容,如果数组很大,会产生多次数组分配和复制。虽然现代List的扩容策略是倍增,均摊下来也没那么吓人,但相比一口气构造,多了一堆无谓的判断和调用。仅建议在需要边遍历边筛选的时候用,比如循环里判断item > 0再Add。
说到底层原理,List<T>内部其实维护了一个T[],初始容量默认为0,第一次Add时扩容到默认的4,之后按2倍增长(Capacity)。所以:
new List<T>(collection)可以直接把容量设为源集合的元素数,一步到位。ToArray()会把内部_items里从0到_size-1的部分拷贝出来,保证返回的数组长度正好等于元素个数,而不是容量大小。
这个点很关键。如果你不知道Capacity和Count的区别,会纳闷为什么ToArray的长度和你预期的不一样。它永远以Count为准。
3. 提升性能和安全性的转换技巧
真实项目里,数据量一大,基础的ToArray和ToList在多数场景下够用,但有几个进阶场景可以显著减少开销。
3.1 用构造函数预分配容量避免扩容浪费
如果你能提前预估数组的长度,写数组转List时,宁可多写两行,也别用连续Add:
var array = GetDataFromSomewhere(); var list = new List<byte>(array.Length); foreach (var b in array) { if (b != 0) // 假设要筛选非0字节 { list.Add(b); } }这比array.ToList()多了一个过滤逻辑,同时预先设置了容量。List内部不会因为不断Add而多次扩容,只会在初始化时分配一次。如果源数据有几百万条,性能差异会非常明显。
3.2 使用CollectionsMarshal做零拷贝转换(.NET 5+)
.NET 5开始,System.Runtime.InteropServices.CollectionsMarshal提供了一个方法,可以拿到List<T>底层的Span<T>,直接以数组视角访问它。但注意,它并不能把一个List<T>直接变成数组,它只是让你绕过List的封装读写内部buffer。
要看真正的零拷贝转换,可以这样做:
Span<int> span = CollectionsMarshal.AsSpan(list);之后你可以把这个span直接当数组用,不需要ToArray()分配新数组。但这个方法有个限制:只要span引用着底層数组,你在此期间不能对list执行任何可能扩容或修改容量的操作,否则底層数组一换,span指向旧内存,非常危险。更多时候,我会拿它做局部性能优化,而不是把它当通用转换工具。
3.3 转换后是否需要克隆:引用类型注意共享引用
前面提过,List转数组和数组转List都是浅拷贝。对于值类型,比如int、double、struct,拷贝后互不影响。但对于引用类型,比如List<SomeClass>,转换后数组和List里的对应元素指向同一个对象。如果业务要求“转换后修改数组不能影响原列表”,你就得做深拷贝:
// 伪代码示例:深拷贝一个类列表 var copied = list.Select(x => new SomeClass(x)).ToArray();或者用序列化手段。但说实话,大多数场景不需要深拷贝,知道这个语义就行。
3.4 用Array.Empty减少空数组分配
当你的List为空时,list.ToArray()会返回一个长度为0的新数组。频繁调用会产生大量小对象,增加GC压力。C#推荐用Array.Empty<T>()来表意空数组:
int[] arr = list.Count == 0 ? Array.Empty<int>() : list.ToArray();这个方法不会每次创建新对象,而是复用同一个静态空数组实例。对性能洁癖来说加分,对普通业务来说也算一种好习惯。
4. 常见转换场景与踩坑实录
4.1 byte[]和List 在上位机缓存中的转换
做串口或TCP通讯时,这组转换我每天写无数遍:
List<byte> buffer = new List<byte>(); // 模拟收到数据,追加到缓存 buffer.AddRange(receivedBytes); // 从缓存中取前4个字节作为一帧长度 int frameLen = BitConverter.ToInt32(buffer.Take(4).ToArray(), 0);这里的buffer.Take(4).ToArray()就是一个典型的局部小转换。数据量不大时没事,但如果每帧几百字节、每秒几千帧,每次都要分配小数组,GC压力会上去。我习惯改成直接操作数组或Span:
Span<byte> span = CollectionsMarshal.AsSpan(buffer); int frameLen = BitConverter.ToInt32(span.Slice(0, 4));注意,使用AsSpan期间不要动buffer的Add/Remove。我也被坑过一次:在异步回调里先拿了span,另一线程又往里加数据,导致底层数组扩容,span访问到旧地址,读出来的数据莫名其妙。后来我加了锁或者直接只用ArraySegment。
4.2 数组转List后修改元素,原数组会变吗?
这个问题经常出现在面试题里。正确答案是:值类型数组,原数组不变;引用类型数组,原数组对应的对象引用不变,但对象内部的字段会变。
var intArr = new int[] { 1, 2 }; var intList = intArr.ToList(); intList[0] = 100; // intArr[0] 仍然是1,两者独立 class Foo { public int Val; } var fooArr = new Foo[] { new Foo { Val = 1 } }; var fooList = fooArr.ToList(); fooList[0].Val = 200; // fooArr[0].Val 变成了200,同一个对象记住这个差异,避免在数据处理链路里无意间改了共享数据。
4.3 多维数组和交错数组转换的坑
C#里int[,]是真正的多维数组,int[][]是数组的数组(交错数组)。很多从C++转过来的朋友容易混。List不支持直接转多维数组,通常需要手动铺平:
int[,] multi = { { 1, 2 }, { 3, 4 } }; List<int> list = new List<int>(); foreach (var item in multi) list.Add(item); // 再手动reshape而交错数组转List<List<T>>则需要逐行转换:
int[][] jagged = new int[2][]; List<List<int>> listOfLists = jagged.Select(row => row.ToList()).ToList();无论哪种,都别指望(List<int>[])array这种强转,C#的数组协变只在引用类型数组上可用,而且转起来非常容易触发运行时异常。
4.4 Linq中的Cast和OfType不是你想的转换
本以为List<object>可以直接Cast<int>().ToList(),但如果你往里面存了一个long或者string,运行时就会抛异常。Cast<T>只做拆箱和引用转换,并不会做数值类型转换。例如:
var listObj = new List<object> { 1, 2, 3 }; var listInt = listObj.Cast<int>().ToList(); // 可以,因为都是int但如果某个元素是double,就炸了。想要从object准确转换,先判断类型或用Convert.ChangeType。这个坑我在解析配置文件时踩过,严格把握类型才能安全转换。
4.5 Using Linq ToArray成功后修改原List,数组会变吗?
数组是新分配的,当然不变。ToArray和ToList都是生成一套新的集合外壳,但内部元素如果是引用类型,共享引用。简单来说:集合的“壳”独立,元素引用共享。
4.6 大量转换时要注意Linq的延迟执行
别写出这种:
var arr = list.Where(x => x > 10).ToArray();这里Where是一次延迟查询,ToArray触发执行,没问题。但如果你把Where的结果保存下来,过一会儿再ToArray,源list如果已经改了,结果会不一样。我在分页处理时遇到过类似问题,以为快照了,实际是活的视图。所以转换前先确认源集合是否稳定。
5. 一个完整示例:用List和数组互转实现数据分帧解析
结合我上位机的经验,写个简化但完整的例子,把今天的转换技巧揉进去。
场景:串口收到不定长字节流,缓存在List<byte>中。协议帧格式是“帧头(1字节0xAA) + 长度(1字节) + 数据(N字节) + 校验(1字节)”。我按以下步骤解析:
private List<byte> _buffer = new List<byte>(); public void OnDataReceived(byte[] data) { _buffer.AddRange(data); ParseBuffer(); } private void ParseBuffer() { while (_buffer.Count >= 3) { if (_buffer[0] != 0xAA) { // 帧头不对,丢掉一个字节 _buffer.RemoveAt(0); continue; } int payloadLen = _buffer[1]; int totalFrameLen = 1 + 1 + payloadLen + 1; if (_buffer.Count < totalFrameLen) { // 数据不够,等待下一包 return; } // 取完整一帧,转为数组交给上层处理 byte[] frame = _buffer.GetRange(0, totalFrameLen).ToArray(); // 从缓存中移除已处理的部分 _buffer.RemoveRange(0, totalFrameLen); // 校验(简化:全字节累加和) int sum = 0; for (int i = 0; i < frame.Length - 1; i++) sum += frame[i]; if ((byte)(sum & 0xFF) != frame[frame.Length - 1]) { continue; // 校验失败,继续解析下一帧 } ProcessFrame(frame); } } private void ProcessFrame(byte[] frame) { // 业务处理,例如转成结构体 // 这里直接用BitConverter取数据 int value = BitConverter.ToInt16(frame, 2); }这个例子中用到了:
_buffer.AddRange(data):把新到的字节流byte[]合并进List<byte>。GetRange(...).ToArray():取片段并转成数组,避免直接将整个缓存转数组再拷贝。RemoveRange:处理完从缓存移除,保持List不在内存里积压。
如果性能再抠一点,我会用ArraySegment<byte>或者MemoryStream来做,但针对大多数上位机场景,上面的写法已经足够清晰可维护。数据量每秒几十KB完全没问题。
6. 性能实测与选择建议
我拿4M个int元素做了一个简单的转换性能对比(.NET 8 Release,单位毫秒):
| 转换方式 | 耗时 | 内存分配 |
|---|---|---|
| list.ToArray() | 2.3ms | 约16MB |
| new List (array) | 2.1ms | 约16MB |
| array.ToList() | 2.2ms | 约16MB |
| 手动循环Add(不预设容量) | 11.6ms | 多次扩容,约48MB |
| 手动循环Add(预设容量) | 2.0ms | 约16MB |
结论很明确:尽量用ToArray/ToList或带容量的构造函数。手动Add一定要先设定初始容量。这些数据也能看出来,C#做集合封装的作者早就优化好了,我们别自己造轮子。
选择建议做个简单总结:
- 如果你要遍历只读数据,直接用数组。
- 如果数据量动态变化、需要频繁插入删除,用List。
- 如果两者的数据需要互通,优先用
ToArray或构造函数。 - 如果追求极致性能且确认List不会被改大小,用
CollectionsMarshal.AsSpan。 - 如果你在多个方法之间传递数据,接口用
IReadOnlyList<T>或IEnumerable<T>,不要死绑定具体类型。
7. 后面我还在折腾的方向
现在.NET的集合生态越来越丰富,Span<T>、Memory<T>、FrozenSet这些新玩意慢慢普及。做上位机和高性能服务时,我开始倾向用Memory<byte>代替byte[]和List<byte>作为缓存载体,因为它既有数组的高效,又能安全切片。但常规业务开发,List和数组的互转仍然是每天要用的基本功。
如果你也是在搞C#上位机或者数据处理,建议多试试CollectionsMarshal.AsSpan和GetRange组合,能解决不少内存分配问题。最后分享一个小技巧:在写代码之前,先想清楚你要的是“引用共享”还是“数据独立”,能避免一大半集合操作相关的Bug。至于更多隐藏的细节,只有在你真正开始压数据量、跟踪内存分配时才会遇到。到时候回来看这个基础话题,估计你也会像我一样,感叹最普通的API里也藏着不少门道。
本文还有配套的精品资源,点击获取