C# 类型与引用:从复制语义到安全转换
一条看似普通的赋值语句,可能复制一个完整的值,也可能只复制一条通往对象的引用。方法参数、字符串、null、装箱和类型转换之所以容易混淆,本质上都在追问同一个问题:当前变量里保存的是什么,这一步又复制或改变了什么?
本章以“配送订单”作为统一案例。我们会让代码真实输出结果,再用复制边界解释现象。学完后,你应当能在不背“栈与堆”口诀的情况下,准确预判赋值、传参和转换的行为。

学习目标与阅读路线
完成本章后,你将能够:
- 区分变量、值、引用和对象,不把变量名误当成对象本身;
- 预测值类型与引用类型在赋值、比较和默认传参时的行为;
- 为小型数据选择
struct、record struct或class; - 解释
string不可变,并在插值、拼接和StringBuilder之间做选择; - 正确使用默认参数、
ref、out、in; - 区分可空值类型与可空引用类型,安全处理
null; - 识别装箱、拆箱的正确类型要求与成本;
- 用隐式/显式转换、
TryParse、is模式和switch模式处理不同来源的数据。
建议按“变量模型 → 复制语义 → 参数边界 → 缺失值 → 运行时类型”的顺序阅读。每节末尾都有小测,先写出预测,再展开答案。
变量、值、引用与对象
变量可以理解为一个有名字、类型明确的存储位置。声明变量是在建立这个位置;初始化是在第一次放入合法内容;读取变量则会取得当前内容的副本。
int packageCount = 3;
int copiedCount = packageCount;
packageCount = 8;
Console.WriteLine($"packageCount={packageCount}, copiedCount={copiedCount}");输出:
packageCount=8, copiedCount=3copiedCount = packageCount 不是建立“永久相等关系”,而是在这一刻读取 packageCount 的内容并赋给另一个变量。之后两个变量独立变化。
对象则是运行时存在的一份数据。引用类型变量不直接包含整个对象,而是包含一个能定位对象的引用。多个变量可以各自保存引用副本,并通过这些引用到达同一个对象。
public sealed class DeliveryOrder
{
public DeliveryOrder(string id, string status)
=> (Id, Status) = (id, status);
public string Id { get; }
public string Status { get; set; }
}
DeliveryOrder first = new("PK-007", "待分拣");
DeliveryOrder second = first;
second.Status = "已发货";
Console.WriteLine(first.Status);
Console.WriteLine(ReferenceEquals(first, second));输出:
已发货
True这里有两个变量、两份引用值,但只有一个 DeliveryOrder 对象。修改 second.Status 是沿着引用找到对象,再修改对象内部状态。
不要用“值类型一定在栈上、引用类型一定在堆上”作为判断规则。字段、数组、闭包和运行时优化都会影响实际布局;复制语义比物理位置更稳定、更有用。
值类型、引用类型与复制边界
常见值类型包括整数、浮点数、bool、char、decimal、枚举、元组以及所有 struct。常见引用类型包括 class、数组、委托、接口、object 和 string。
统一案例中,用值类型表达“没有独立身份的一组小数据”很自然:
public readonly record struct PackageSize(decimal Length, decimal Width, decimal Height)
{
public decimal Volume => Length * Width * Height;
}
PackageSize original = new(30m, 20m, 10m);
PackageSize copy = original with { Height = 15m };
Console.WriteLine($"original={original.Volume}, copy={copy.Volume}");
Console.WriteLine(original == copy);输出:
original=6000, copy=9000
Falserecord struct 仍是值类型:赋值、传参时按值复制;同时它自动提供基于成员的值相等语义,with 表达式也适合创建“改了少数字段的新值”。普通 struct 同样是值类型,但不会自动得到 record 的全部数据模型便利。
选择类型时可以先问:
可变结构体尤其危险:你以为在修改“那个值”,实际可能只修改了某个副本。因此实践中优先让结构体保持小且不可变。
string 不可变与文本构建
string 是引用类型,但字符串对象的内容不可变。ToUpperInvariant、Replace、Trim 和 + 不会在原对象上改字,而是返回一个结果字符串。
string label = "pk-007";
string alias = label;
string normalized = label.ToUpperInvariant();
Console.WriteLine($"label={label}");
Console.WriteLine($"alias={alias}");
Console.WriteLine($"normalized={normalized}");
Console.WriteLine(ReferenceEquals(label, normalized));输出:
label=pk-007
alias=pk-007
normalized=PK-007
False
不可变带来了稳定共享:多个变量引用同一字符串时,不必担心某处“原地改字”影响其他调用者。代价是循环中不断拼接可能产生许多中间字符串。
using System.Text;
var builder = new StringBuilder();
for (int stop = 1; stop <= 3; stop++)
{
if (builder.Length > 0) builder.Append(" -> ");
builder.Append($"站点{stop}");
}
string route = builder.ToString();
Console.WriteLine(route);输出:
站点1 -> 站点2 -> 站点3经验规则:少量固定片段优先字符串插值;循环或动态累计许多片段时考虑 StringBuilder。Span<T> 是连续内存上的短生命周期视图,可减少某些切片与解析分配,但受生命周期限制,不应存入普通对象字段;它属于性能优化工具,本章只建立边界,不替代 string。
默认参数传递:复制传入的值
C# 方法参数默认按值传递。对值类型,复制的是数据;对引用类型,复制的是引用值。
static void AddOne(int count) => count++;
static void MarkShipped(DeliveryOrder order)
=> order.Status = "已发货";
static void ReplaceLocally(DeliveryOrder order)
=> order = new DeliveryOrder("PK-999", "新建");
int count = 3;
DeliveryOrder order = new("PK-007", "待分拣");
AddOne(count);
MarkShipped(order);
ReplaceLocally(order);
Console.WriteLine($"count={count}");
Console.WriteLine($"{order.Id}: {order.Status}");输出:
count=3
PK-007: 已发货AddOne 修改整数副本。MarkShipped 拿到引用副本,这份副本仍能到达原订单,因此对象状态改变。ReplaceLocally 只让参数变量改指向,调用方的 order 没有被替换。

ref、out、in:把变量边界写进签名
需要让方法直接操作调用方变量时,使用显式参数修饰符。
static void Swap(ref int left, ref int right)
=> (left, right) = (right, left);
static bool TryReadTemperature(string text, out decimal temperature)
=> decimal.TryParse(text, out temperature);
static decimal CalculateVolume(in PackageSize size)
=> size.Volume;
int firstStop = 1;
int lastStop = 5;
Swap(ref firstStop, ref lastStop);
bool ok = TryReadTemperature("18.5", out decimal temperature);
PackageSize size = new(30, 20, 10);
Console.WriteLine($"{firstStop}, {lastStop}");
Console.WriteLine($"ok={ok}, temperature={temperature}");
Console.WriteLine(CalculateVolume(in size));输出:
5, 1
ok=True, temperature=18.5
6000in 的“只读”约束的是参数变量以及值类型成员的直接修改。若 in 参数是类引用,它不会把对象变成不可变对象;对象的可写属性仍可能被修改。另一个实践点是:小型值类型按值传递通常已经足够快,不必为了“看起来高性能”到处加 in。
可空值类型、可空引用与 null 运算
普通值类型总要表示一个值。若业务需要区分“温度为 0”与“没有采集到温度”,使用 decimal?:
decimal? measuredTemperature = null;
Console.WriteLine(measuredTemperature.HasValue);
Console.WriteLine(measuredTemperature ?? -999m);
measuredTemperature = 0m;
Console.WriteLine(measuredTemperature ?? -999m);输出:
False
-999
0decimal? 是 Nullable<decimal> 的简写,内部表达“有无值”以及值本身。推荐用模式或 ?? 读取,避免未检查就访问 .Value。
string Describe(decimal? value) => value switch
{
null => "未采集",
< 0m => "低于零度",
var temperature => $"{temperature:0.0} °C"
};可空引用类型是另一件事:string? 主要告诉编译器“这个引用可能为 null”,让流分析在危险解引用处报警;它不是 Nullable<string> 包装。
string? receiverName = null;
receiverName ??= "未指定收件人";
int displayLength = receiverName?.Length ?? 0;
Console.WriteLine($"{receiverName} / {displayLength}");输出:
未指定收件人 / 7?. 在左侧为 null 时停止成员访问并产生 null;?? 提供备用值;??= 只在左侧为 null 时赋值。不要把 ! 当作运行时检查:null 宽恕运算符只压制编译器警告,若判断错了,运行时仍可能抛异常。

装箱、拆箱与泛型
当值类型需要以 object 或某个接口类型出现时,会发生装箱:运行时创建一个可由引用访问的对象,并把值复制进去。
int packageCount = 42;
object boxed = packageCount; // 装箱
packageCount = 100;
int restored = (int)boxed; // 拆箱
Console.WriteLine($"packageCount={packageCount}");
Console.WriteLine($"restored={restored}");输出:
packageCount=100
restored=42盒子里保存的是装箱当时的值副本,不会随着原变量变化。拆箱有一个容易忽略的规则:必须先匹配装进去的精确值类型。
object boxedInt = 42;
// long wrong = (long)boxedInt; // InvalidCastException
long correct = (long)(int)boxedInt;
Console.WriteLine(correct);第一次转换先把 object 精确拆为 int,第二次才进行 int 到 long 的数值转换。
频繁装箱会产生额外对象和复制成本。需要保存同类值时,优先使用泛型:
var counts = new List<int> { 1, 2, 3 }; // 元素保持 int性能之外,泛型还把错误提前到编译期,避免从 object 中取值时才发现类型不匹配。
类型测试、模式匹配与转换策略
类型转换不只有一种写法。选择取决于数据来源、失败是否正常、是否可能丢失信息。
static string InspectPayload(object? payload) => payload switch
{
null => "没有数据",
int count when count >= 0 => $"包裹数:{count}",
decimal temperature => $"温度:{temperature:0.0}",
string { Length: > 0 } text => $"文本:{text.Trim()}",
_ => $"暂不支持:{payload.GetType().Name}"
};
Console.WriteLine(InspectPayload(3));
Console.WriteLine(InspectPayload("PK-007"));
Console.WriteLine(InspectPayload(null));输出:
包裹数:3
文本:PK-007
没有数据声明模式把测试与强类型变量绑定在一起:
if (payload is DeliveryOrder { Status: "已发货" } shipped)
{
Console.WriteLine($"{shipped.Id} 已离开仓库");
}常见策略对照:
数值窄化可能丢失数据:
int count = 300;
try
{
byte compact = checked((byte)count);
}
catch (OverflowException)
{
Console.WriteLine("300 超出 byte 范围");
}外部输入优先 TryParse:
string input = "18.5";
if (decimal.TryParse(input, out decimal temperature))
{
Console.WriteLine($"读取成功:{temperature}");
}不要先 as 再立刻 !,那会把温和失败重新变成潜在空引用。若后续必须使用强类型值,is 模式通常更紧凑、更安全。
常见陷阱与调试方法
- 把引用副本说成对象副本。 看有没有执行构造、克隆或映射代码;普通引用赋值不会复制对象。
- 以为给参数重新赋值能替换调用方变量。 默认参数是副本;确实要替换时,返回新值通常比
ref更直观。 - 遗漏字符串方法的返回值。
name.Trim();不会改变name,应写成name = name.Trim();或接入新变量。 - 无检查访问 nullable.Value。 先用模式、
HasValue或??表达缺失分支。 - 把 null 宽恕运算符当校验。
!没有运行时保护,应在输入边界真正检查。 - 把数值转换和拆箱混为一步。
object中装的是int,就先拆成int。 - 为了性能过早使用 struct、in 或 Span。 先写清楚并测量,再优化热点。
调试复制问题时,可以按以下顺序记录证据:
Console.WriteLine(value.GetType().FullName); // 运行时类型
Console.WriteLine(ReferenceEquals(first, second)); // 是否同一对象
Console.WriteLine(first.Status); // 对象当前状态GetType() 不能对 null 调用,所以先判空。对于值类型,即使 ReferenceEquals 接收它们,也会触发装箱,不适合用它判断值语义。
实践清单与综合练习
写业务代码时,可用这份清单快速审查:
- 先确认类型表达的是“一个值”还是“有身份的对象”;
- 看到赋值或传参,明确复制的是数据还是引用;
- 让结构体小而不可变,不凭感觉用它优化性能;
- 接住所有字符串变换的返回值;
- 只有在调用方变量确需被方法直接改写时才用
ref/out; - 把“缺失”与
0、空字符串等合法值分开; - 对外部文本用
TryParse,对未知对象用模式匹配; - 注意隐藏装箱,尤其是非泛型 API、接口调用与格式化热点;
- 对可能溢出的窄化转换使用范围检查或
checked; - 优化
StringBuilder、in、Span<T>前先建立基准。
综合练习:实现下面的方法,要求不抛出常规输入异常。
static bool TryCreateSnapshot(
object? orderId,
string? temperatureText,
out DeliverySnapshot snapshot)
{
// 1. orderId 必须是非空白 string
// 2. temperatureText 允许为 null;非 null 时必须能解析为 decimal
// 3. 成功时构造不可变快照并返回 true
// 4. 失败时 snapshot 设为 default 并返回 false
throw new NotImplementedException();
}
public readonly record struct DeliverySnapshot(
string OrderId,
decimal? Temperature);一种实现:
static bool TryCreateSnapshot(
object? orderId,
string? temperatureText,
out DeliverySnapshot snapshot)
{
snapshot = default;
if (orderId is not string { Length: > 0 } id ||
string.IsNullOrWhiteSpace(id))
{
return false;
}
decimal? temperature = null;
if (temperatureText is not null)
{
if (!decimal.TryParse(temperatureText, out decimal parsed))
return false;
temperature = parsed;
}
snapshot = new DeliverySnapshot(id.Trim(), temperature);
return true;
}总结
C# 的类型系统不是一张需要死记的分类表,而是一组关于“值如何被保存、复制和解释”的规则。值类型赋值复制值;引用类型赋值复制引用;默认传参始终按值,只是被复制的内容随类型而不同。
string 通过不可变性换来稳定共享,StringBuilder 服务于大量动态构建;ref/out/in 把变量读写边界写进方法签名;T?、可空引用与 null 运算让“缺失”成为显式分支;装箱把值复制进对象,拆箱要求精确类型;模式匹配和 TryParse 则把不确定输入变成清晰、安全的控制流。
真正可靠的判断方法始终是:先看静态类型和运行时类型,再定位这次操作发生在哪一层。