C#中ref和out彻底拆解:从底层原理到上位机实战性能优化
2026/9/9 21:27:26 网站建设 项目流程

做了这么多年C#开发,ref和out这两个关键字算是老生常谈,但每次面试问到底,能讲透的人还真不多。更别提在真实项目里——比如上位机开发、扫码枪触发事件、TCP通讯这种场景——很多人用着用着就踩坑,参数改了没生效、返回值丢了、性能莫名变差,多半都是没搞清楚这两个关键字的本质。这篇文章我想彻底拆一次ref和out,从底层的值类型拷贝机制讲起,结合我实际写过的串口数据采集、循环UI刷新、和原生DLL交互这些案例,把它们的区别、原理、适用场景和坑全部说清楚。不管是刚入门C#的新手,还是写了好几年想查漏补缺的老手,应该都能从里面捞到点干货。

1. 先搞清楚ref和out到底在解决什么问题

1.1 值类型参数默认是“按值传递”的,这意味着一份拷贝

要理解ref和out,首先得搞清楚C#里方法参数默认的传递方式。大多数情况下(不加任何修饰符),参数是按值传递的。这里的“值”对于int、double、struct这类值类型来说,就是变量本身存的那个数据;对于class、string这类引用类型来说,传进去的是“引用本身的值”——也就是对象地址的一份拷贝。

我举个串口采集的案例。假设你写了一个方法去解析扫码枪返回的数据帧,最简单的方式是这样的:

private bool ParseFrame(byte[] buffer, int count) { // 这里尝试解析buffer中的数据 return true; }

在这个例子里,参数是按值传递的。如果我在方法内部改变了buffer指向的内容,比如执行buffer[0] = 0xFF,那外部传入的数组确实会被改动,这是因为数组是引用类型,传进来的是“引用值的拷贝”,但指向的还是同一个数组对象。可如果我对buffer变量本身重新赋值,比如buffer = new byte[1024],那外部变量一点都不会受影响——你改的是那份引用拷贝,不是外部的原始变量。

1.2 为什么需要“引用传递”

回到实际场景。我早期做一个基于TCP的工业网关通讯程序时,经常需要写一个“读取设备响应”的方法,既要返回成功与否,又要拿到解析后的数据体。早期版本我是用返回值加一个类包装来实现的:

private class ReadResult { public bool Success; public int Value1; public int Value2; }

但这样每秒钟采集很多次,每次都new一个包装类对象,堆内存压力大,GC频繁,后来UI刷新就卡顿。我改用ref参数进行数据回传,在循环采集中复用预分配的缓冲区和实体对象,彻底绕开了频繁分配对象的问题。这才是ref和out真正的价值——它们让方法能直接操作调用方的变量,而不是操作一份拷贝。

1.3 out的出现是为了解决“多返回值”的语法负担

out和ref本质上是同一套底层机制:传递变量的引用。但out的语义更明确——调用方不关心参数进来时是什么值,方法内部必须给它赋一个有意义的值。最经典的例子就是int.TryParse

if (int.TryParse(inputText, out int value)) { // 使用value }

这种TryParse模式解决了两个问题:一是返回值用来表达成功失败,二是out参数带回解析结果。C#团队在设计API时大量采用这种模式,后来甚至把它发展成了TryDoSomething的方法命名惯例。out的设计意图非常直白:数据流方向是“从方法内部流向外部”,方法调用前这个变量是什么不重要,调用后它一定会被赋值。

2. ref和out的区别:一张表讲透,再看底层原理

2.1 四个核心差异点

很多面试题喜欢问“ref和out的区别”,标准答案无非是以下几点:

对比维度refout
变量初始化要求调用前必须初始化变量,因为方法可能读取它的值调用前无需初始化,调用后必定被赋值
方法内部赋值要求可以读取参数值,也可以不赋值(但如果要赋值也可以)方法内部必须给参数赋值(且return之前必须有至少一次赋值)
数据流方向双向:既能传入值,又能传回修改结果单向:仅向外部传出结果
重载规则仅靠ref/out区分不能构成重载(二者在元数据上等价)同上

但这只是表面。你还需要理解一个底层事实:ref和out在IL(中间语言)层面的实现几乎一样,都是用托管指针(managed pointer)来传递变量的地址。区别主要体现在C#编译器的静态检查规则上——ref要求变量已明确赋值,out要求方法内保证赋值。这也是为什么你不能仅仅靠ref和out差异来重载两个方法,编译器会报“无法定义重载的ref和out方法”。

2.2 理解“托管指针”的底层本质

我在讲《C#高级编程》相关内容时,经常用一个比喻:把变量想象成一个储物柜,值类型变量就是柜子里的物品本身。按值传递时,你拿到的是一张写满物品信息的纸条,你改了纸条上的内容影响不到柜子;而ref传递时,你拿到的是一把柜子钥匙,你可以直接打开柜子换掉里面的东西。

C#语言的ref/out参数在JIT编译后的机器码层面,就是传递变量的内存地址,这和C/C++的指针传递在行为上是一致的(区别在于C#做了类型安全和可验证性检查)。所以在涉及性能敏感的场景——比如每秒几千次的循环解析、和C语言DLL互操作——ref和out能显著减少数据拷贝的开销。

2.3 从IL层面看ref和out

这里给想深挖的同学补充点源码层面的东西。假设我们有这样的代码:

public void TestRef(ref int x) { x = 10; } public void TestOut(out int x) { x = 10; }

ildasm或者dnSpy查看IL,你会发现TestRef的参数类型是int32&,TestOut的参数类型也是int32&——完全一样的托管指针。唯一的区别在于元数据中参数的特性标志(IsOut)。这就解释了为什么C#禁止仅靠ref/out互相区分来重载方法:底层的签名是冲突的,CLR层面无法区分它们。

3. 实操案例:从扫码枪数据解析到串口通讯中的ref/out实战

3.1 案例一:实现通用的值交换Swap

最经典的入门案例自然是交换两个值。这个案例虽然简单,但用来讲解“引用传递”的本质非常直观:

public void Swap(ref int a, ref int b) { int temp = a; a = b; b = temp; }

很多人会问:为什么不直接返回两个值的元组?(a, b) = (b, a)不是更简洁吗?没错,但如果你的目标是原地修改调用方的两个变量,并且想在大量数据排序的循环里复用这段逻辑,ref版本在性能和表达能力上都有优势。比如我写过一个数组循环右移的工具方法,内部就用到了Swap式的ref传递技巧,每次移位只交换3次,比新建数组拷贝快得多。

3.2 案例二:TryParse模式解析扫码枪数据帧

扫码枪在工业场景里非常常见,通过串口或者网络接口上报条码数据。我做过一个C#上位机,需要从收到的ASCII字符串中提取条码、校验位、触发时间。如果只用返回值,代码会变得非常啰嗦。用out参数会清爽得多:

public bool TryParseScanFrame(string rawLine, out string barcode, out int triggerType, out DateTime timestamp) { barcode = null; triggerType = 0; timestamp = default; if (string.IsNullOrWhiteSpace(rawLine)) return false; string[] parts = rawLine.Split(';', StringSplitOptions.RemoveEmptyEntries); // 假设数据帧格式形如:BC=1234567890;TT=2;TS=20240117153000 for (int i = 0; i < parts.Length; i++) { string part = parts[i]; if (part.StartsWith("BC=")) barcode = part.Substring(3); else if (part.StartsWith("TT=")) triggerType = Convert.ToInt32(part.Substring(3)); else if (part.StartsWith("TS=")) timestamp = DateTime.ParseExact(part.Substring(3), "yyyyMMddHHmmss", CultureInfo.InvariantCulture); } return !string.IsNullOrEmpty(barcode); }

这里注意几个细节:

  • out参数在方法开头我先赋了默认值,这是为了满足编译器的“out参数必须赋值”的要求,也算是个防御性习惯。
  • 即使解析中途某个字段缺失,返回值也是false,调用方能够感知失败状态。
  • 我把barcode用null初始化,这样调用方在失败时不会误用旧数据。

调用方可以这么写:

if (TryParseScanFrame(line, out string code, out int type, out DateTime ts)) { // 更新UI显示 labelBarcode.Text = code; } else { // 触发异常告警逻辑 }

这种TryParse模式在整个.NET生态里非常成熟,你在int.TryParseDateTime.TryParseExactConcurrentQueue.TryDequeue里面都能看到同样的设计。

3.3 案例三:循环数据采集中的引用传递与性能优化

继续讲上位机。采集循环里,数据帧源源不断进来,需要解析并实时刷新UI。如果每次解析都new返回数组和对象,循环一密集(比如每秒几百包),GC压力就会上来。这时候我通常用预先分配缓冲区的策略,配合ref/out参数复用内存:

public bool DecodeFrame(byte[] frame, int offset, int length, ref DecodedData data) { // DecodedData是结构体,不是类 data.FrameIndex++; data.RawBytes = frame.AsSpan(offset, length).ToArray(); // 其他解析逻辑... return true; }

注意这里我用了ref DecodedData data,而且DecodedData是struct类型。为什么?因为struct是值类型,按值传递会导致整个结构体被拷贝——如果结构体很大(十几个字段),拷贝成本不可忽视。用ref传递后,方法内部可以直接操作外部的同一个结构体实例,既不需要额外分配堆内存,也不需要结构体拷贝。这在性能敏感的循环里差异非常明显。

我实测过一组数据:在每秒500次采集、DecodedData结构体大约80字节的场景下,改用ref传递后GC分配频率明显下降,UI刷新的卡顿感基本消失。与之形成对比的是,如果我坚持用class或者每次都返回新结构体,运行几分钟后内存抖动就会显现,出现类似error: C9555E: failed to check out a license.这种闪现根本不相干——但性能和稳定的问题本质是内存分配过于频繁。

3.4 案例四:与C语言DLL互操作时的ref/out使用

C#上位机经常要对接工业相机SDK、PLC通讯库,这些通常都是C/C++的DLL。比如我用过一个相机SDK,它的接口定义是:

int Camera_ReadData(int handle, int* value);

在C#里用P/Invoke声明时就要用ref或者out来映射指针参数:

[DllImport("CameraSDK.dll", CallingConvention = CallingConvention.Cdecl)] private static extern int Camera_ReadData(int handle, out int value);

这里用out是合理的,因为C函数的int*参数是用来回传值的。但是有个坑:如果你的DLL函数要求传入一个已经被初始化的缓冲区指针,比如:

int Camera_ReadBuffer(int handle, unsigned char* buffer, int* size);

这个buffer参数应该用byte[]数组传递(数组本身是引用类型),而size参数如果既要传入缓冲区大小、又要输出实际读取大小,那就必须用ref:

[DllImport("CameraSDK.dll", CallingConvention = CallingConvention.Cdecl)] private static extern int Camera_ReadBuffer(int handle, byte[] buffer, ref int size);

调用时:

byte[] buf = new byte[4096]; int size = buf.Length; int ret = Camera_ReadBuffer(handle, buf, ref size); // 此时size可能被DLL修改为实际读取字节数

不需要加unsafe的固定操作,是因为byte[]在P/Invoke的过程中由marshaler处理,会自动把托管数组转成非托管指针。ref和out在这里扮演的是“让非托管代码看到同一块内存地址”的角色,非常关键。

4. 进阶用法和容易混淆的边界情况

4.1 in关键字和ref readonly返回值

随着C#版本演进,ref这个家族还发展出了inref readonly。在C# 7.2以后,你可以在参数前加in,表示“只读引用传递”——调用方式与ref相同,但方法内部不能修改该参数。这主要应用于性能优化:当参数是大型结构体时,in可以避免拷贝又保证不被修改。

public void ProcessData(in DecodedData data) { // 可以读取data.FrameIndex,但不能给data.FrameIndex赋值 }

我在实际项目中的体会:如果你正在开发一个对性能有要求的库,大型struct参数尽量用in或ref;但普通应用层面没必要滥用,代码可读性更重要。

4.2 异步方法不能用ref和out参数

这是面试常考的细节:async方法中不能使用ref或out参数。原因在于async方法的执行状态机会被暂存和恢复,而ref/out是托管指针,指向的是栈上的变量地址,状态机恢复后这些地址可能已经失效。C#编译器直接禁止了这种写法。

如果确实需要在异步方法中返回多个结果,正确的做法是使用元组或者自定义的ValueTuple

public async Task<(bool success, string message, int value)> ProcessAsync() { await Task.Delay(10); return (true, "成功", 42); }

4.3 迭代器方法也不能使用ref/out参数

和async类似,使用yield关键字的迭代器方法同样不能有ref/out参数。因为迭代器是延迟执行的,参数在方法真正执行前就会被调用方修改甚至重新赋值,导致语义混乱。解决办法是拆成两个方法:外层方法检查参数,内层私有方法接收ref/out并返回IEnumerable

4.4 ref return和ref局部变量

C# 7.0引进的ref返回,允许一个方法返回某个变量的引用,调用方可以通过ref局部变量直接修改它:

private int[] _items = new int[100]; public ref int FindItem(int index) { return ref _items[index]; } // 调用方 ref int item = ref obj.FindItem(10); item = 99; // 直接修改了_items[10]

这个特性的典型场景是性能敏感的查找,比如游戏引擎的组件数组、大型矩阵运算。但我个人的建议是:除非你明确知道你需要它(比如在做Unity ECS、写高性能库),否则普通业务代码使用ref return会让阅读者有额外的心智负担,而且稍不留意就可能让引用指向已失效的位置。

4.5 方法重载和参数匹配的规则

前面提到过,C#禁止仅靠ref/out区别来重载方法。但是void Foo(ref int x)void Foo(int x)是可以重载的,调用时编译器会根据调用方的写法来决定选择哪个。下面这段代码是可以编译的:

public void Foo(int x) { } public void Foo(ref int x) { }

但如果调用Foo(10),只会选中第一个重载,因为ref版本的实参必须是一个变量、而且调用时也要写ref关键字。很多初学者在这儿犯迷糊,实参漏写ref还报错。记住:调用时写不写ref/out关键字,是重载决策的重要依据之一

4.6 泛型和ref/out的兼容性限制

泛型方法使用ref/out参数时,需要格外小心类型参数的约束和转换。看一个反例:

public T BadMethod<T>(ref T input) where T : struct { // 某些通用逻辑 return input; }

这样写没问题。但如果你尝试把ref int隐式转换成ref object,在C#里是不允许的——即使是int可以装箱成objectref intref object也不兼容。这个限制在C# 11以前一直存在,如果你在做ref相关的泛型编程,要注意设计API时不能依赖这种转换。好在C# 11引入了ref字段和泛型ref支持的一些改进,但常规开发中遇到得少。

5. 常见问题、踩坑记录与排查经验

5.1 变量未初始化导致的编译错误

最容易踩的坑是忘记初始化ref参数:

int x; SetValue(ref x); // 编译器报错:使用了未赋值的局部变量x

相比之下,out参数就不存在这个问题:

int x; GetValue(out x); // OK

这个约束其实合情合理。既然ref允许方法读取参数的值,那调用前这个变量必须有一个明确的值。想根治这个错误,可以养成一个习惯:所有int、bool型变量声明时就给默认值,不要图省事留着不初始化。

5.2 在LINQ和lambda表达式中错误使用ref/out

你没法在lambda表达式或匿名方法中直接捕获并修改一个ref/out参数。比如:

public void Test(out int value) { value = 0; Action action = () => value = 10; // 编译错误 }

即使变量是外部捕获的class字段,你在lambda中也无法改动。这是由C#的闭包机制决定的——lambda捕获的是变量本身,不是变量的引用。遇到这种需求,可以换一种设计:用类字段、用返回值、或者把操作分成几步执行。

5.3 误以为ref能提升所有引用类型的性能

很多人有个误解:既然ref能避免值类型拷贝,那我把所有参数都加上ref是不是更快?不是。对于string、数组、class类型的参数,默认按值传递只是拷贝一个引用(指针),这个成本极低,加上ref反而增加代码复杂度、破坏方法纯粹性,并且给调用方增加心智负担。

举个例子:

public void ProcessString(string s) { s = "abc"; } // 外部不受影响 public void ProcessString(ref string s) { s = "abc"; } // 外部string变量会被改成"abc"

默认情况下的string参数修改外部变量是不可见的,这是很多人的预期,也更安全。加了ref后,方法可以直接替换外部变量指向的字符串对象,这会让调用方意外。我的建议是:如果只是为了读取引用类型的内容,别加ref;只有当你要修改的是变量本身(比如重新赋值或将其指向新对象)时,才考虑ref

5.4 ref参数与线程安全的纠缠

上位机里经常用多线程读取串口数据,然后用Invoke或者BeginInvoke更新UI。如果多个线程共享一个结构体变量,并通过ref传给不同的解析方法,很容易出现数据竞争。这是ref/out使用中最隐蔽的坑。

我的经验是:多个线程同时读写同一个ref参数指向的变量时,必须用lock、InterlockedSemaphoreSlim来保护,或者干脆每个线程分配自己的变量,互不干扰。曾经有一次我在高速采集中把同一个结构体通过ref传给多个生产者线程,结果卡片显示的数据时序偶尔紊乱,排查了一晚上才发现是数据竞争。

5.5 值类型结构体大导致copy成本高

ref/out的另一个典型应用场景是大型结构体。假设你有一个64字节的设备状态结构体,按值传递进方法会完整拷贝64字节;调用一多,性能损失就上来了。改成ref传递,只拷贝一个指针(x64下8字节),快8倍。但这里也要注意,ref传递会破坏方法纯粹性,调用方能看到方法内部对结构体的修改。如果不能接受副作用,可以考虑用in参数。

5.6 排查工具推荐:dnSpy和编译器警告

当你怀疑ref/out相关的代码有问题,建议用dnSpy查看编译后的IL,确认方法的参数标记是否正确。同时,打开编译警告:

  • CS1525:与ref/out相关的语法错误
  • CS1628:不能在匿名方法、lambda表达式中使用ref/out参数
  • CS1937:ref/out参数不能在查询表达式中使用

这些警告大多数情况下直接告诉你哪里写错了,对照着改就行。

6. 在真实项目中如何设计ref/out风格的API

6.1 设计优先原则:返回值 > out参数 > ref参数

很多团队内部的编码规范都有一条,API设计应优先使用返回值,只有在需要多返回值且性能敏感时才使用out/ref。这不是因为ref/out本身不好,而是返回值表达意图更清晰,更适合管道式写法。比如:

// 推荐:用元组表达多返回值 public (bool Success, int Value, string ErrorMessage) TryGetDeviceData(string deviceId) // 可用但不推荐:out版本 public bool TryGetDeviceData(string deviceId, out int value, out string errorMessage)

我自己写库的时候,小项目随便,遇到面向团队的基础组件,会尽量用元组和Result模式。ref/out只在两种场景下用:一是模拟.NET框架内置的TryParse模式,和生态系统保持风格一致;二是性能瓶颈明确指向结构体拷贝的时候。

6.2 结合缓存与池化,省掉out导致的默认值分配

out参数在进入方法前,编译器其实会做一个默认赋值或者清零处理,但这个开销微乎其微,不用过度优化。真正需要注意的是:out参数配合大型结构体时,调用方可能以为方法没有分配内存,其实如果方法内部给out赋了一个new的数组,堆上照样会分配。所以out和ref并不是“零分配”的代名词。

6.3 代码可读性的黄金法则:让调用点一目了然

调用ref/out参数时,实参必须带ref/out关键字,这反而成了一个优点——阅读代码时,你能清晰地看到哪些参数会受方法影响,不会被隐藏的副作用坑到。比如:

TryParseScanFrame(line, out code, out type, out ts); DecodeFrame(frame, 0, length, ref decodedData);

一眼就知道codetypets会在方法调用后被赋值,decodedData既被读取又被修改。相比C语言里没有显式标注的指针参数,C#的ref/out在安全性上进步了很大一步。

7. 几个进阶问题的逐层拆解

7.1 ref和out底层到底一样吗

我在前文提过,IL层面的托管指针是一样的,元数据里的标志不同。但是这里还有一个细节:JIT编译时,out参数在进入方法时会被视为“已清零”,所以方法内不能先读取out参数的值。ref则完整保留地址的原值。这两个语义差异是编译器保证的,不是运行时强制的。

7.2 为什么C#不能仅靠ref/out重载方法

因为CLR的类型签名中,托管指针类型本身不区分“是ref还是out”,唯一的区分是参数元数据上的一个标志位。所以void M(ref int x)void M(out int x)在元数据层面的签名是相同的,CLR无法区分,重载决策自然无从谈起。C#编译器把这个错误封闭在编译期了。

7.3 ref参数能用在属性上吗

不能。属性是方法(get_X和set_X)的语法糖,不能作为变量传入ref/out参数。比如:

this.TextBox1.Text = "hello"; Foo(ref this.TextBox1.Text); // 编译错误:属性不是变量

解决方案是先取出属性值放到局部变量,调用完毕再赋值回去。这个在WinForms/WPF开发中经常遇到,算入门级坑。

7.4 字符串是不可变的,ref string到底改了谁

ref string传引用时,方法内可以直接让外部string变量指向一个新的字符串对象。比如:

public void TrimString(ref string s) { s = s?.Trim(); }

调用后外部变量可能变成Trim后的新字符串。有人觉得这违反字符串不可变原则,其实没有——字符串对象本身不可变,但变量可以被重新赋值。理解这一点很关键:ref操作的是“变量”,不是“对象”。

7.5 有关ref struct的限制

C# 7.2引入了ref struct,比如Span<T>。ref struct只能存在于栈上,不能装箱,不能作为class的字段,也不能用于lambda捕获。如果某个方法接收ref struct参数,调用时要注意它不能被跨越异步边界传递。这些限制让我在写高性能通讯协议解析时吃了不少苦头,但也逼着我把代码设计得更干净。

7.6 什么时候ref/out比返回值更合适

一个很好的判断标准:如果方法需要修改多个“变量”本身,并且这些变量就是调用方栈上的普通局部变量,ref一定比返回值更直接、更高效。典型例子:

public void UpdatePositions(ref double x, ref double y, double deltaTime) { x += velocityX * deltaTime; y += velocityY * deltaTime; }

如果你的积分计算在每帧主循环里执行,使用ref避免构造包含x和y的临时元组,性能和代码清晰度都是更好的。

7.7 与Net 6+中的Memory和Span结合

在高性能场景(比如文件解析、TCP分包处理),C# 7.2以后的Span<T>和ref参数简直是绝配。比如我可以写一个读取TCP字节流每个数据帧起始位置的方法:

public bool TryReadFrameHeader(ReadOnlySpan<byte> buffer, ref int offset, out int frameLength) { if (offset + 2 > buffer.Length) { frameLength = 0; return false; } frameLength = (buffer[offset] << 8) | buffer[offset + 1]; offset += 2; return true; }

方法中用ref int offset持续推动当前解析位置,调用方可以反复调用直到数据消费完毕。这比每次都把offset放进一个返回值,或者每次返回更新后的offset要直观得多,而且Span切片本身不产生堆分配。

8. 实操心得与最后的几条经验

8.1 给自己的编码规范

经历了几个上位机项目和WPF桌面应用项目之后,我给自己定了这么几条关于ref/out的规矩:

  • 普通业务代码优先用返回值、元组、Result对象,ref/out只在TryParse模式或性能瓶颈处使用。
  • 结构体超过32字节且在循环里调用频繁时,用ref/in传参,避免拷贝。
  • 每个方法尽量保持参数数量在5个以内;超过5个,考虑用一个参数对象或结构体重构。
  • 变量声明即初始化,无论它是不是ref参数,这个习惯能避免一半的编译错误。
  • 如果需要在多线程中共享结构体变量,加锁,不要指望ref自动帮你保证一致性。

8.2 一次通过ref/out优化的真实数据

曾经优化过一个连续采集温湿度的WinForms程序。最初版本每秒1000次采集,数据结构体20个字段,用的是方法返回值。跑了一段时间后,内存占用稳步上升,UI刷新越来越卡,最终甚至在任务管理器里能看到明显的GC时间。后来我把解析方法改成ref DecodedData data,在调用方预分配结构体变量,循环复用,GC分配次数下降了接近90%,UI刷新变得非常顺滑。那次优化给我留下的印象很深——ref/out不只是“面试知识点”,它真的能在关键路径上改变程序的命运。

8.3 最后再分享一个小技巧

如果你在用ref/out调试时总感觉流程不好跟踪,可以在Visual Studio的调用堆栈窗口和局部变量窗口里直接查看ref参数指向的变量的当前值,另外在“即时窗口”中也可以通过ref参数来临时检查。还有个小细节:当你在重命名方法或调整参数时,把ref/out的变化一并在提交信息里写清楚,比如“Change DecodeFrame to use ref for buffer reuse”,这样后期查git历史时会非常舒服。

C#的ref和out就像一把双刃剑:用好了是性能利器,滥用则会让代码难以阅读和维护。希望这篇从原理到实战的拆解,能帮你在下一次写上位机数据解析、TCP通讯处理或者任何涉及多返回值的方法时,都能做出更合理的选择。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询