一条看似普通的赋值语句,可能复制一个完整的值,也可能只复制一条通往对象的引用。方法参数、字符串、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", "待分拣"
输出:
已发货
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
输出:
original=6000, copy=9000
Falserecord struct 仍是值类型:赋值、传参时按值复制;同时它自动提供基于成员的值相等语义,with 表达式也适合创建“改了少数字段的新值”。普通 struct 同样是值类型,但不会自动得到 record 的全部数据模型便利。
选择类型时可以先问:
可变结构体尤其危险:你以为在修改“那个值”,实际可能只修改了某个副本。因此实践中优先让结构体保持小且不可变。
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}");
输出:
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}");
}
输出:
站点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
输出:
count=3
PK-007: 已发货AddOne 修改整数副本。MarkShipped 拿到引用副本,这份副本仍能到达原订单,因此对象状态改变。ReplaceLocally 只让参数变量改指向,调用方的 order 没有被替换。

需要让方法直接操作调用方变量时,使用显式参数修饰符。
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)
输出:
5, 1
ok=True, temperature=18.5
6000in 的“只读”约束的是参数变量以及值类型成员的直接修改。若 in 参数是类引用,它不会把对象变成不可变对象;对象的可写属性仍可能被修改。另一个实践点是:小型值类型按值传递通常已经足够快,不必为了“看起来高性能”到处加 in。
普通值类型总要表示一个值。若业务需要区分“温度为 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: >
输出:
包裹数: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(); 或接入新变量。HasValue 或 ?? 表达缺失分支。! 没有运行时保护,应在输入边界真正检查。object 中装的是 int,就先拆成 int。调试复制问题时,可以按以下顺序记录证据:
Console.WriteLine(value.GetType().FullName); // 运行时类型
Console.WriteLine(ReferenceEquals(first, second)); // 是否同一对象
Console.WriteLine(first.Status); // 对象当前状态GetType() 不能对 null 调用,所以先判空。对于值类型,即使 ReferenceEquals 接收它们,也会触发装箱,不适合用它判断值语义。
写业务代码时,可用这份清单快速审查:
ref/out;0、空字符串等合法值分开;TryParse,对未知对象用模式匹配;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一种实现:
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;
C# 的类型系统不是一张需要死记的分类表,而是一组关于“值如何被保存、复制和解释”的规则。值类型赋值复制值;引用类型赋值复制引用;默认传参始终按值,只是被复制的内容随类型而不同。
string 通过不可变性换来稳定共享,StringBuilder 服务于大量动态构建;ref/out/in 把变量读写边界写进方法签名;T?、可空引用与 null 运算让“缺失”成为显式分支;装箱把值复制进对象,拆箱要求精确类型;模式匹配和 TryParse 则把不确定输入变成清晰、安全的控制流。
真正可靠的判断方法始终是:先看静态类型和运行时类型,再定位这次操作发生在哪一层。