[深入解析C#] 第 15 章:C# 8 及其后续
15.1 可空引用类型
15.1.1 解决的问题
- 核心概念:C# 8 引入可空引用类型,旨在通过编译器静态分析明确区分“可能为 null”和“不应为 null”的引用类型变量,在编译时捕获
NullReferenceException,减少 Tony Hoare 所称的“十亿美金错误”。 - 关键点:
- 问题背景:
- C# 7 及以前,所有引用类型变量都可以为 null,但无法在类型系统层面表达“此变量不应为 null”的意图。
- 值类型有
Nullable<T>表达可空/非可空,引用类型缺失对应机制。 - 代码中何时应该进行 null 检查,何时可以安全省略,缺乏统一标准,完全依赖文档和约定。
- 目标:
- 让引用类型的可空性成为类型系统的一部分,通过编译器警告/错误提醒开发者潜在的 null 风险。
- 可逐步启用,兼容庞大的现有 .NET 代码库。
- 状态:
- 本章编写时(C# 8 预览阶段)尚未最终确定,但已进入实验性构建。
- 预览版仅支持部分项目类型,后续构建会放宽限制。
- 后续内容:将深入探讨编译器如何实现、与现有代码的兼容策略以及具体使用语法。
- 问题背景:
- 面试准备建议:
- 重要性:⭐⭐⭐(C# 重要演进方向,面试高频)
- 面试回答要点:
- 可空引用类型是 C# 8 的关键特性,通过在类型后加
?(如string?)标记可空,不加的默认非空。 - 编译器会进行流分析,检测可能的 null 引用解引用,发出警告;可配置为错误。
- 兼容性设计:通过可空性上下文(
#nullable enable)选择加入,不影响未启用该功能的旧代码。 - 对比值类型的
Nullable<T>,理解其补齐了类型系统的最后一块空白。 - 在 Unity 面试中,若被问及如何减少空引用异常,可提及利用此特性在编译期强制 null 检查,结合良好的设计模式(如依赖注入、不可空构造函数参数)可大幅提升代码健壮性。
- 可空引用类型是 C# 8 的关键特性,通过在类型后加
15.1.2 改变引用类型的默认含义
-
核心概念:启用可空引用类型特性后,
string默认为非可空引用类型,而string?表示可空引用类型。这从根本上改变了引用类型的默认语义,假设null不应是常态。 -
关键点:
- 语法变化:
T:非可空引用类型(激活特性后的默认行为)。T?:可空引用类型,表示该变量可能为null。
- 设计前提:在良好设计的代码中,
null引用不应频繁出现。因此将默认行为改为非可空,促使开发者显式处理可空情况。 - 编译器警告:
- 未初始化的非可空属性会引发警告,强制要求通过构造函数或初始化器提供非空值。
- 尝试将可能为
null的值赋给非可空变量会触发警告。
- 代码安全性提升:
- 一旦保证所有非可空属性均在构造时赋值,
customer.Address.Country这样的链式访问就可被编译器证明为安全,无需担忧NullReferenceException。
- 一旦保证所有非可空属性均在构造时赋值,
- 兼容性说明:
- 该特性需显式激活(如通过
#nullable enable),不会在未选择加入的旧代码中默认生效。 - 激活后,所有未标记为
?的引用类型都被视为非可空,可能产生大量警告,需逐步修正代码。
- 该特性需显式激活(如通过
- 语法变化:
-
代码示例(启用可空引用类型前后对比):
// 旧式定义(无 null 安全性信息) public class Customer { public string Name { get; set; } public Address Address { get; set; } } // C# 8 可空引用类型:非可空属性必须在构造时初始化 #nullable enable public class Customer { public string Name { get; set; } // 非可空,必须赋值 public Address Address { get; set; } // 非可空,必须赋值 public Customer(string name, Address address) => (Name, Address) = (name, address); } public class Address { public string Country { get; set; } // 非可空 public Address(string country) => Country = country; } // 安全访问(编译器已验证非空) Customer customer = ...; // 假设已正确初始化 Console.WriteLine(customer.Address.Country); // 保证无 NullReferenceException -
面试准备建议:
- 重要性:⭐⭐⭐(C# 8 核心特性,面试高频考点)
- 面试回答要点:
- C# 8 引入可空引用类型,通过
?后缀明确标记可空性,未标记的引用类型默认非空。 - 编译器通过静态流分析检查可能的 null 引用,发出警告,将运行时
NullReferenceException提前到编译时。 - 可逐步迁移:通过
#nullable enable选择加入,不破坏旧代码。 - 注意:运行时 CLR 不区分
string与string?,所有检查均在编译时完成,不会影响性能。
- C# 8 引入可空引用类型,通过
15.1.3 可空引用类型的语法与编译器检查
-
核心概念:
T?表示可空引用类型,T(无问号)表示非可空引用类型。编译器根据这些注解进行静态 null 分析,对可能的 null 引用误用发出警告。 -
关键点:
- 语法:
string?:可空字符串,可能为 null。string:非可空字符串,不应为 null。- 与可空值类型
int?语法一致,语义统一。
- 编译器检查项:
- 将可能为 null 的值赋给非可空变量:警告。
- 将可能为 null 的值作为非可空参数传递:警告。
- 对可能为 null 的值进行解引用(访问成员):警告(CS8602)。
- 模型修改示例:若
Address可为 null,只需将属性类型改为Address?,并从构造函数移除该参数。编译器会自动在解引用customer.Address.Country时发出警告。 - 行为模式:特性通过静态流分析跟踪 null 状态,即使在复杂条件分支中也能识别变量何时已检查为非空,避免重复警告。
- 语法:
-
代码示例:
#nullable enable public class Customer { public string Name { get; set; } // 非可空 public Address? Address { get; set; } // 可空 public Customer(string name) => Name = name; } // 使用 Customer customer = new Customer("Jon"); Console.WriteLine(customer.Address.Country); // 警告 CS8602:可能解引用 null if (customer.Address != null) Console.WriteLine(customer.Address.Country); // 安全,无警告 -
面试准备建议:
- 重要性:⭐⭐⭐(C# 8 核心特性,面试高频)
- 面试回答要点:
- 可空引用类型通过
?标记,让 null 安全成为类型系统的一部分,编译器强制检查。 - 常见的编译器警告场景:赋值、传参、解引用可能为 null 的值。
- 可逐步启用(
#nullable enable),不影响已有代码;利用编译时检查替代部分运行时断言,提升代码健壮性。
- 可空引用类型通过
15.1.4 编译时检查与运行时行为的差异
-
核心概念:可空引用类型是纯粹的编译时特性,不改变 CLR 运行时行为。编译器通过静态数据流分析发出警告,但不会插入运行时 null 检查。开发者仍需防御性编程,并严肃对待每一个警告。
-
关键点:
- 黄金法则:
- 运行时行为完全不变,无新 CLR 类型,
string和string?在运行时是同一个类型。 - 所有可空性信息仅通过 attribute 存储在元数据中,供编译器使用。
- 因此,防御性编程(参数校验等)依然必要:旧代码或忽略警告的代码仍可能传入 null。
- 运行时行为完全不变,无新 CLR 类型,
- 编译器数据流分析:
- 编译器会跟踪变量在每一条执行路径上的 null 状态,而不仅仅是看类型。
- 典型安全化写法及编译器反应:
- 空条件运算符 + 空合并:
customer.Address?.Country ?? "(unknown)"→ 安全,无警告。 - 提取局部变量判空:
if (address != null) { ... }→ 进入if块后,address被视为非空,无警告。 - 直接重复属性访问判空:
if (customer.Address != null) { Console.WriteLine(customer.Address.Country); }→ 编译器假定同一属性两次读取返回相同值,判空后内部访问被视为安全,无警告。
- 空条件运算符 + 空合并:
- 已知局限性:
- 多线程:判空后属性可能被其他线程改为 null,导致运行时
NullReferenceException。 - 不纯的属性 getter:如果 getter 可能随机返回 null,编译器假设不成立,会导致运行时异常。
- 刻意绕过:开发者仍可通过强制转换等方式绕过警告。
- C# 团队认为这是务实的选择:在不彻底颠覆语言的前提下,大幅提升 null 安全性,而非追求绝对安全。
- 多线程:判空后属性可能被其他线程改为 null,导致运行时
- 黄金法则:
-
代码示例(编译器认可的三种安全化写法):
// 1. 空条件运算符 + 空合并 Console.WriteLine(customer.Address?.Country ?? "(Address unknown)"); // 2. 局部变量判空 Address? address = customer.Address; if (address != null) Console.WriteLine(address.Country); // 3. 直接对属性判空(编译器信任属性不变性) if (customer.Address != null) Console.WriteLine(customer.Address.Country); -
面试准备建议:
- 重要性:⭐⭐⭐(深入理解该特性实际影响,面试加分项)
- 面试回答要点:
- 可空引用类型是编译时特性,不改变运行时,
string和string?完全相同。 - 编译器通过静态流分析提供警告,三种常见安全模式(空条件、局部判空、属性判空)都能被正确识别。
- 必须清醒认识到局限性:不处理多线程修改和不纯 getter 的情况,因此运行时
NullReferenceException仍可能发生,关键公共 API 仍需参数校验。 - Unity的话,减少NRE可以用
TryGetComponent和序列化字段[Required]
- 可空引用类型是编译时特性,不改变运行时,
15.1.5 damnit 运算符或者 bang 运算符 !
-
核心概念:
!运算符(dammit/bang operator)用于抑制编译器的可空性警告,告诉编译器“我确定此表达式在运行时不会为 null”,从而强制将可空表达式视为非空类型。 -
关键点:
- 作用:覆盖编译器的静态空值分析。当开发者比编译器更清楚某个值实际非空,或故意传入 null 测试时使用。
- 两种典型场景:
- 编译器无法推断的安全情况:
- 例如,
string.IsNullOrEmpty的调用者和编译器之间缺乏关联性信息。我们“知道”若它返回false,则字符串非空。 - 在
if (!string.IsNullOrEmpty(text))内使用text!.Length消除警告。
- 例如,
- 单元测试中故意传入 null:
- 测试参数校验逻辑时,需向非空参数传入
null。 - 使用
null!抑制编译器警告,如new Customer(null!, address)。
- 测试参数校验逻辑时,需向非空参数传入
- 编译器无法推断的安全情况:
- 运行时行为:与可空引用类型一致,
!只是编译时指令,不生成任何 IL 代码。如果实际值为 null,仍会抛出NullReferenceException。 - 使用原则:应谨慎使用,尽量通过其他方式(如更精确的类型、空条件访问)让编译器自动推断安全;滥用会削弱特性价值。
-
代码示例:
// 1. 编译器无法推断的关联 static void PrintLength(string? text) { if (!string.IsNullOrEmpty(text)) { Console.WriteLine(text!.Length); // 使用 ! 告知编译器 text 非 null } } // 2. 单元测试中故意传 null [Test] public void Customer_NameValidation() { Address address = new Address("UK"); Assert.Throws<ArgumentNullException>( () => new Customer(null!, address)); // null! 抑制非空参数警告 } -
面试准备建议:
- 重要性:⭐⭐(理解用途与风险)
- 面试回答要点:
- bang 运算符
!是 C# 8 可空引用类型系统的一部分,用于强制将可能为 null 的表达式视为非 null,抑制编译器警告。 - 强调它不是空值检查,不插入运行时保护,误用会导致运行时异常。
- 常见用法:当开发者通过逻辑保证值非空,但编译器无法证实时;以及在测试中故意传递 null。
- 建议谨慎使用,优先采用空条件运算符或类型系统表达可空性,以保持安全性。
- 在 Unity 中,若开启可空引用类型,可能在某些反射或序列化场景下需要
!告诉编译器“这个字段会被外部赋值”,但仍应搭配防御性编程。
- bang 运算符
📦15.1.6 可空引用类型迁移经验(基于 Noda Time 项目实践)
- 核心概念:将现有项目迁移到可空引用类型是一个迭代式过程,需先梳理旧有 null 意图,逐步修复警告,合理使用
!运算符,并审慎处理泛型等边缘情况。 - 关键点:
- 迁移前的准备:
- 梳理项目中哪些引用可空、哪些非空。作者原先使用自定义
[CanBeNull]、[NotNull]特性标注意图。 - 利用 ReSharper 等工具辅助识别,或保持自定义特性为后续迁移打下基础。
- 梳理项目中哪些引用可空、哪些非空。作者原先使用自定义
- 迭代式修复:
- 开启特性后,警告会大量出现。修复一批后可能引发更多警告(类似“打地鼠”),需多次反复。
- 警告不增反减属于正常现象,需团队评估整体改造成本。
- 最难处理的情况:同一变量在不同位置需要不同可空性,往往是原设计问题。
!运算符使用规范:- 非必要不使用,产品代码中使用时应加注释说明原因,便于未来审查。
- 仅在开发者确定编译器无法推断时才用,避免滥用。
- 作者主要在单元测试中使用(如测试参数校验),产品代码中极少用。
- 若因
!使用错误导致运行时异常,可接受——暴露假设错误好于隐藏。
- 泛型处理难题:
IEqualityComparer<T>等既有接口的Equals(T)接受 null,GetHashCode(T)不接受 null,可空引用类型在此类场景下缺乏完美方案。- 难以决定实现
IEqualityComparer<Period?>还是IEqualityComparer<Period>,都会面临警告或运行时问题。 - 这是泛型可空性设计的普遍难题,无统一解法,非开发者错误。
- 迁移成效:
- Noda Time 迁移耗时约 5 小时(中型项目),修复了真实 bug(如 Mono 下
TimeZoneInfo.Local返回 null)。 - 最终效果:编译器可全面检查空值一致性,库使用者可在编译时发现潜在 null 问题,大幅提升健壮性。
- Noda Time 迁移耗时约 5 小时(中型项目),修复了真实 bug(如 Mono 下
- 版本说明:以上经验基于 C# 8 早期预览版(2018 年上半年),最终特性可能有调整。
- 迁移前的准备:
- 面试准备建议:
- 重要性:⭐(了解真实迁移经验,展现工程实践能力)
- 面试回答要点:
- 强调可空引用类型的迁移需要迭代进行,不能一蹴而就;合理使用
!且需注释。 - 提及泛型接口的可空性处理是一个已知棘手问题,体现对语言局限性的了解。
- 在 Unity 项目中引入可空引用类型,可先在核心逻辑或新代码中启用,逐步覆盖;需注意与 Unity 序列化系统的交互。
- 面试时能分享此类实践经验,表明对现代 C# 特性有落地的思考,而非仅了解语法。
- 强调可空引用类型的迁移需要迭代进行,不能一蹴而就;合理使用
📦15.1.7 未来的改进
- 核心概念:C# 设计团队认识到预览版的可空引用类型仍需大量打磨,未来将围绕编译器语义推导、泛型可空性、自动参数校验、分步迁移等方面持续完善,最终实现更安全、更智能的 null 检查体系。
- 关键点:
- 增强编译器语义推导:
- 当前编译器不理解
string.IsNullOrEmpty、ReferenceEquals、XElement显式转换等方法的 null 语义,导致本应安全的代码仍产生警告。 - 短期:可能为常见 BCL 方法硬编码语义规则。
- 长期:设计一套特性(attribute)体系,让库作者能向编译器声明方法的输入输出 null 关系,从根本上减少
!的使用。
- 当前编译器不理解
- 泛型可空性的深度挑战:
- 同一个泛型类
Wrapper<T>,对于int、int?、string、string?的默认值行为截然不同,且string/string?在 CLR 层面完全一致,无法简单区分。 - 现有的
T?仅适用于where T : struct,引入可空引用类型后需要全新的约束机制,但值类型和引用类型的可空语义本质不同,无法统一。 - 这是 C# 团队尚未解决的核心设计难题之一。
- 同一个泛型类
- 运行时参数校验语法糖:
- 编译时检查不足以保证运行时安全,参数校验代码仍然必要。
- 提议语法:
static void PrintLength(string text!)—— 参数名后加!,编译器自动生成ArgumentNullException检查。 - 可应用于方法和属性,大幅减少样板代码。
- 可空性检查的启用策略:
- 正式版需提供更精细的开关,默认应关闭可空性检查,避免存量项目升级后出现大量警告。
- 需要支持混合场景:已启用可空检查的项目引用尚未升级的库时,应可灵活配置外部的 null 假设。
- 允许按文件、按类逐步迁移;为自动生成的代码提供豁免机制。
- 这是 C# 史上兼容性适配工作量最大的特性之一。
- 增强编译器语义推导:
- 面试准备建议:
- 重要性:⭐(了解演进方向,体现技术前瞻性)
- 面试回答要点:
- 可空引用类型仍在演进中,未来的改进重点包括:让编译器更智能地推断 null 状态(减少
!依赖)、解决泛型可空性难题、提供参数自动校验语法、以及设计灵活的项目级启用策略。 - 面试时若被问到该特性的局限性或未来,可提及泛型可空性至今仍是设计挑战,展现对语言底层复杂度的理解。
- 在 Unity 语境下,可讨论:随着 C# 版本升级,若能结合自动参数校验和更智能的编译器推导,将使 MonoBehaviour 和自定义组件的 null 安全性大幅提升,减少大量防御性代码。
- 可空引用类型仍在演进中,未来的改进重点包括:让编译器更智能地推断 null 状态(减少
❗️15.2 switch表达式
-
核心概念:C# 8 引入
switch表达式,允许将switch作为表达式使用,每个分支通过=>直接返回结果,语法更简洁,特别适合模式匹配场景下的值映射。 -
关键点:
- 语法变化(对比传统
switch语句):- 顺序反转:
shape switch { ... }替代switch (shape) { ... }。 - 箭头分隔:模式与结果间用
=>连接,不再使用case关键字和冒号。 - 表达式体:每个分支必须返回一个值或抛出异常,无需
break或return。 - 逗号分隔:分支之间用逗号分隔,不再是分号。
- 兜底分支:用下划线
_取代default,表示未匹配所有前置模式时执行。
- 顺序反转:
- 适用场景:任何可以使用表达式的位置,包括变量赋值、方法返回值,特别适合表达式体方法。
- 硬性要求:
switch表达式必须穷举所有可能,要么覆盖所有情况,要么通过_兜底,否则编译器将产生警告(未来将成为错误),可能自动插入抛出InvalidOperationException的兜底逻辑。 - 已知局限(早期预览版):不能多个模式共享同一结果,不像传统
switch可以堆叠case标签。设计团队计划后续版本添加此能力。
- 语法变化(对比传统
-
代码示例:
// 使用 switch 表达式作为方法体(表达式主体方法) static double Perimeter(Shape shape) => shape switch { null => throw new ArgumentNullException(nameof(shape)), Rectangle rect => 2 * (rect.Height + rect.Width), Circle circle => 2 * Math.PI * circle.Radius, Triangle triangle => triangle.SideA + triangle.SideB + triangle.SideC, _ => throw new ArgumentException($"Unknown shape: {shape.GetType()}", nameof(shape)) }; // 赋值给变量 double circumference = shape switch { Circle c => 2 * Math.PI * c.Radius, _ => 0 }; -
面试准备建议:
- 重要性:⭐⭐⭐(C# 8 重要特性,面试高频)
- 面试回答要点:
switch表达式是 C# 8 对模式匹配的进一步增强,使switch可以作为表达式使用,每个分支直接产生值。- 语法要点:
value switch { pattern => result, _ => defaultResult };无需case、break,用=>和逗号分隔。 - 强调它特别适合与表达式体成员结合,消除冗余的
switch语句和临时变量。 - 在 Unity 中应用:处理不同类型的事件、根据状态枚举返回不同配置值、解析不同类型的资源路径等,可让代码更简洁声明式。
- 了解早期版本的局限(不能共享结果分支),展现对技术演进的跟踪。
15.3 嵌套模式匹配
15.3.1 使用模式来匹配属性
-
核心概念:在模式匹配中嵌套子模式,直接匹配对象的属性并提取值,无需引入完整对象变量。使用大括号
{}包裹属性模式,每个属性对应一个模式,以逗号分隔。 -
关键点:
- 语法:
Type { Property1: pattern1, Property2: pattern2 }可选地后跟变量名(如rect)以捕获整体对象。 - 子模式:属性模式内部可以是任何常规模式(类型模式、常量模式、
var模式等),最常用的是var h来提取属性值到变量。 - 优势:
- 只提取需要的属性,避免引入不必要的整体变量。
- 可结合常量模式做条件匹配,如
Rectangle { Height: 0 } rect匹配高度为 0 的矩形并捕获整个对象。 - 当对象属性众多但仅关心少数几个时,代码更精炼,意图更明确。
- 与旧版对比:
- 传统写法:
Rectangle rect => 2 * (rect.Height + rect.Width),引入rect变量。 - 嵌套模式:
Rectangle { Height: var h, Width: var w } => 2 * (h + w),直接使用属性变量。
- 传统写法:
- 适用场景:复杂业务逻辑、对象属性较多、仅需校验或读取少数属性时优势明显;简单场景两者皆可。
- 语法:
-
代码示例:
// 嵌套模式直接提取属性 static double Perimeter(Shape shape) => shape switch { null => throw new ArgumentNullException(nameof(shape)), Rectangle { Height: var h, Width: var w } => 2 * (h + w), Circle { Radius: var r } => 2 * Math.PI * r, Triangle { SideA: var a, SideB: var b, SideC: var c } => a + b + c, _ => throw new ArgumentException($"Unknown shape", nameof(shape)) }; // 结合常量模式与整体捕获 static string Describe(Shape shape) => shape switch { Rectangle { Height: 0 } rect => $"Flat rectangle of width {rect.Width}", // ... _ => "Other" }; -
面试准备建议:
- 重要性:⭐⭐(中等,了解特性有助于写出更优雅的模式匹配代码)
- 面试回答要点:
- 嵌套模式是 C# 8 对模式匹配的增强,允许直接匹配对象的属性,无需引入整个对象。
- 语法:
Type { Property: pattern },属性模式可使用var提取值、常量校验等。 - 优势:减少冗余变量,特别适合只关心对象部分字段的场合,与
switch表达式配合可写出高度声明式的代码。 - 在 Unity 中应用:处理复杂的游戏对象状态判断,如根据
Player { Health: var hp, Armor: var ar }计算伤害减免,或根据碰撞信息匹配特定层级和标签,让代码更易读。
15.3.2 分解模式
-
核心概念:将已定义的
Deconstruct方法直接用于模式匹配,在switch表达式或is模式中直接“解构”对象并提取成员变量,无需逐个书写属性名称。 -
关键点:
- 语法形式:
Type (var x, var y, ...)替代属性嵌套模式Type { PropA: var x, PropB: var y }。 - 依赖前提:目标类型必须具有可访问的
Deconstruct方法(自定义或扩展方法),且out参数数量、类型与模式匹配。 - 写法对比:
- 属性嵌套模式:
Triangle { SideA: var a, SideB: var b, SideC: var c } => a + b + c - 分解模式:
Triangle (var a, var b, var c) => a + b + c
- 属性嵌套模式:
- 可读性权衡:是否比属性嵌套模式更清晰,取决于个人习惯和具体场景。团队可形成编码规范以保持一致性。
- 与之前特性的关联:是第 12 章分解特性与本章模式匹配特性的结合,体现了 C# 逐步融合声明式、函数式编程风格的趋势。
- 语法形式:
-
代码示例:
// 自定义类型提供 Deconstruct 方法 public class Triangle : Shape { public double SideA { get; } public double SideB { get; } public double SideC { get; } public void Deconstruct(out double sideA, out double sideB, out double sideC) => (sideA, sideB, sideC) = (SideA, SideB, SideC); } // 在 switch 表达式中使用分解模式 static double Perimeter(Shape shape) => shape switch { // ... 其他分支 Triangle (var a, var b, var c) => a + b + c, // 直接分解为三边长度 _ => throw new ArgumentException("Unknown shape") }; -
面试准备建议:
- 重要性:⭐⭐(中等,体现对模式匹配进阶用法的理解)
- 面试回答要点:
- 分解模式将
Deconstruct方法与模式匹配结合,在switch或is中直接解构对象成员。 - 相比属性嵌套模式,语法更紧凑,当需要提取多个成员时优势明显。
- 可举例 Unity 中的
Vector3:为其定义扩展Deconstruct后,可在模式匹配中写Vector3 (var x, var y, var z)直接获取三个分量,用于坐标判断、范围计算等。 - 强调理解其依赖的
Deconstruct方法机制(第 12 章内容),能展现对语言特性组合运用的能力。
- 分解模式将
15.3.3 忽略模式中的类型——属性模式与 {} 空对象匹配
-
核心概念:在嵌套模式匹配中,可省略重复的类型声明,直接使用属性模式
{ Property: pattern }或空属性模式{ }(匹配任何非 null 对象),从而编写更简洁、声明式的条件逻辑。 -
关键点:
- 属性模式省略类型:当上下文已明确对象类型(如
Customer)时,直接写{ Address: { Country: "UK" } }而无需写Customer { ... },编译器自动推断类型。 - 空属性模式
{ }:匹配任何非 null 的对象,不关心其属性值。常用来区分“对象存在但某些字段为空”的情况。 - 常量模式与 var 模式嵌套:
{ Address: { Country: "UK" } }:常量模式,精准匹配国家。{ Address: { Country: string country } }:类型/var 模式,捕获国家名到country变量。
- 顺序至关重要:
switch表达式自上而下匹配,更具体的模式(如"UK")必须放在通用模式(如string country)之前,否则会被“拦截”而永远无法匹配。 - 典型应用场景:替代多层
if-else对对象图进行深度条件判断,如根据订单状态、用户地址、游戏实体属性组合执行不同逻辑。 - 不要滥用:虽然模式匹配功能强大,但对于简单条件,传统
if可能更直观;团队应逐步建立统一的风格规范。
- 属性模式省略类型:当上下文已明确对象类型(如
-
代码示例(基于 Customer/Address 模型):
static string GetGreeting(Customer customer) => customer switch { { Address: { Country: "UK" } } => "Welcome, UK customer!", { Address: { Country: "USA" } } => "Welcome, USA customer!", { Address: { Country: string c } } => $"Welcome, customer from {c}!", { Address: { } } => "Welcome, address without country!", { } => "Welcome, customer with no address!", _ => "Welcome, null customer!" }; -
面试准备建议:
- 重要性:⭐⭐(中等,体现代码简洁性与声明式编程思想)
- 面试回答要点:
- 属性模式可以直接嵌套,无需重复指定类型,
{}可匹配任意非 null 对象。 - 匹配顺序必须从具体到通用,避免分支被“遮盖”。
- 可有效替代复杂的
if-else嵌套,尤其适合处理深层对象图的条件分支(如游戏中的实体状态、UI 数据模型)。 - 在 Unity 面试中,可举例:处理
RaycastHit信息,根据collider的标签、层级等属性组合,用模式匹配写出清晰的伤害计算或交互逻辑。 - 强调应追求代码清晰,避免为用模式匹配而把简单逻辑复杂化。
- 属性模式可以直接嵌套,无需重复指定类型,
15.4 index 和 range
15.4.1 index 与 range 类型和字面量
-
核心概念:引入
Index和Range两个结构体,配合^(从末尾索引)和..(范围)运算符,实现简洁、统一的位置与切片表达。 -
关键点:
-
Index结构体:- 表示序列中的一个位置,不能为负数。
int可隐式转换为表示从起始位置计数的Index。- 一元运算符
^:创建从末尾倒数的Index。^1为最后一个元素,^0为末尾之后的位置(常用于表示不包含的上界)。
-
Range结构体:- 由起始和结束两个
Index组成,表示一个区间。 - 二元运算符
..:构造Range。可省略任意一端:..(全部)、start..、..end、start..end。 - 区间为左闭右开,不包含结束
Index指向的元素。
- 由起始和结束两个
-
语法支持:
- 可用于任何支持索引和长度的类型(通过
Length/Count属性和索引器,或特定模式)。 string、数组、Span<T>等原生支持。
- 可用于任何支持索引和长度的类型(通过
-
代码示例:
Index start = 2; // 正向索引 2 Index end = ^2; // 倒数索引,倒数第二个元素 Range all = ..; // 全部 Range fromStart = start..; Range toEnd = ..end; Range range = start..end; Range implicit = 1..5; // 从索引 1 到索引 5(不包含 5) string quotedText = "'This text was in quotes'"; Console.WriteLine(quotedText.Substring(1..^1)); // "This text was in quotes"
-
-
面试准备建议:
- 重要性:⭐⭐(常用小特性,体现代码简洁性)
- 面试回答要点:
^和..是 C# 8 引入的 index 和 range 语法糖,让序列的索引和切片操作更直观。^n表示倒数第 n 个元素,start..end表示左闭右开区间。- 可以用于
string、数组、Span<T>等,大幅减少Substring、Take/Skip等方法的复杂参数计算。 - 在 Unity 中:处理字符串格式化、数组数据分段、配合
Span<T>操作大型数据集时可提升可读性,减少偏移量计算错误。
15.4.2 应用 index 和 range
-
核心概念:通过
Index和Range重载的索引器,数组、Span<T>、字符串等序列类型可以使用统一的语法进行单个元素访问和子序列截取,消除不同 API 的差异。 -
关键点:
- 统一的元素访问:
sequence[2]:正向索引获取元素。sequence[^3]:从末尾倒数获取元素,^1为最后一个。
- 统一的切片截取:
sequence[start..end]:返回子序列(左闭右开区间)。- 适用于
string(替代Substring)、Span<T>(替代Slice)、数组等。
- 混合索引:
- 可自由组合正向索引和倒数索引,如
text[^5..]截取最后 5 个字符,text[^10..5]混合使用倒数起始和正向结束索引。 - 自动转换为合法的正向区间,例如
text[^10..5]等价于text[1..5]。
- 可自由组合正向索引和倒数索引,如
- 条件:需要目标类型通过索引器或扩展方法支持
Index和Range,.NET 预览版已为常用类型提供支持。
- 统一的元素访问:
-
代码示例(字符串与
Span<int>统一操作):string text = "hello world"; Console.WriteLine(text[2]); // 'l' Console.WriteLine(text[^3]); // 'r' Console.WriteLine(text[2..7]); // "llo w" Span<int> span = stackalloc int[] { 5, 2, 7, 8, 2, 4, 3 }; Console.WriteLine(span[2]); // 7 Console.WriteLine(span[^3]); // 2 Span<int> slice = span[2..7]; // {7, 8, 2, 4, 3} Console.WriteLine(string.Join(", ", slice.ToArray())); // 7, 8, 2, 4, 3 // 混合正向/倒数索引 Console.WriteLine(text[^5..]); // "world" Console.WriteLine(text[^10..5]); // "ello" (等价于 text[1..5]) -
面试准备建议:
- 重要性:⭐⭐(实用特性,提升代码简洁度)
- 面试回答要点:
Index和Range通过统一的语法简化了序列操作,[^1]取最后一个元素,[start..end]切片。- 适用于数组、
string、Span<T>等所有可索引序列,消除Substring/Slice的不一致。 - 在 Unity 中处理字符串解析、数组数据分段、与
Span<T>结合的高性能内存操作时,可大幅提高可读性,减少索引计算错误。 - 注意区间为左闭右开,
^0表示末尾之后的位置,常用作不包含末尾的结束索引。
15.5 更多异步集成
15.5.1 异步资源回收(IAsyncDisposable 和 await using,C# 8 预览)
-
核心概念:引入
IAsyncDisposable接口和await using(原文为using await,预览版语法)语句,允许资源释放时异步执行清理操作,避免阻塞线程。 -
关键点:
-
IAsyncDisposable接口:
用于实现异步资源释放逻辑(如异步 flush 流、关闭连接等)。public interface IAsyncDisposable { Task DisposeAsync(); } -
using await语句:在using后添加await,编译器会确保在退出作用域时调用DisposeAsync()并等待其完成。 -
执行顺序:先执行
using块内的异步操作,再自动等待异步释放。 -
待解决问题(预览版状态):
- 如何通过编译器控制
ConfigureAwait(false)(类库与应用的上下文需求不同)。 - 如何集成取消令牌(CancellationToken),以便取消异步释放过程。
- 如何通过编译器控制
-
设计意义:填补了
IDisposable在异步场景下的空白,使异步资源管理更自然。
-
-
代码示例:
class AsyncResource : IAsyncDisposable { public async Task DisposeAsync() { Console.WriteLine("Disposing asynchronously..."); await Task.Delay(2000); Console.WriteLine("... done"); } public async Task PerformWorkAsync() { Console.WriteLine("Performing work asynchronously..."); await Task.Delay(2000); Console.WriteLine("... done"); } } async static Task Main() { using await (var resource = new AsyncResource()) { await resource.PerformWorkAsync(); } Console.WriteLine("After the using await statement"); } // 输出:执行工作 -> 异步释放 -> 结束后打印 -
面试准备建议:
- 重要性:⭐⭐(中等,体现对异步编程模型的全面理解)
- 面试回答要点:
IAsyncDisposable用于异步释放资源,与IDisposable互补。await using语句让异步清理代码更优雅,避免手动 try-finally 和忘记等待。
15.5.2 异步迭代(foreach await 和 IAsyncEnumerable<T>,C# 8 预览)
-
核心概念:引入
IAsyncEnumerable<T>和IAsyncEnumerator<T>接口,搭配foreach await语法,实现异步消费数据流,尤其适合分页、分批 IO 等场景。 -
关键点:
-
核心接口:
public interface IAsyncEnumerable<out T> { IAsyncEnumerator<T> GetAsyncEnumerator(); } public interface IAsyncEnumerator<out T> { Task<bool> WaitForNextAsync(); // 异步获取下一批数据,返回是否还有数据 T TryGetNext(out bool success); // 同步读取当前元素 } -
设计思路:
- 针对分页/分批 IO 场景(如数据库查询、RPC 调用),数据不是一次性全部返回。
WaitForNextAsync异步拉取下一批,TryGetNext同步读取,分离了异步等待和值获取。- 适合网络传输、流式处理,内存中已有数据应优先用同步
foreach。
-
foreach await语法:
编译器自动处理WaitForNextAsync/TryGetNext循环,开发者无需手动编写复杂的异步轮询逻辑。foreach await (var item in asyncSequence) { // 使用 item } -
实际应用:
- 分页 API 封装:
GeoClient.ListCitiesAsync()返回IAsyncEnumerable<string>,内部通过 RPC 分页令牌自动获取所有页数据。 - 消费方仅一行
foreach await,无需关心分页细节。
- 分页 API 封装:
-
待解决问题(预览版):
IAsyncEnumerator<T>尚未继承IAsyncDisposable,正式版预计会加入,以支持自动异步释放资源。TryGetNext方法签名(T TryGetNext(out bool success))与常规TryXxx模式不同,可能调整。- 支持基于模式(不强制实现接口)的
foreach await,类似同步foreach的鸭子类型。
-
-
代码示例(封装与消费异步序列):
// 消费方:极为简洁 var client = new GeoClient(service); foreach await (var city in client.ListCitiesAsync()) { Console.WriteLine(city); } // 封装方:内部实现 IAsyncEnumerable<string>,隐藏分页 RPC 细节 // (具体实现代码较长,省略,核心是正确实现 IAsyncEnumerator<string>) -
面试准备建议:
- 重要性:⭐⭐(中等,理解异步流式处理的价值)
- 面试回答要点:
IAsyncEnumerable<T>与foreach await让异步数据流的消费变得如同步foreach一样简单。- 最适用于需要分批/分页 IO 的网络、数据库等异步场景,避免一次性加载全部数据。
- 能说明
WaitForNextAsync与TryGetNext分离的设计意图(批量拉取 + 同步读取),体现对异步 IO 模式的理解。 - 在 Unity 中,可用于处理大型资源的分批加载、网络请求的分页结果、WebSocket 消息流等,提升大型异步数据处理的可读性和可维护性。
- 了解预览版的局限(如尚未继承
IAsyncDisposable),展现对技术演进阶段的认知。
📦15.5.3 异步迭代器
-
核心概念:允许在
async方法中使用yield return和yield break,返回IAsyncEnumerable<T>或IAsyncEnumerator<T>,从而自然地生成异步数据流。 -
关键点:
- 语法组合:方法标记
async,返回类型为IAsyncEnumerable<T>或IAsyncEnumerator<T>,方法体内使用await和yield return/yield break。 - 解决的问题:手动实现
IAsyncEnumerator<T>需要处理分页、异步等待、同步读取等复杂状态管理,异步迭代器让这些逻辑编写起来如同步迭代器一样简单。 - 编译器状态机:
- 生成的状态机需同时处理
await(异步等待)和yield(值产出)。 - 需在同步模式(无
await的连续 yield)和异步模式(有await暂停)间高效切换。 - 状态机响应调用方的
WaitForNextAsync()或TryGetNext(),决定何时恢复执行。
- 生成的状态机需同时处理
- 实际应用:
- 封装分页 RPC 调用(如云服务 API)为简洁的异步枚举。
- 以直观的
yield return逐个产出异步获取的元素,消费方用foreach await遍历。
- 状态:预览版中未上线,本内容为基于设计思路的合理推测。
- 语法组合:方法标记
-
代码示例(推测语法,非实际预览版代码):
public async IAsyncEnumerable<string> ListCitiesAsync() { string pageToken = null; do { var request = new ListCitiesRequest(pageToken); var response = await service.ListCitiesAsync(request); foreach (var city in response.Cities) { yield return city; } pageToken = response.NextPageToken; } while (pageToken != null); } -
面试准备建议:
- 重要性:⭐(了解即可,属于前沿特性)
- 面试回答要点:
- 异步迭代器结合了
async/await和yield return,用于简洁地生成异步序列。 - 最典型的场景是将分页、分批的异步 IO 封装为自然的异步枚举,供
foreach await消费。 - 了解其底层编译器状态机的复杂性(需同时处理异步等待和值产出),但日常使用只需专注业务逻辑。
- 在 Unity 开发中,若 .NET 版本支持,可用于异步加载资源列表、分页请求玩家数据等,使得代码更清晰、可维护。
- 强调该特性预览版尚未实现,属于对未来方向的了解,展现对 C# 语言演进趋势的关注。
- 异步迭代器结合了
15.6 预览版中尚未提供的特性
15.6.1 默认接口方法
此部分让AI结合了使用默认接口方法安全地更新接口 - C# | Microsoft Learn进行的总结
-
核心概念:C# 8 允许接口定义具有默认实现的方法、属性等成员,使得在已发布的接口中添加新成员成为安全的非破坏性变更,实现者可以选用默认实现或提供自己的版本。
-
关键点:
- 核心动机:为已广泛发布的接口安全添加新功能,不必要求所有已有实现者修改代码。
- 基本语法与调用:
- 接口内直接编写方法体。
- 接口成员可包含静态字段、静态方法、实例方法,可使用各种访问修饰符(
public、private、protected等)。 - 实现类不会“继承”默认方法,必须通过接口类型的变量来调用。
- 参数化配置能力:
- 可通过接口中的静态字段和静态方法提供可配置的默认参数,让使用者在不重写方法的情况下定制行为。
- 重用默认逻辑:
- 将默认实现的核心逻辑提取为
protected static方法,自身实现以及实现类的重写都可复用此逻辑。 - 实现类可调用
ICustomer.DefaultLoyaltyDiscount(this)来复用基接口逻辑,并在其前后添加额外行为。
- 将默认实现的核心逻辑提取为
- 版本升级的优雅性:接口作者可安全迭代,无需担心破坏现有使用者,符合开闭原则。
-
代码示例:
public interface ICustomer { string Name { get; } IEnumerable<IOrder> PreviousOrders { get; } DateTime DateJoined { get; } // 默认实现 public decimal ComputeLoyaltyDiscount() => DefaultLoyaltyDiscount(this); // 可被实现者替代或调用的受保护静态辅助方法 protected static decimal DefaultLoyaltyDiscount(ICustomer c) { DateTime start = DateTime.Now - length; if ((c.DateJoined < start) && (c.PreviousOrders.Count() > orderCount)) return discountPercent; return 0; } // 静态成员,用于参数化默认行为 public static void SetLoyaltyThresholds(TimeSpan ago, int minimumOrders = 10, decimal percentageDiscount = 0.10m) { length = ago; orderCount = minimumOrders; discountPercent = percentageDiscount; } private static TimeSpan length = new TimeSpan(365 * 2, 0, 0, 0); private static int orderCount = 10; private static decimal discountPercent = 0.10m; } // 实现者:可选择使用默认参数、定制参数、或完全重写 public class SampleCustomer : ICustomer { // 重写:先应用新客户特惠,否则调用接口默认逻辑 public decimal ComputeLoyaltyDiscount() { if (!PreviousOrders.Any()) return 0.50m; return ICustomer.DefaultLoyaltyDiscount(this); } } // 调用方:必须通过接口类型访问默认方法 ICustomer c = new SampleCustomer(); Console.WriteLine(c.ComputeLoyaltyDiscount()); -
面试准备建议:
- 重要性:⭐⭐(中等,体现对C#版本演进的掌握)
- 面试回答要点:
- 默认接口方法解决接口版本化问题:向已有接口安全添加新成员而不破坏现有实现。
- 实现类不“继承”接口默认方法,必须显式调用,且需通过接口类型访问。
- 与扩展方法的关键区别:默认接口方法是虚方法,可在实现类中被重写以提供优化或定制逻辑;接口内还可包含静态成员辅助默认实现。
- 可举例:
IEnumerable<T>若添加默认Count(),集合类可重写以提供高效实现。
❗️15.6.2 记录类型(record,C# 9 引入)
此部分结合了C# 记录类型 - C# | Microsoft Learn进行总结
-
核心概念:记录是一种用于简化数据模型的类型修饰符,可应用于
class或struct。编译器自动生成值相等性、ToString、Deconstruct和with表达式等成员,使不可变数据的创建和操作变得简洁。 -
关键点:
- 声明语法:
- 位置记录:
public record Person(string FirstName, string LastName);自动生成 init-only 属性。 - 传统属性:可手动定义
get; init;或get; set;属性以获取更多控制。 record默认是record class(引用类型);record struct是值类型。
- 位置记录:
record classvsrecord struct:record class:引用类型,赋值复制引用;默认属性为 init-only(不可变);支持继承。record struct:值类型,赋值复制整个数据;默认属性可读写(需加readonly变为 init-only);不支持继承。
- 编译器生成的成员:
- 基于位置的构造器、
init属性。 - 值相等性:重写
Equals、GetHashCode、==、!=,逐属性比较(引用类型成员按引用比较)。 - 格式化
ToString()。 Deconstruct方法,支持解构为独立变量。
- 基于位置的构造器、
with表达式(非破坏性变异):- 创建现有记录的副本,修改部分属性:
var modified = original with { FirstName = "Margaret" }; - 仅创建一个新对象,性能优于多次调用
WithX方法。 - 可用于
record class和record struct。
- 创建现有记录的副本,修改部分属性:
- 继承:
record class可继承自另一个record class,不能继承普通类,普通类也不能继承记录。- 值相等性检查包含运行时类型,所以
Person和Student即使属性相同也不相等。
- 使用场景:主要角色是存储数据;需要值相等性;偏爱不可变性(尤其
record class);希望自动获得可读的ToString。 - 避免场景:Entity Framework Core 中的实体类型(依赖引用相等性跟踪实体)。
- 声明语法:
-
代码示例:
// 位置记录 public record Person(string FirstName, string LastName); // record struct public record struct Coordinate(double Latitude, double Longitude); // 值相等性 var p1 = new Person("Grace", "Hopper"); var p2 = new Person("Grace", "Hopper"); Console.WriteLine(p1 == p2); // True(值相等) // with 表达式 var original = new Person("Grace", "Hopper"); var modified = original with { FirstName = "Margaret" }; Console.WriteLine(modified); // Person { FirstName = Margaret, LastName = Hopper } // 解构 var (first, last) = modified; // 继承 public record Student(string FirstName, string LastName, int GradeLevel) : Person(FirstName, LastName); -
面试准备建议:
- 重要性:⭐⭐⭐(C# 9+ 核心特性,高频考点)
- 面试回答要点:
- 记录类型是用于简化不可变数据模型定义的语法糖,编译器自动生成值相等性、
with表达式、解构等方法。 record class是引用类型、默认不可变、支持继承;record struct是值类型、默认可变、不支持继承。with表达式用于非破坏性创建修改后的副本,保持原对象不变。- 对比类/结构体:普通类引用相等,结构体默认值相等但性能差;记录提供高效、自动的值相等性。
- 在 Unity 中:数据容器(配置、事件参数、状态快照)非常适合用记录,可显著减少样板代码并保证不可变性;需注意 Unity 序列化系统对记录的支持(通常需要自定义序列化器),且
record class是堆分配,高频场景可考虑record struct。
- 记录类型是用于简化不可变数据模型定义的语法糖,编译器自动生成值相等性、

浙公网安备 33010602011771号