
一篇能落地到日常开发里的 C# ref 和 out 笔记不聊教科书里那句“引用类型传引用、值类型传值”就完事的那种。我先从一个新手特别容易踩的现场说起。很多刚开始写 C# 的人都会遇到这种问题写了个方法想改一个 int 变量结果方法跑完外面的变量纹丝不动。void AddTen(int x) { x 10; } int num 5; AddTen(num); Console.WriteLine(num); // 还是 5这时候有人会告诉你是因为 int 是值类型默认按值传递方法里改的是副本。然后你自然会问那我想改原来的变量怎么办答案就是ref。但ref和out的关系以及它们背后的参数传递机制、适用场景、坑远比一句“用 ref 就能改原值”复杂。这篇文章想把这块讲透更适合正在写 C# 业务代码、上位机、或者准备面试的人。1. 传值拷贝带来的“改不动”才是理解 ref 和 out 的起点想彻底搞懂ref和out不能先背语法得先理解默认情况下的参数传递到底发生了什么。很多老手能一眼看出问题是因为他们脑子里的模型不是“引用类型传引用”而是“方法调用时实参的值被复制了一份给形参”。1.1 方法参数默认是值拷贝不是“把变量交进去”C# 里方法的普通参数本质上都是值传递。这句话对所有类型都成立包括大多数人口中的引用类型。区别只是值类型变量复制的是数据本身引用类型变量复制的是“引用”本身也就是那个对象地址。void ChangeList(Liststring list) { list.Add(new); list new Liststring { other }; } var myList new Liststring { old }; ChangeList(myList); Console.WriteLine(string.Join(,, myList)); // old,newlist.Add(new)对原列表有影响因为复制出来的引用仍然指向同一个 List 对象但list new Liststring...只改了形参副本的指向外面的 myList 不变。新手很容易把“能修改对象内容”误解为“能重新赋值变量”这是第一步要纠正的。也就是说默认参数机制里你操作的是变量的一份拷贝。所以当方法内部需要修改“调用方持有那个变量本身”时默认传参就做不到必须把“变量所在位置”传进去ref就是干这个的。1.2 ref 到底改了什么从“拷贝变量”变成“指向同一个存储位置”ref参数在编译和运行层面做的事情可以理解成把调用方的变量“引用”传给了方法方法内的形参不再是实参的副本而是实参所在内存位置的别名。void AddTen(ref int x) { x 10; } int num 5; AddTen(ref num); Console.WriteLine(num); // 15这里没有任何赋值行为发生x和num在方法调用期间操作的是同一个存储位置。哪怕在方法内部对一个ref int参数重新赋值也会直接影响调用方变量。在 C# 的 IL 层面对应的是ldarga、starg这类指令通过托管指针直接访问变量。不过日常写代码不需要抠到 IL只要记得ref参数让你获得了操作调用方变量本身的能力而不是一个副本。这也解释了为什么ref参数不能传常量、不能传属性因为常量没有存储位置属性本质上是方法调用返回的临时值不是变量。必须传一个能赋值、能取地址的东西比如字段、数组元素、局部变量。2. ref 和 out 的核心分歧不在“引用”而在“使用前是否必须赋值”ref和out在底层实现上基本是同一套机制都通过托管指针访问变量但编译器对它们的使用约束完全不同。理解这些约束比背两行示例代码有价值得多。2.1 out 的两个硬性编译规则调用前不用初始化返回前必须赋值out表达的是“这个方法负责把结果填给我”。所以 C# 编译器有两个强制规则任何一个不满足都编译不过调用out方法前传入的变量不要求先赋值方法内部默认它没有值方法返回之前必须给所有out参数赋值否则编译失败。bool TryParseNumber(string input, out int result) { if (int.TryParse(input, out result)) return true; result 0; return false; }再配合最经典的int.TryParse模式一眼就能看出out的语义TryParse返回是否成功真正解析出来的值通过out result带回来。bool ok int.TryParse(input, out result); if (ok) { // 使用 result }正因为必须在返回前赋值out天然适合“方法一定有输出”的场景。你可以把多个out参数当成“方法的多返回值”在很多老旧代码或不易引入元组的环境中这种写法仍然大量存在。2.2 ref 的初始化要求与“读写双向”语义和out相比ref是“我传进去一个变量方法可以读它也可以改它”。所以编译器要求调用方在传参之前必须完成变量初始化。因为方法可能先读取值再修改如果实参本身没有确定值逻辑上就是无效的。void Normalize(ref double value) { if (double.IsNaN(value) || double.IsInfinity(value)) value 0; } double ratio ComputeRatio(); // 必须先赋值 Normalize(ref ratio);ref的语义是双向的方法能读到原始数据也能改写调用方变量。out的语义是单向的方法只负责输出调用方不关心原有值。一些经验不多的开发者会把ref和out混用其实只要看“方法在赋值前需不需要读这个参数”就能判断用哪个。2.3 in 关键字与 readonly struct同一个传参家族的另一面聊到ref和out很容易漏掉同一个家族里的in。in参数也是通过引用传递但它限制为只读。方法内部不能对in参数重新赋值也不能调用那些可能修改其内部状态的成员前提是编译器能判断出来。void PrintDistance(in Vector3 point) { Console.WriteLine(point.Length()); }in最大的价值是避免大结构体拷贝。当一个结构体包含多个double或更多字段时按值传递会把整个结构体复制一遍频繁调用性能损耗不小。in传递引用又保证不会被修改非常适合只读场景。但这里有个热词里特别常见的坑如果传入的是一个没有声明readonly方法的结构体编译器在调用结构体方法时为了保证“不能修改”会在方法调用点生成一个隐式副本。热词里那句“c# 结构的方法不设置 readonly 会在 in 传值的时候被复制”指的就是这个现象。struct Point { public int X; public int Y; public double Length() Math.Sqrt(X * X Y * Y); } void PrintDistance(in Point p) { // 这里编译器可能生成 p 的拷贝再调用 Length Console.WriteLine(p.Length()); }如果Length()加readonly修饰编译器知道它不会修改字段就可以直接通过引用调用避免那个隐藏拷贝。在 C# 7.2 之后给结构体成员加readonly不仅表达意图更直接影响性能尤其是高频调用的路径上。3. 实战一TryParse 式 out 返回、本地变量与字段读取理论说再多不如把代码拆开看。这节围绕几个真实场景看看ref和out在业务代码里到底怎么用、为什么这么用。3.1 TryParse 把“结果”和“是否成功”打包返回网络热词里有一句特别典型bool ok int.tryparse(input, out result);。这是 .NET 里最普及的out用法。public static bool TryParse(string? s, out int result);它为什么不用int Parse加异常的方式因为字符串转数字是高频操作而输入不合法的概率不低。如果靠Parse抛异常再捕获异常机制的开销远大于一次普通方法调用。TryParse用bool表示成功失败用out把解析结果带回既避免异常开销又让调用方代码清晰。我在写串口数据解析时经常遇到类似场景。比如从 Modbus 寄存器读回字节需要把两个字节转成带符号整数解析失败不应当导致整个采集流程崩溃而是记录一条警告继续下一帧数据。这时定义自己的TryConvert方法就非常合适。bool TryConvertToInt16(byte high, byte low, out short value) { try { value (short)((high 8) | low); return true; } catch { value 0; return false; } }这个方法内部返回两种信息是否成功、成功时结果是多少。out提供了比返回值更自然的表达方式因为return已经被是否成功占用了。3.2 co.save(domhead, dombody, 0, ref curid) 这类方法为什么必须传 ref热词里还出现了一句string result co.save(domhead, dombody, 0, ref curid);。这种方法的典型场景是保存一条业务数据save除了返回一个字符串或对象还需要把新生成的记录 ID 通过res传回去。如果不用ref curid而是只靠返回值返回 ID代码可能变成这样string result co.Save(domhead, dombody, 0, out int newId);out在这里也完全合理。那为什么原代码用ref一种可能是方法内部逻辑是先传入一个临时 ID如果保存时检测到 ID 存在则更新ID 不存在则新建。这种情况下方法需要先读取curid判断逻辑再决定改写就必须用ref而不能用out。所以看到现成代码里save(..., ref curid)时不要简单觉得“把 out 改成 ref 也一样”。你需要关心的是方法实现里有没有提前读取这个参数。如果只写不读用out更严谨如果先读后写ref是唯一合理选择。我自己的习惯是方法签名里如果出现ref一定要在调用前给变量赋值如果出现out调用前不用管但方法内部思维要切换到“这个变量将为结果腾位置”。这两种心态对应的是完全不同的接口契约。3.3 用 out 接收多返回值时的可读性取舍C# 7.0 之后我们可以用元组返回多个值比如(bool success, int value)。那还需要out吗需要但场景变少了。out更适合“返回值已经用于主要结果附加结果通过参数带出”的 API典型的还是TryParse、Dictionary.TryGetValue。而元组更适合让方法返回一个整体结果比如查询接口返回(bool found, User user, string message)。这时候用out连写三个参数调用方会被参数列表淹没bool TryFindUser(string id, out User? user, out string? error)改用元组可以让接口更聚合。但元组在小规模高频调用时会有额外的值类型/引用类型分配问题out则没有任何额外包装。选择依据应该是返回信息之间是否强关联、调用频率和性能敏感度、以及团队的可读性偏好。4. 实战二字符串、数组、Span 场景的性能细节很多 C# 开发者第一次真正重视引用参数不是在业务逻辑里而是在性能敏感路径上。尤其是字符串处理、大数组、协议解析这类场景ref、in、ref return组合起来能把不必要的拷贝省掉但细节也最容易出错。4.1 大量字符串拼接时 ref 的作用域问题有人会想字符串是引用类型我传一个StringBuilder进去方法里追加内容外部能生效这对吧对但这和ref无关因为StringBuilder本身就是可修改引用对象。真正用到ref的场景是你想在方法里替换掉调用方持有的整个字符串变量。void AppendSuffix(ref string text) { text text _done; } string fileName report; AppendSuffix(ref fileName); Console.WriteLine(fileName); // report_done这里如果不用reftext text _done只会修改形参副本外面的 fileName 还是原值。字符串是不可变的任何“修改”都产生新对象所以只有通过ref把新对象重新赋给调用方的变量修改才成立。但这类代码要克制使用。它把方法对变量的改写隐藏在了参数列表里阅读时不如fileName AppendSuffix(fileName)直观。除非确实需要一个方法同时改多个字符串变量或者方法写在性能热点里不想多一次返回值复制否则尽量返回新字符串。4.2 Span 、ref struct 与 ref 返回避免大结构体拷贝C# 处理二进制协议时SpanT配合ref返回能少很多拷贝。SpanT本身是ref struct它只能存在于栈上不能装箱、不能作为普通字段存储、不能被异步方法捕获。它内部持有托管内存/非托管内存的引用和长度按值传递SpanT传的是这个“视图”不是底层缓冲区。但如果要返回缓冲区内部的某个切片用ref返回可以直接暴露原缓冲区位置ref byte FindFirstZero(Spanbyte data) { for (int i 0; i data.Length; i) { if (data[i] 0) return ref data[i]; } throw new InvalidOperationException(No zero found); }调用方拿到ref byte之后可以直接修改缓冲区对应位置而不需要知道索引。这在解析帧头、定位标志位时很有用。不过ref返回有严格限制不能返回局部变量引用、不能返回null除非是引用类型的ref可空、不能返回readonly字段的非只读引用。我建议普通业务代码不要一上来就上ref return先用索引或返回SpanT切片。等真到了剖析热点代码、Profilor 显示有大量结构体拷贝时再考虑用ref优化。4.3 结构体没有 readonly 时 in 传值的隐式复制问题前面简单提过in参数的隐式复制问题这节展开。假设有个高频调用的方法void UpdateAverage(in SensorReading reading) { double avg reading.Average(); }struct SensorReading { public double Temperature; public double Humidity; public double Average() (Temperature Humidity) / 2; }因为Average()没有readonly修饰编译器无法保证它不会修改Temperature或Humidity。但方法接收的又是只读in引用这时为了遵守“通过 in 不能修改实参”的约定编译器会在调用Average()前创建一份SensorReading的副本然后在这个副本上调用方法。这意味着你本来想省拷贝结果每个非readonly成员调用都额外多一次拷贝甚至比直接按值传更糟。解决办法很明确给结构体内部所有成员方法加readonly如果结构体较大考虑使用class或readonly record struct如果只是单个小结构体如Point、Color按值传递的拷贝成本通常可以忽略不需要过度优化。从热词里那句关于“结构的方法不设置 readonly 会在 in 传值的时候被复制”的讨论热度看这已经是很多人在实际优化中踩过的坑。5. 避坑ref/out 在异步方法、迭代器、属性与重载中的限制有一类问题不是因为概念不清而是 C# 编译器对ref/out使用位置有严格限制。我见过不少代码在这些限制上栽跟头所以单开一节讲清楚。5.1 不能用在 async 方法参数上以及背后的原因异步方法以及迭代器方法包含yield的方法体内不能使用ref/out参数。不是因为编译器偷懒而是因为异步方法执行时会把局部状态打包到状态机里ref参数本质上是调用方栈上变量的托管指针等到await恢复时原始栈帧可能已经不存在了指针无处可指。async Task DoWorkAsync(ref int value) // 编译错误 { await Task.Delay(100); value; }遇到这种需求思路不是绕过编译器而是调整设计把需要跨await修改的数据放到一个类实例里或者用返回值传递新值。async Taskint DoWorkAsync(int value) { await Task.Delay(100); return value 1; } int result await DoWorkAsync(5);迭代器同理yield return会把方法变成状态机ref/out参数生命周期无法保证所以也是禁止的。写代码前先想清楚比报错了再搜答案效率高。5.2 属性不能作为 ref/out 参数字段可以obj.Property不能直接传给ref/out参数因为属性本质是get_X/set_X方法不是存储位置。编译器需要的是一个可以读取和写入的内存位置属性显然不满足。int.TryParse(text, out this.Value); // 编译错误属性不能作为 out解决方法是先取字段、再赋回属性if (int.TryParse(text, out int temp)) { this.Value temp; }数组元素则可以传给ref和out因为arr[i]有明确的内存位置。比如想通过方法直接修改数组里的某个元素可以传ref arr[0]这在处理字节缓冲区时很实用void ResetByte(ref byte b) { b 0; } byte[] buffer new byte[10]; ResetByte(ref buffer[3]);5.3 ref 返回字段与 ref readonly 返回能省则省但别滥用C# 7.0 引入了ref返回。它让你可以把类内部的数组元素、字段的引用直接暴露给外部外部可以绕过属性直接修改内部状态。public class FrameBuffer { private byte[] _buffer new byte[1024]; public ref byte GetByte(int index) ref _buffer[index]; }这种设计的价值在于避免GetByte返回一个字节的拷贝尤其是在高频访问协议缓冲区时。但风险也很明显外部拿到了内部数组的引用可以从GetByte(0)一路写到GetByte(1023)完全破坏封装。如果只想暴露只读引用可以用ref readonly返回让调用方无法通过该引用修改目标。但要注意ref readonly返回的语义在 C# 12 之前需要调用方声明ref readonly局部变量绑定否则会创建拷贝。这些细节比较绕我的建议是除非你在写高性能库否则优先返回字段值或ReadOnlySpanT可读性和封装性都更好。5.4 重载解析的坑ref/out 修饰符参与签名但不区分实际参数C# 方法重载是可以区分ref、out、in参数的。也就是说下面两个方法可以共存void Process(ref int value); void Process(out int value);但你调用时如果写Process(ref number)编译器会选择第一个写Process(out number)会选择第二个。这没问题容易踩坑的是当重构代码把参数从ref改成out或反过来时调用点往往也会被编译器要求同步修改。IDE 自动改参数修饰符时偶尔会漏掉方法内部的提前读取逻辑导致逻辑变化这个问题在代码评审里要特别留意。6. 上位机与数据采集场景中的 ref/out 使用心得热词列表里堆了很多上位机、串口、Modbus 相关词比如 C# 上位机、C# nmodbus4、读取传感器温度。这块我实际写过不少说说ref和out在其中的真实应用方式。6.1 与 Modbus 设备通信中用 ref 保存寄存器地址游标Modbus RTU 通信一个常见需求是按顺序读取连续多个寄存器但每次函数调用只读一段下次要从上次结束的地址继续。接口天然适合用ref保存地址游标因为方法既要知道当前地址又要更新它。bool ReadNextRegisters(ushort count, out ushort[] values, ref ushort address) { values ReadHoldingRegisters(address, count); address count; // 下次从新地址继续 return true; } ushort currentAddress 0; bool ok ReadNextRegisters(10, out ushort[] data, ref currentAddress);调用方持有currentAddress这个状态方法内部负责往后推进。这比让方法返回“读到的数据和下一个地址”的元组更自然也省去解构的麻烦。代价是一旦方法内部抛出异常而address已经被推进游标状态可能和实际通信状态不一致重试逻辑要额外处理。6.2 读取传感器温度等数据时的 out 解析模式上位机采集温度传感器的数据通常是从串口或网口收到一帧二进制数据需要按协议解析出温度值、湿度值、状态位等。这种解析函数天然用out参数bool TryParseTemperatureFrame(byte[] buffer, int offset, out double temperature) { if (offset 4 buffer.Length) { temperature 0; return false; } short raw (short)((buffer[offset] 8) | buffer[offset 1]); temperature raw / 10.0; return true; }out的“返回前必须赋值”规则反而成了保护提醒每个分支都要给temperature一个确定值避免上层拿到未初始化数据。我在代码评审时看到有人用ref做这种解析其实没有读取原值需求用out更合适因为调用前不用写占位初始化少一行毫无意义的double temp 0。6.3 串口/上位机代码中 out 的误用与规范上位机项目里最常见的问题是为了省事一个方法塞了好几个out参数比如out bool connected, out double temp, out string error。这种写法的可读性很差调用方必须一口气声明一堆变量。更合适的是用结构体或记录把结果聚合起来public sealed record DeviceReading { public bool Connected { get; init; } public double Temperature { get; init; } public string? Error { get; init; } }out参数数量建议最多两三个超过这个量就该考虑封装。另外上位机经常有超时、断线重连、数据异常等逻辑out参数在异常分支赋值不当时容易引起编译报错这其实是好事能逼着开发者给每个分支定义好结果。不要把编译错误当成麻烦它是帮你守住“函数必须有输出”的约束。7. 日常编码与评审中判断“要不要用 ref”的三条标准最后回到一个实际问题什么时候该用ref和out什么时候不该用我总结成三条判断标准在写代码和做评审时都能用上。7.1 三个判断维度要改、要省、要带出第一个维度是“是否必须修改调用方的变量本身”。如果方法的目的就是要把新值写回传入变量而且这个变量属于调用方管理ref就有意义。比如交换两个变量、在集合或缓冲区原位置更新值。第二个维度是“是否必须避免拷贝大结构体”。当一个结构体字段多、方法被高频调用时in或ref能省去每次传参的复制成本。注意只有当你确认 Profilor 显示结构体拷贝确实是热点时才值得做普通小结构体如int、double、DateTime加ref带来的收益可以忽略反而降低代码可读性。第三个维度是“方法是否需要带出附加结果”。典型就是TryParse模式返回值表示成功失败out参数带回真正的结果。一句话总结out是输出通道ref是读写通道in是只读通道。搞清楚你需要在参数上做什么修饰符就清楚了。7.2 常见 ref 滥用给引用类型的变量再加 ref有类代码特别常见就是给引用类型参数加refvoid ProcessData(ref Listint data) { data.Add(1); }如果方法内部只是调用data.Add、data.Remove这类成员方法完全不需要ref因为即使没有ref形参和实参的引用也指向同一个对象改的是对象内容外部一样能看到。只有方法内部要给data重新赋一个全新的Listint时才需要ref让外部变量跟着更新。我看到有人为了“提高性能”给所有对象类型加ref这是误解。引用类型变量本身只有 8 字节64 位按值传一次的拷贝成本可以忽略传引用反而不一定更快还容易让方法调用方意外丢失对原始变量的控制权。评审时遇到这类代码我一般会建议去掉ref除非有明显的重新赋值意图。7.3 一个可复制的心态ref/out/in 不是语法炫技而是“数据传递方式”的说明书写代码时把ref、out、in当成接口契约的一部分。新手看签名时重点看每个参数前面的修饰符它的作用比参数名更关键参数修饰符语义调用前要求方法内能否修改实参典型场景无按值传递方法拿到副本变量已赋值只改副本不改实参变量大部分常规方法ref按引用传递可读可写变量已赋值能修改调用方持有的变量、避免大结构体拷贝out按引用传递只用于输出不要求赋值必须赋值TryParse 模式、多结果带回in按引用传递只读变量已赋值不能改只读传递大结构体、避免拷贝最后分享一个我在实际项目里养成的习惯写完参数列表后把方法签名大声读一遍读的过程中如果发现“这个 ref 到底要改什么”说不清楚十有八九是这个ref不应该存在。把修改意图用参数修饰符表达清楚比注释里写“注意会改变原值”可靠得多因为编译器会帮你盯着。希望这篇把 C# 的ref和out讲得比文档更贴近实战下次你看到bool ok int.TryParse(input, out result)或co.Save(domhead, dombody, 0, ref curid)时第一反应不再是“这是个传引用”而是立刻能判断出这里的数据流方向、方法职责和可能的坑。