自在学

我们与你共同进步

  • 分类课程
  • 文章
  • 工作台
  • 订阅

  • 关于我们
  • 隐私政策
  • 使用条款

探索

  • 分类课程
  • 文章
  • 工作台
  • 订阅

网站信息

  • 关于我们
  • 隐私政策
  • 使用条款

加入社区

自在学学习社区微信二维码

微信扫码,交流学习

株洲市自在学教育科技有限公司© 2025 - 2026 版权所有

© 2025 - 2026 株洲市自在学教育科技有限公司 版权所有

湘公网安备43020302000292号|湘ICP备2025148919号-1
分类课程工作台文章订阅
分类课程工作台文章价格

C#

  1. 01C# 入门:从第一行代码到可运行的小程序
  2. 02函数与逻辑:从方法契约到 Lambda
  3. 03面向对象:从有效对象到可靠边界
  4. 04C# 类型与引用:从复制语义到安全转换
  5. 05C# 继承与运行时多态
  6. 06C# 接口与抽象:从契约到可测试设计
  7. 07C# 异步与 JSON:从 Task 到可靠数据边界
  8. 08C# 错误处理:从异常传播到可靠边界
  9. 09C# LINQ:从集合流水线到可靠的数据边界
  10. 10C# 委托与事件:从类型安全回调到生命周期管理
  11. 11C# MVVM:从可绑定状态到可测试交互
正在加载课程章节内容
课程编程C#C# 类型与引用:从复制语义到安全转换

C# 类型与引用:从复制语义到安全转换

一条看似普通的赋值语句,可能复制一个完整的值,也可能只复制一条通往对象的引用。方法参数、字符串、null、装箱和类型转换之所以容易混淆,本质上都在追问同一个问题:当前变量里保存的是什么,这一步又复制或改变了什么?

本章以“配送订单”作为统一案例。我们会让代码真实输出结果,再用复制边界解释现象。学完后,你应当能在不背“栈与堆”口诀的情况下,准确预判赋值、传参和转换的行为。

值类型复制与引用类型复制的整体对照:值副本彼此独立,引用副本可指向同一对象。

学习目标与阅读路线

完成本章后,你将能够:

  • 区分变量、值、引用和对象,不把变量名误当成对象本身;
  • 预测值类型与引用类型在赋值、比较和默认传参时的行为;
  • 为小型数据选择 struct、record struct 或 class;
  • 解释 string 不可变,并在插值、拼接和 StringBuilder 之间做选择;
  • 正确使用默认参数、ref、out、in;
  • 区分可空值类型与可空引用类型,安全处理 null;
  • 识别装箱、拆箱的正确类型要求与成本;
  • 用隐式/显式转换、TryParse、is 模式和 switch 模式处理不同来源的数据。

建议按“变量模型 → 复制语义 → 参数边界 → 缺失值 → 运行时类型”的顺序阅读。每节末尾都有小测,先写出预测,再展开答案。

1
使用 var 声明的局部变量没有固定的静态类型,之后可以存入任意类型的值。
2
分析 C# 中的赋值、传参与转换时,哪些问题最有助于判断实际行为?

变量、值、引用与对象

变量可以理解为一个有名字、类型明确的存储位置。声明变量是在建立这个位置;初始化是在第一次放入合法内容;读取变量则会取得当前内容的副本。

csharp
int packageCount = 3;
int copiedCount = packageCount;
packageCount = 8;
 
Console.WriteLine($"packageCount={packageCount}, copiedCount={copiedCount}");

输出:

console
packageCount=8, copiedCount=3

copiedCount = packageCount 不是建立“永久相等关系”,而是在这一刻读取 packageCount 的内容并赋给另一个变量。之后两个变量独立变化。

对象则是运行时存在的一份数据。引用类型变量不直接包含整个对象,而是包含一个能定位对象的引用。多个变量可以各自保存引用副本,并通过这些引用到达同一个对象。

csharp
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));

输出:

console
已发货
True

这里有两个变量、两份引用值,但只有一个 DeliveryOrder 对象。修改 second.Status 是沿着引用找到对象,再修改对象内部状态。

不要用“值类型一定在栈上、引用类型一定在堆上”作为判断规则。字段、数组、闭包和运行时优化都会影响实际布局;复制语义比物理位置更稳定、更有用。

3
已有 first 指向一个订单对象,执行 second = first 后,内存中共有几个由这两行代码创建的订单对象?
4
把 second 重新赋值为 new DeliveryOrder(...) 会同时改变 first 保存的引用。
5
读取 int 变量时,得到的是其当前值的 ____,原变量不会因此被清空。

值类型、引用类型与复制边界

常见值类型包括整数、浮点数、bool、char、decimal、枚举、元组以及所有 struct。常见引用类型包括 class、数组、委托、接口、object 和 string。

统一案例中,用值类型表达“没有独立身份的一组小数据”很自然:

csharp
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);

输出:

console
original=6000, copy=9000
False

record struct 仍是值类型:赋值、传参时按值复制;同时它自动提供基于成员的值相等语义,with 表达式也适合创建“改了少数字段的新值”。普通 struct 同样是值类型,但不会自动得到 record 的全部数据模型便利。

选择类型时可以先问:

场景更合适的起点理由
小、不可变、整体代表一个值readonly struct / readonly record struct复制后自然独立,值相等直观
有业务身份、生命周期和共享修改class / record class多个组件可引用同一实例
很大且频繁传递的数据谨慎选择大型结构体按值复制可能昂贵
需要继承层次class结构体不支持类继承

可变结构体尤其危险:你以为在修改“那个值”,实际可能只修改了某个副本。因此实践中优先让结构体保持小且不可变。

6
record struct 在 C# 类型系统中属于哪一类?
7
两个内容完全相同、但分别通过 new 创建的普通类实例,ReferenceEquals 会返回 true。
8
大型可变 struct 不适合表示订单实体的原因有哪些?

string 不可变与文本构建

string 是引用类型,但字符串对象的内容不可变。ToUpperInvariant、Replace、Trim 和 + 不会在原对象上改字,而是返回一个结果字符串。

csharp
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));

输出:

console
label=pk-007
alias=pk-007
normalized=PK-007
False

string 转换产生新字符串,而 StringBuilder 在可变缓冲区中追加内容。

不可变带来了稳定共享:多个变量引用同一字符串时,不必担心某处“原地改字”影响其他调用者。代价是循环中不断拼接可能产生许多中间字符串。

csharp
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);

输出:

console
站点1 -> 站点2 -> 站点3

经验规则:少量固定片段优先字符串插值;循环或动态累计许多片段时考虑 StringBuilder。Span<T> 是连续内存上的短生命周期视图,可减少某些切片与解析分配,但受生命周期限制,不应存入普通对象字段;它属于性能优化工具,本章只建立边界,不替代 string。

9
调用 label.ToUpperInvariant() 会原地修改 label 当前指向的字符串对象。
10
哪种场景最适合优先考虑 StringBuilder?
11
Span<char> 可以像 string 一样不受限制地保存在普通类的实例字段中。

默认参数传递:复制传入的值

C# 方法参数默认按值传递。对值类型,复制的是数据;对引用类型,复制的是引用值。

csharp
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}");

输出:

console
count=3
PK-007: 已发货

AddOne 修改整数副本。MarkShipped 拿到引用副本,这份副本仍能到达原订单,因此对象状态改变。ReplaceLocally 只让参数变量改指向,调用方的 order 没有被替换。

按值、ref、out、in 四种参数方式的复制与读写边界。

12
引用类型参数在没有 ref 等修饰符时,默认就是按引用传递。
13
默认传入一个引用类型变量后,方法内通常可以完成哪些操作?

ref、out、in:把变量边界写进签名

需要让方法直接操作调用方变量时,使用显式参数修饰符。

修饰符调用前方法内部返回前典型用途
ref必须已赋值可读、可写、可重新绑定不要求重新赋值原地更新或交换
out不要求已赋值先写后读必须赋值Try 模式写出结果
in必须有值只读引用,不能给参数重新赋值无额外要求只读传入较大值类型
csharp
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));

输出:

console
5, 1
ok=True, temperature=18.5
6000

in 的“只读”约束的是参数变量以及值类型成员的直接修改。若 in 参数是类引用,它不会把对象变成不可变对象;对象的可写属性仍可能被修改。另一个实践点是:小型值类型按值传递通常已经足够快,不必为了“看起来高性能”到处加 in。

14
out 参数在方法正常返回前必须被明确 ____。
15
为什么 ref 参数在传入方法前必须已经初始化?
16
把 DeliveryOrder 以 in 参数传入,会使该订单对象的所有可写属性自动变成只读。

可空值类型、可空引用与 null 运算

普通值类型总要表示一个值。若业务需要区分“温度为 0”与“没有采集到温度”,使用 decimal?:

csharp
decimal? measuredTemperature = null;
 
Console.WriteLine(measuredTemperature.HasValue);
Console.WriteLine(measuredTemperature ?? -999m);
 
measuredTemperature = 0m;
Console.WriteLine(measuredTemperature ?? -999m);

输出:

console
False
-999
0

decimal? 是 Nullable<decimal> 的简写,内部表达“有无值”以及值本身。推荐用模式或 ?? 读取,避免未检查就访问 .Value。

csharp
string Describe(decimal? value) => value switch
{
    null => "未采集",
    < 0m => "低于零度",
    var temperature => $"{temperature:0.0} °C"
};

可空引用类型是另一件事:string? 主要告诉编译器“这个引用可能为 null”,让流分析在危险解引用处报警;它不是 Nullable<string> 包装。

csharp
string? receiverName = null;
receiverName ??= "未指定收件人";
 
int displayLength = receiverName?.Length ?? 0;
Console.WriteLine($"{receiverName} / {displayLength}");

输出:

console
未指定收件人 / 7

?. 在左侧为 null 时停止成员访问并产生 null;?? 提供备用值;??= 只在左侧为 null 时赋值。不要把 ! 当作运行时检查:null 宽恕运算符只压制编译器警告,若判断错了,运行时仍可能抛异常。

可空值的有值与无值状态,以及装箱、拆箱和类型模式的安全路径。

17
当 int? 变量 value 的值为 0 时,表达式 value ?? -1 的结果是什么?
18
string? 是 Nullable<string> 的简写。
19
表达式 order!.Status 中的 ! 会执行什么操作?

装箱、拆箱与泛型

当值类型需要以 object 或某个接口类型出现时,会发生装箱:运行时创建一个可由引用访问的对象,并把值复制进去。

csharp
int packageCount = 42;
object boxed = packageCount; // 装箱
 
packageCount = 100;
int restored = (int)boxed;   // 拆箱
 
Console.WriteLine($"packageCount={packageCount}");
Console.WriteLine($"restored={restored}");

输出:

console
packageCount=100
restored=42

盒子里保存的是装箱当时的值副本,不会随着原变量变化。拆箱有一个容易忽略的规则:必须先匹配装进去的精确值类型。

csharp
object boxedInt = 42;
 
// long wrong = (long)boxedInt; // InvalidCastException
long correct = (long)(int)boxedInt;
Console.WriteLine(correct);

第一次转换先把 object 精确拆为 int,第二次才进行 int 到 long 的数值转换。

频繁装箱会产生额外对象和复制成本。需要保存同类值时,优先使用泛型:

csharp
var counts = new List<int> { 1, 2, 3 }; // 元素保持 int

性能之外,泛型还把错误提前到编译期,避免从 object 中取值时才发现类型不匹配。

20
执行 object boxed = 42 后,可以直接通过 (long)boxed 把盒内值拆成 long。
21
int 值装箱后,盒内的值会随原 int 变量的后续赋值同步更新。
22
与存放整数的 ArrayList 相比,List<int> 通常具备哪些优势?

类型测试、模式匹配与转换策略

类型转换不只有一种写法。选择取决于数据来源、失败是否正常、是否可能丢失信息。

csharp
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));

输出:

console
包裹数:3
文本:PK-007
没有数据

声明模式把测试与强类型变量绑定在一起:

csharp
if (payload is DeliveryOrder { Status: "已发货" } shipped)
{
    Console.WriteLine($"{shipped.Id} 已离开仓库");
}

常见策略对照:

写法适合场景失败行为
隐式转换编译器确认安全、通常无信息丢失编译期决定
显式强转 (T)value契约保证类型,失败是程序错误可能抛异常
is T value / switch 模式运行时类型不确定,需要分支处理不匹配走其他分支
value as T引用/可空转换失败是正常情况返回 null
TryParse外部文本转数字、日期等返回 false,不以异常表达常规失败
Convert已知可转换的基础类型之间转换规则与异常依目标类型而定

数值窄化可能丢失数据:

csharp
int count = 300;
 
try
{
    byte compact = checked((byte)count);
}
catch (OverflowException)
{
    Console.WriteLine("300 超出 byte 范围");
}

外部输入优先 TryParse:

csharp
string input = "18.5";
if (decimal.TryParse(input, out decimal temperature))
{
    Console.WriteLine($"读取成功:{temperature}");
}

不要先 as 再立刻 !,那会把温和失败重新变成潜在空引用。若后续必须使用强类型值,is 模式通常更紧凑、更安全。

23
把可能格式不正确的外部文本 42 转换成 int 时,通常首选哪种方式?
24
对允许使用 as 的类型执行转换时,如果运行时类型不兼容,会得到什么结果?
25
checked((byte)300) 会检查窄化转换的溢出,并因 300 超出 byte 范围而抛出 OverflowException。
26
模式匹配处理 object? 载荷时,可以在同一结构中判断哪些信息?

常见陷阱与调试方法

  1. 把引用副本说成对象副本。 看有没有执行构造、克隆或映射代码;普通引用赋值不会复制对象。
  2. 以为给参数重新赋值能替换调用方变量。 默认参数是副本;确实要替换时,返回新值通常比 ref 更直观。
  3. 遗漏字符串方法的返回值。 name.Trim(); 不会改变 name,应写成 name = name.Trim(); 或接入新变量。
  4. 无检查访问 nullable.Value。 先用模式、HasValue 或 ?? 表达缺失分支。
  5. 把 null 宽恕运算符当校验。 ! 没有运行时保护,应在输入边界真正检查。
  6. 把数值转换和拆箱混为一步。 object 中装的是 int,就先拆成 int。
  7. 为了性能过早使用 struct、in 或 Span。 先写清楚并测量,再优化热点。

调试复制问题时,可以按以下顺序记录证据:

csharp
Console.WriteLine(value.GetType().FullName);          // 运行时类型
Console.WriteLine(ReferenceEquals(first, second));   // 是否同一对象
Console.WriteLine(first.Status);                     // 对象当前状态

GetType() 不能对 null 调用,所以先判空。对于值类型,即使 ReferenceEquals 接收它们,也会触发装箱,不适合用它判断值语义。

27
只调用 name.Trim(); 却不接收返回值后,name 仍可能包含首尾空格,根本原因是什么?
28
方法需要产生替换后的对象时,哪种设计通常比使用 ref 更清楚?
29
ReferenceEquals(1, 1) 适合用来判断两个整数的数值是否相等。

实践清单与综合练习

写业务代码时,可用这份清单快速审查:

  • 先确认类型表达的是“一个值”还是“有身份的对象”;
  • 看到赋值或传参,明确复制的是数据还是引用;
  • 让结构体小而不可变,不凭感觉用它优化性能;
  • 接住所有字符串变换的返回值;
  • 只有在调用方变量确需被方法直接改写时才用 ref/out;
  • 把“缺失”与 0、空字符串等合法值分开;
  • 对外部文本用 TryParse,对未知对象用模式匹配;
  • 注意隐藏装箱,尤其是非泛型 API、接口调用与格式化热点;
  • 对可能溢出的窄化转换使用范围检查或 checked;
  • 优化 StringBuilder、in、Span<T> 前先建立基准。

综合练习:实现下面的方法,要求不抛出常规输入异常。

csharp
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);

一种实现:

csharp
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;
}
30
为什么 DeliverySnapshot 的 temperature 更适合使用 decimal?,而不是用 -999 表示缺失?
31
在方法开头把 out snapshot 设为 default,有助于确保后续任意失败返回路径都满足 out 参数必须赋值的契约。
32
TryCreateSnapshot 的实现综合运用了哪些本章能力?

总结

C# 的类型系统不是一张需要死记的分类表,而是一组关于“值如何被保存、复制和解释”的规则。值类型赋值复制值;引用类型赋值复制引用;默认传参始终按值,只是被复制的内容随类型而不同。

string 通过不可变性换来稳定共享,StringBuilder 服务于大量动态构建;ref/out/in 把变量读写边界写进方法签名;T?、可空引用与 null 运算让“缺失”成为显式分支;装箱把值复制进对象,拆箱要求精确类型;模式匹配和 TryParse 则把不确定输入变成清晰、安全的控制流。

真正可靠的判断方法始终是:先看静态类型和运行时类型,再定位这次操作发生在哪一层。

33
默认引用类型参数与 ref 引用类型参数的关键区别是什么?
34
处理未知 object? 输入时,推荐的步骤有哪些?
上一章面向对象:从有效对象到可靠边界下一章C# 继承与运行时多态