10.1 using static 指令
10.1.1 引入静态成员
-
核心概念:
using static指令允许直接导入某个类型的静态成员,从而省略类型名前缀,简化代码。 -
关键点:
- 语法:
using static 类型名;,之后可直接使用该类型的静态字段、属性、方法、枚举值、嵌套类型。 - 可导入的成员:
- 静态字段和属性(如
Math.PI→PI) - 静态方法(如
Math.Cos(..)→Cos(..)) - 枚举值(如
BindingFlags.Public→Public) - 嵌套类型(如
Outer.Types.Inner→Inner)
- 静态字段和属性(如
- 适用于非静态类:即使类型本身不是静态类,也可导入其静态成员,如
using static System.String;后可直接用Join。 - 名称冲突优先级:如果当前类中存在同名成员,将优先使用自己的成员,而非导入的静态成员。
- 局限性:无法导入扩展方法(将在后续内容讨论)。
- 语法:
-
代码示例:
// 极坐标转换,消除 Math. 前缀 using static System.Math; static Point PolarToCartesian(double degrees, double magnitude) {double radians = degrees * PI / 180;return new Point(Cos(radians) * magnitude, Sin(radians) * magnitude); }// 枚举组合,消除 BindingFlags. 前缀 using static System.Reflection.BindingFlags; var fields = type.GetFields(Instance | Static | Public | NonPublic);// switch 语句中使用枚举值 using static System.Net.HttpStatusCode; switch (response.StatusCode) {case OK: ...case NotFound: ... }// 嵌套类型简化 using static Outer.Types; Outer outer = new Outer { Inner = new Inner { Text = "Some text" } };// 非静态类导入静态方法 using static System.String; Console.WriteLine(Join("/", "a", "elements")); -
面试准备建议:
- 重要性:⭐⭐(常用语法糖,提升代码简洁度)
- 面试回答要点:
using static是 C# 6 引入的指令,用于导入类型的静态成员,可省略类型名直接调用。- 适用于静态方法、静态属性、常量、枚举值、嵌套类型。
- 当存在命名冲突时,类自身成员优先级更高。
- 不能导入扩展方法。
- 常用场景:数学计算(
using static System.Math)、枚举标志位、枚举 switch、嵌套类型访问。 - 注意:过度使用可能降低可读性(无法一眼看出方法来源),应确保上下文清晰。
10.1.2 using static 与扩展方法
-
核心概念:
using static可精确引入特定类的扩展方法,而不引入同命名空间下其他类的扩展方法。但引入的扩展方法只能以实例方法形式调用,不能像普通静态方法那样调用。 -
关键点:
- 精细控制扩展方法引入:
- 传统
using指令会引入整个命名空间,包括所有扩展方法,无法分离。 using static 具体类只引入该类中定义的扩展方法,不会引入同命名空间其他类中的扩展方法。
- 传统
- 调用方式限制:
- 通过
using static引入的扩展方法只能以实例方法语法调用(如obj.Method())。 - 不能像调用普通静态方法那样调用(如
Class.Method(obj)),会编译错误。
- 通过
- 对库开发者的影响:
- 将普通静态方法改为扩展方法(加
this)以前是非破坏性修改,但在 C# 6 中可能破坏使用using static引入该方法的代码。 - 建议将扩展方法按类型或功能分散到不同静态类,以便用户按需引入。
- 将普通静态方法改为扩展方法(加
- 方法查找优先级:
- 通过
using static引入的扩展方法,优先级低于通过命名空间直接引入的扩展方法。 - 当多个来源同时存在匹配的扩展方法时,仍遵循正常的重载决议规则。
- 通过
- 精细控制扩展方法引入:
-
代码示例:
using static System.Linq.Queryable; // 只引入 Queryable 的扩展方法 // 不引入 System.Linq.Enumerable 的扩展方法var query = new[] { "a", "bc", "d" }.AsQueryable(); Expression<Func<string, bool>> expr = x => x.Length > 1; Func<string, bool> del = x => x.Length > 1;var valid = query.Where(expr); // 正确:调用 Queryable.Where // var invalid = query.Where(del); // 错误:Enumerable.Where 未被引入using static System.Linq.Enumerable;IEnumerable<string> strings = new[] { "a", "b", "c" }; int valid = strings.Count(); // 正确:以扩展方法形式调用 // int invalid = Count(strings); // 错误:不能以静态方法形式调用 -
面试准备建议:
- 重要性:⭐⭐(理解该特性可体现对语言细节的掌握)
- 面试回答要点:
using static可以精确地只引入指定类的扩展方法,避免了传统using全命名空间引入的污染。- 引入的扩展方法只能以实例方法语法调用,不能作为静态方法调用,这是语言设计上的明确区分。
- 库开发者在添加扩展方法时需注意:将静态方法改为扩展方法可能会破坏使用了
using static的客户端代码。 - 可以结合 LINQ 举例:需要表达式树版本的
Where时,使用using static System.Linq.Queryable可以避免误用Enumerable.Where,提高安全性。 - 若被问到“如何避免引入不需要的扩展方法”,可回答使用
using static按类引入,或库作者将扩展方法分散到独立类中。
10.2 对象初始化器和集合初始化器特性增强
10.2.1 对象初始化器中的索引器
-
核心概念:C# 6 扩展了对象初始化器,支持直接使用索引器为成员赋值;集合初始化器保持不变,但新增了扩展方法支持。两者适用的场景有所重叠,选择时需注意键值重复等陷阱。
-
关键点:
- 对象初始化器中的索引器:
- 现在可以在初始化器中使用
[index] = value来调用索引器 setter。 - 典型用例:
StringBuilder设置某个位置的字符、Dictionary<TKey,TValue>添加或覆盖元素、ConcurrentDictionary<,>等无Add方法的类型初始化。
- 现在可以在初始化器中使用
- 与集合初始化器的对比(以
Dictionary为例):- 集合初始化器:
{ {"A", 20}, {"B", 30} }调用Add方法,重复键抛异常。 - 索引器初始化器:
{ ["A"] = 20, ["B"] = 30 }调用索引器 setter,重复键覆盖旧值,不抛异常,更容易隐藏 bug。 - 若不小心复制粘贴导致重复键,集合初始化器能尽早暴露问题,更推荐在要求键唯一时使用。
- 集合初始化器:
- 何时优先使用索引器初始化器:
- 类型无
Add方法,无法使用集合初始化器(如ConcurrentDictionary<,>)。 - 类型索引器和
Add对重复键的处理方式相同。 - 确实需要替换已有元素,而不是新增。
- 需要同时初始化其他非集合属性,代码更紧凑。
- 类型无
- 代码易读性权衡:索引器初始化器可能看起来更简洁,但需警惕键值覆盖风险。可将键设为常量字符串以降低出错概率。
- 对象初始化器中的索引器:
-
代码示例:
// 对象初始化器中使用索引器(StringBuilder) StringBuilder builder = new StringBuilder("This text needs truncating") {Length = 10,[9] = '\u2026' };// 字典初始化对比 // 集合初始化器:重复键抛出异常 var dict1 = new Dictionary<string, int> {{ "A", 20 },{ "B", 30 },{ "B", 40 } // 运行时 ArgumentException };// 索引器初始化器:重复键静默覆盖 var dict2 = new Dictionary<string, int> {["A"] = 20,["B"] = 30,["B"] = 40 // 编译通过,最终 "B" 的值是 40 };// 无 Add 方法的 ConcurrentDictionary var concurrent = new ConcurrentDictionary<string, int> {["x"] = 1,["y"] = 2 };// 混合普通属性和索引器 SchemalessEntity child = new SchemalessEntity {Key = "child-key",ParentKey = parent.Key,["name"] = "Jon Skeet",["location"] = "Reading, UK" }; -
面试准备建议:
- 重要性:⭐⭐(实用技巧,但非高频)
- 面试回答要点:
- C# 6 允许在对象初始化器中使用索引器赋值,语法为
[key] = value,适用于Dictionary、StringBuilder、ConcurrentDictionary等类型。 - 与集合初始化器的区别:集合初始化器调用
Add方法,重复键会抛出异常;索引器初始化器调用 setter,重复键会覆盖旧值,更容易掩盖错误。面试中若被问到字典初始化,可主动提及这一点,体现对细节的关注。 - 适用场景:类型没有
Add方法,或需要覆盖已有值,或要混合初始化其他属性。 - 在 Unity 中,初始化自定义配置对象、序列化容器或某些没有
Add的集合类时可能会用到。 - 建议加上常量字符串避免键值拼写错误,提高可维护性。
- C# 6 允许在对象初始化器中使用索引器赋值,语法为
📦10.2.2 在集合初始化器中使用扩展方法(C# 6)
-
核心概念:C# 6 允许集合初始化器通过扩展方法来满足
Add方法的需求,使原本不支持集合初始化器的类型(或需要特定重载时)能利用扩展方法完成初始化。 -
关键点:
- 传统限制回顾:
- 类型必须实现
IEnumerable(C# 6 未放宽此限制)。 - 必须有合适的
Add实例方法(单参数或多参数)。
- 类型必须实现
- C# 6 增强:集合初始化器查找
Add方法时,会考虑扩展方法,且支持可选参数。 - 常见应用场景:
- 添加通用重载:为
List<T>添加Add(IEnumerable<T>),使其能直接用AddRange风格的添加,尤其便于初始化只读集合属性。 - 创建专属
Add方法:为特定字典类型添加以值对象直接推导键的Add方法,简化代码(如dict.Add(person)自动提取person.Name)。 - 暴露显式接口实现:某些类型(如
ConcurrentDictionary<,>)把Add方法作为显式实现隐藏,通过扩展方法重新暴露,使其支持集合初始化器。
- 添加通用重载:为
- 注意事项:
- 用扩展方法暴露显式实现的方法要谨慎,因为可能破坏封装意图。
- 结合
using static可以精确控制引入哪些扩展方法,避免污染其他代码区域。 - 选择扩展方法方案前,权衡简洁性与可能引入的误解。
- 传统限制回顾:
-
代码示例:
// 1. 扩展 List<T> 支持添加集合 static class ListExtensions {public static void Add<T>(this List<T> list, IEnumerable<T> collection)=> list.AddRange(collection); } // 使用: var contacts = new List<Person> {allContacts.Where(c => c.Town == "Reading") };// 2. 专有化字典 Add 方法 static class PersonDictionaryExtensions {public static void Add(this Dictionary<string, Person> dict, Person person)=> dict.Add(person.Name, person); } // 使用: var dict = new Dictionary<string, Person> {{ new Person { Name = "Jon" } },{ new Person { Name = "Holly" } } };// 3. 为 IDictionary 添加扩展,使 ConcurrentDictionary 可用 public static class DictionaryExtensions {public static void Add<TKey, TValue>(this IDictionary<TKey, TValue> dict, TKey key, TValue value)=> dict.Add(key, value); } var concurrent = new ConcurrentDictionary<string, int> {{ "x", 10 },{ "y", 20 } }; -
面试准备建议:
- 重要性:⭐(了解特性存在,能解释其价值即可)
- 面试回答要点:
- C# 6 使集合初始化器可以找到扩展方法
Add,让初始化语法更灵活。 - 主要用途:为已有类型添加缺失的
Add重载、简化带有推导逻辑的字典初始化、让隐藏的Add重新可用。 - 结合
using static可避免扩展方法污染整个命名空间,实现按需引入。 - 面试时如果被问“如何让
ConcurrentDictionary使用集合初始化器”,可答:定义一个扩展方法Add调用IDictionary.Add,并说明要谨慎使用,因为显式实现通常意在隐藏。 - 在 Unity 面试中,可能用于自定义集合类的初始化扩展,但总体非重点,可作为对 C# 语言演进理解的一个例子展示。
- C# 6 使集合初始化器可以找到扩展方法
📦10.2.3 测试代码与产品代码中的权衡(对象/集合初始化器)
- 核心概念:对象初始化器和集合初始化器常用于静态只读集合和测试代码。测试代码可容忍更大的便利性,而产品代码需更谨慎地权衡正确性与可读性。
- 关键点:
- 静态只读集合:一次性初始化且之后不修改的集合,使用初始化器非常合适。
- 测试代码的灵活性:
- 可以为测试程序集自由添加
Add扩展方法,不影响产品代码。 - 即使出现重复键等小错误,测试本身通常会暴露问题,影响可控。
- 测试代码中可以在“便利”和“绝对安全”之间做出让步。
- 可以为测试程序集自由添加
- 产品代码的谨慎性:
- 公共 API 需要更严格的正确性保证,尽量避免容易误用的模式。
- 使用索引器初始化器替代集合初始化器时要警惕键覆盖问题。
- 延伸思考:链式调用(如 LINQ)遇到
null会崩溃,下一节将介绍 C# 6 提供的安全导航机制。
- 面试准备建议:
- 重要性:⭐(理念层面,理解设计取舍)
- 面试回答要点:
- 测试代码与产品代码对“完美”的要求不同:测试允许以轻微风险换取简洁性,产品则要确保健壮性和可维护性。
- 可举例:在单元测试中为
Dictionary编写便捷的Add扩展方法很常见,但公共库中要慎重暴露。 - 这种权衡思维在 Unity 开发中同样适用:编辑器工具或测试脚本可更随意地使用语法糖,游戏核心逻辑或公共 API 应保持清晰和安全。
- 面试官若问“如何平衡代码简洁与安全”,可结合该理念回答:根据代码影响范围(内部/外部,测试/生产)决定采用何种写法。
10.3 空值条件运算符
❗️10.3.1 空值条件运算符 ?. 的基本用法
-
核心概念:
?.运算符在访问成员或元素时,若左操作数为null,则整个表达式短路并返回null,而不是抛出NullReferenceException。 -
关键点:
- 解决什么问题:替代冗长的链式
!= null检查,使深层属性访问的代码更简洁安全。 - 语法与行为:
a?.B:若a为null,结果为null;否则返回a.B。- 支持链式调用:
a?.B?.C?.D,任意一环为null,整个表达式停止求值并返回null。 - 与
==等运算符配合时,若左表达式结果为null,则null == "Reading"结果为false,不抛异常。
- 省略的
?:- 若已确定某个变量非空(如
allCustomers中的元素c假定非空),则无需对c使用?.。 - 末尾值(如
Town)参与==比较时,由==处理null,无需再使用?.。
- 若已确定某个变量非空(如
- 对比旧式代码:
- 旧式:
c.Profile != null && c.Profile.DefaultShippingAddress != null && ... - 新式:
c.Profile?.DefaultShippingAddress?.Town == "Reading",消除重复,意图清晰。
- 旧式:
- 解决什么问题:替代冗长的链式
-
代码示例:
// 旧式:大量的 != null 检查 var readingCustomers = allCustomers.Where(c => c.Profile != null &&c.Profile.DefaultShippingAddress != null &&c.Profile.DefaultShippingAddress.Town == "Reading");// C# 6:使用 ?. 运算符 var readingCustomers = allCustomers.Where(c => c.Profile?.DefaultShippingAddress?.Town == "Reading"); -
面试准备建议:
- 重要性:⭐⭐⭐(高频使用,必须掌握)
- 面试回答要点:
?.是空值条件运算符(Null-conditional operator),C# 6 引入。若左侧为null,整个表达式返回null,不继续访问。- 主要用于避免深层属性访问时抛出
NullReferenceException,大幅减少冗余的 null 检查代码。 - 链式使用时,任意一环为
null都会安全终止并返回null。 - 与相等比较
==结合时,null == value结果为false,无需担心空比较问题。 - Unity 示例:
gameObject?.transform?.position安全访问组件链;GetComponent<Renderer>()?.material?.color避免在缺少组件时崩溃。 - 面试常问:“如何安全地访问可能为空的深层属性?” 回答使用
?.并举例说明。
❗️10.3.2 空值条件运算符 ?. 的细节与编译器行为
-
核心概念:
?.运算符不仅适用于属性,还适用于方法、字段和索引器。编译器在遇到?.时会自动生成临时变量和 null 检查,确保每个左操作数只求值一次,避免重复计算和潜在的线程安全问题。 -
关键点:
- 适用范围:
- 属性:
obj?.Property - 字段:
obj?.Field - 方法:
obj?.Method()(若obj为 null,方法不调用,返回null) - 索引器:
obj?[index]
- 属性:
- 编译器转换机制:
- 每遇到一个
?.,编译器会引入临时变量保存左侧表达式的值。 - 如果临时变量为
null,整个链终止,返回null;否则继续访问下一个成员。 - 每个左操作数只被求值一次,即使表达式链很长也不会重复计算。
- 每遇到一个
- 优于手动 null 检查:
- 旧式代码中同一属性可能被访问多次(如先判空,再用其成员),若中间被其他线程修改,仍可能抛
NullReferenceException。 ?.的临时变量机制保证了线程安全性更好,且更高效。
- 旧式代码中同一属性可能被访问多次(如先判空,再用其成员),若中间被其他线程修改,仍可能抛
- 可空值类型提升:
- 如果链式表达式最终结果是非可空值类型(如
int),使用?.后整个表达式类型会提升为对应的可空值类型(int?)。
- 如果链式表达式最终结果是非可空值类型(如
- 序列终止规则:
?.的序列仅包含属性、字段、方法、索引器的访问。- 其他运算符(如
==、+等)会打断序列,不再传递空值条件效果。
- 适用范围:
-
代码示例(编译器转换示意):
// 原始代码 c => c.Profile?.DefaultShippingAddress?.Town == "Reading"// 编译器等价转换(伪代码) string result; var tmp1 = c.Profile; if (tmp1 == null)result = null; else {var tmp2 = tmp1.DefaultShippingAddress;if (tmp2 == null)result = null;elseresult = tmp2.Town; } return result == "Reading";// 方法调用示例 string upper = person?.Name?.ToUpper(); // 若 person 或 Name 为 null,upper 为 null// 索引器示例 var first = list?[0]; // 若 list 为 null,first 为 null// 可空值类型提升 int? length = text?.Length; // Length 为 int,使用 ?. 后结果为 int? -
面试准备建议:
- 重要性:⭐⭐⭐(高频考点,必须深入理解)
- 面试回答要点:
?.适用于属性、字段、方法、索引器,编译时自动生成临时变量,确保左操作数只计算一次。- 对比手写
!= null检查:代码更简洁、性能更好(避免重复求值)、线程更安全。 - 如果链式调用最终结果是非可空值类型,表达式结果会自动提升为对应的可空值类型(如
int?)。 - 可以举例 Unity 中
GetComponent<Rigidbody>()?.velocity.magnitude安全获取速度大小,避免空引用。 - 若面试官深入问原理,可以说“编译器插入临时变量,模拟短路逻辑,其他运算符会终止空值传递序列”。
10.3.3 空值条件运算符与布尔值比较
-
核心概念:当使用
?.访问返回bool的方法或属性时,表达式类型为bool?。需将null结果映射为true或false才能用于条件判断,常用空合并运算符??或与布尔常量比较。 -
关键点:
- 问题来源:链式调用
?.在任意位置因null中断时返回null,导致整个表达式为bool?,不能直接用于if或Where等需要bool的地方。 - 三种可能结果:
- 所有访问成功:结果为
true或false。 - 途中遇到
null:整个表达式结果为null。
- 所有访问成功:结果为
- 映射方案:
- 不执行(null 视为
false):expression ?? falseexpression == true
- 执行(null 视为
true):expression ?? trueexpression != false
- 不执行(null 视为
- 个人推荐:使用
?? false或?? true更直观,可读为“如果访问失败则采用右侧默认值”。 - 设计历史:C# 2 中
bool?与true/false的比较已有微妙语义,书写代码时应优先保证意图清晰。
- 问题来源:链式调用
-
代码示例:
// 假设 name 可能为 null string name = null;// 如果 name 为 null,不进入 if if (name?.Equals("X") ?? false) { ... } if (name?.Equals("X") == true) { ... }// 如果 name 为 null,进入 if if (name?.Equals("X") ?? true) { ... } if (name?.Equals("X") != false) { ... }// 在 LINQ 中使用 customers.Where(c => c.Profile?.IsActive ?? false); -
面试准备建议:
- 重要性:⭐⭐(理解即可,常见于条件判断场景)
- 面试回答要点:
?.返回可空类型T?,对于原来返回bool的成员,会变成bool?。- 需要把
null明确转换为true或false才能用于if或Where子句。 - 常用
?? false忽略null(视为假),?? true将null视为真。 - 强调“代码意图应一目了然”,推荐
??语法,避免混淆== true与!= false的细微差别。 - 在 Unity 中:判断玩家是否存活
health?.IsAlive ?? false,若health组件为空则视为死亡。
📦10.3.4 索引器与空值条件运算符
-
核心概念:空值条件运算符
?.也可以用于索引器,语法为?[index],在访问数组或自定义索引器时安全处理null左操作数。 -
关键点:
- 语法:问号放在方括号前,
array?[0],适用于数组和自定义索引器。 - 返回值提升:如果索引器返回非可空类型,表达式结果自动提升为可空类型(如
int?)。 - 应用价值:不如属性和方法常见,但保持了特性在各类成员访问上的一致性。
- 使用场景:安全访问可能为
null的数组或集合元素。
- 语法:问号放在方括号前,
-
代码示例:
int[] array = null; int? firstElement = array?[0]; // firstElement 为 null,不抛异常// 自定义索引器 Dictionary<string, int> dict = null; int? value = dict?["key"]; // value 为 null,不抛异常 -
面试准备建议:
- 重要性:⭐(了解即可,非重点)
- 面试回答要点:
?.可用于索引器,语法为?[index],适用于数组和自定义索引器。- 原理与属性/方法访问一致:若左操作数为
null,短路并返回null,不会抛出NullReferenceException。 - 虽然在日常编码中使用频率不如属性和方法高,但体现了 C# 6 语言特性设计的内部一致性。
- 在 Unity 中很少单独使用,但如果需要安全访问可能为
null的集合或数组时可以使用,如allPlayers?[0]。
❗️10.3.5 空值条件运算符提升编程效率的两个场景
-
核心概念:利用
?.简化事件触发模式,并与返回null的 API 配合,实现高效、简洁且安全的代码。 -
关键点:
- 安全便捷的事件触发:
- 传统模式:局部变量缓存事件字段,判空后调用,避免多线程问题。
?.Invoke简化:Click?.Invoke(this, EventArgs.Empty),等价于线程安全的空值检查调用。- 若事件触发方法仅此一行,可写成表达式主体成员,进一步精简。
- 最大化利用返回
null的 API:- 设计 API 时,可让“未启用”状态返回
null接口(如ILogger.Debug返回null表示日志级别未开启)。 - 结合
?.调用,实现按需执行:logger.Debug?.Log($"Request: {url}")。 - 性能优势:仅当 logger 非
null时才执行内插字符串和日志记录,避免无用操作。
- 设计 API 时,可让“未启用”状态返回
- 其他适用场景:
- LINQ 的
FirstOrDefault等返回null的方法。 - LINQ to XML 中查询可选元素/属性:
book.Element("author")?.Attribute("name")?.Value。 - 反射 API 等返回
null的情况。
- LINQ 的
- 扩展性:即便现有 API 不支持该模式,也可通过扩展方法添加类似行为。
- 安全便捷的事件触发:
-
代码示例:
// 1. 事件触发 public event EventHandler Click; protected void OnClick() => Click?.Invoke(this, EventArgs.Empty); // 表达式主体// 2. 日志 API(按需格式化) public interface ILogger {IActiveLogger Debug { get; }// ... 其他级别 } public interface IActiveLogger {void Log(string message); } // 使用 logger.Debug?.Log($"Received request for URL {request.Url}");// 3. LINQ to XML 安全导航 string authorName = book.Element("author")?.Attribute("name")?.Value; string authorName2 = (string)book.Element("author")?.Attribute("name"); // 利用显式转换 -
面试准备建议:
- 重要性:⭐⭐⭐(高频,体现对现代 C# 特性的掌握)
- 面试回答要点:
- 事件触发推荐使用
?.Invoke,简洁且线程安全(内部使用临时变量),并可进一步简化成表达式主体。 ?.与返回null的 API 结合可延迟执行或跳过不必要的计算,提高性能(例如日志级别控制、可选 XML 节点导航)。- 能举例说明 Unity 中用法:
onHealthChanged?.Invoke(newHealth)安全触发事件;GetComponent<Renderer>()?.material?.SetColor(...)安全访问组件。 - 强调“空值条件运算符不仅用于防崩溃,还能优化代码结构和性能”,展现语言特性和设计思路的融合。
- 事件触发推荐使用
📦10.3.6 空值条件运算符的局限性
- 核心概念:
?.表达式的结果是一个值而非变量,因此不能用于赋值操作的左侧,也不能用于++、-或ref/out参数等需要变量的位置。 - 关键点:
- 不能用于赋值左侧:
- 非法:
person?.Name = ""; - 非法:
array?[index] = 10; - 非法:
stats?.RequestCount++;
- 非法:
- 原因:
?.返回的是值的副本(且可能为null),不是可修改的存储位置。 - 变通方案:仍需使用传统的
if (obj != null)判断后再赋值。 - 实际影响:根据作者经验,这一限制在实际编码中极少造成困扰。
- 不能用于赋值左侧:
- 面试准备建议:
- 重要性:⭐(了解限制即可)
- 面试回答要点:
?.返回的是值,不能用于赋值左侧或自增/自减操作。- 需要赋值时,只能用传统
if判空方式处理。 - 知道这一限制反映了对运算符语义(返回“可空值”而非“可写变量”)的准确理解。
10.4 异常过滤器
10.4.1 异常过滤器(catch when)
-
核心概念:异常过滤器允许在
catch块中使用when条件,仅当条件为true时才捕获异常;若为false,异常继续向上传播,就像从未被捕获一样。 -
关键点:
- 语法:
catch (ExceptionType e) when (条件表达式),条件可使用捕获的异常变量,返回值必须为bool。 - 与旧式对比:
- 旧式:捕获所有同类型异常,
if判断后不满足条件再throw,会丢失原始栈信息。 - 新式:不满足条件的异常不被视为捕获,栈信息完整保留,调试更准确。
- 旧式:捕获所有同类型异常,
- 双通路异常模型(CLR 层面):
- 第 1 通路:自上而下寻找匹配的
catch块,执行异常过滤器。 - 第 2 通路:一旦确定处理的
catch块,展开调用栈,执行沿途的finally块,然后执行catch体。 - 不含过滤器的
catch等价于when (true)。 - 安全影响:过滤器在第 1 通路执行,早于
finally块;若有权限提升/恢复的finally逻辑,恶意代码可能在过滤器中被执行。极罕见但应知晓。
- 第 1 通路:自上而下寻找匹配的
- 多次捕获同一异常类型:
- 有了过滤器后,同一
try块可以有多个捕获相同类型的catch,只要when条件互斥。 - 最后可跟一个无过滤器的
catch处理该类型的其余情况。
- 有了过滤器后,同一
- 典型应用场景:
- 根据异常属性选择性捕获(如
WebException.Status)。 - 重试逻辑:只在满足重试条件时捕获。
- 日志记录:在过滤器中记录日志并返回
false,不真正捕获异常(副作用用法需谨慎)。
- 根据异常属性选择性捕获(如
- 语法:
-
代码示例:
// 按条件捕获 try {// 尝试 Web 操作 } catch (WebException e) when (e.Status == WebExceptionStatus.ConnectFailure) {// 仅处理连接失败 } catch (WebException e) when (e.Status == WebExceptionStatus.NameResolutionFailure) {// 仅处理名称解析失败 }// 日志过滤器(记录后继续传播异常) try { ... } catch (Exception e) when (LogAndReturn(e, false)) { } // LogAndReturn 返回 false,不捕获
static bool LogAndReturn(string message, bool result)
{Console.WriteLine(message); // 异常过滤器调用的辅助方法return result;
}static void Top()
{try{throw new Exception();}finally{Console.WriteLine("Top finally"); // 在第2条通路中执行的finally块}
}static void Middle()
{try{Top();}catch (Exception e)when (LogAndReturn("Middle filter", false)) // 永远不会进行捕获的异常过滤器,永远不会打印,因为过滤器返回false{Console.WriteLine("Caught in middle");}finally{Console.WriteLine("Middle finally"); // 在第2条通路中执行的finally块}
}static void Bottom()
{try{Middle();}catch (IOException e)when (LogAndReturn("Never called", true)) // 永远不会被调用的过滤器,因为异常类型不匹配{}catch (Exception e)when (LogAndReturn("Bottom filter", true)) // 每次都会进行捕获的过滤器,该行会被打印,因为异常被捕获{Console.WriteLine("Caught in Bottom");}
}static void Main()
{Bottom();
}
Middle filter
Bottom filter
Top finally
Middle finally
Caught in Bottom

- 面试准备建议:
- 重要性:⭐⭐(中等,熟悉者加分)
- 面试回答要点:
- 异常过滤器用
when条件精确控制捕获时机,避免catch后throw丢失堆栈信息。 - 同一
try块可有多个同类型catch,互斥过滤,逻辑清晰。 - 了解 CLR 双通路模型的基本过程:先找处理者(含过滤器执行),再展开栈执行
finally。 - 适用场景:条件捕获、重试、以日志为目的的“触碰而不捕获”。
- 在 Unity 中:处理网络请求时按状态码过滤、资源加载失败按文件扩展名分类处理;编辑器脚本中记录异常后继续抛出供上层处理。
- 若被问“如何不丢失原始堆栈地判断异常是否处理”,可回答使用异常过滤器替代
catch + if + throw。
- 异常过滤器用
10.4.2 使用异常过滤器实现重试操作
-
核心概念:利用异常过滤器的
when条件,在重试次数未耗尽时捕获异常并执行重试逻辑,次数耗尽后异常自动向上传播,无需显式throw。 -
关键点:
- 重试的必要性:云服务、数据库等远程操作可能因临时故障失败,重试可提高健壮性。
- 设计考量:
- 避免多层重试:若调用栈中多个抽象层各自重试,会导致故障延迟暴露。顶层控制者应决定重试策略,底层重试应可配置甚至关闭。
- 生产级重试需考虑:退避策略(指数退避、随机抖动)、最大重试次数、可重试的异常类型等。
- 异常过滤器的优势:
- 条件
attempts > 0直接写在when中,逻辑清晰。 - 次数耗尽时,过滤器返回
false,异常不被捕获,原始堆栈完整保留。 - 不需要在
catch块内再throw,避免栈信息丢失。
- 条件
- 代码模式:
- 外层
while (true)循环,内部try执行操作。 catch带过滤器,捕获所有异常但仅在重试次数 > 0 时进入。- 循环内部无法到达的代码会导致编译错误,需保证所有路径都有
return或throw。
- 外层
-
代码示例(简化重试循环):
static T Retry<T>(Func<T> operation, int attempts) {while (true){try{attempts--;return operation();}catch (Exception e) when (attempts > 0){Console.WriteLine($"Failed: {e}");Console.WriteLine($"Attempts left: {attempts}");Thread.Sleep(5000);}} }// 使用 var result = Retry(() => {DateTime utcNow = DateTime.UtcNow;if (utcNow.Second < 20)throw new Exception("I don't like the start of a minute");return utcNow; }, 3); -
面试准备建议:
- 重要性:⭐⭐(体现对异常处理与设计模式的综合理解)
- 面试回答要点:
- 异常过滤器能干净地实现重试:条件不满足时异常自动向上传播,不破坏堆栈。
- 对比在
catch块内if判断再throw的方案:旧方案会丢失原始调用栈或产生误导的堆栈。 - 需注意重试的“归属问题”:不要在多个层级都做重试,应统一管理,避免雪崩或拖延。
- Unity 中可应用场景:网络请求失败重试(如 UnityWebRequest)、资源加载失败尝试备用路径、文件写入冲突重试。
- 可提及:生产级实现应加上指数退避与最大延迟、只重试特定异常类型(如
IOException、WebException),过滤掉逻辑错误异常。
📦10.4.3 利用异常过滤器的副作用记录日志
-
核心概念:在异常过滤器的条件表达式中执行日志记录并返回
false,可以在不捕获异常的情况下记录异常信息,不影响异常的传播和调用栈。 -
关键点:
- “仅副作用”模式:
catch块完全为空,过滤器只用于调用日志方法,异常处理流程不变。 - 保留原始堆栈:因为异常从未被实际捕获,栈信息完整,不会因
throw而重置。 - 避免二次记录:如果后续有其他
catch处理该异常,日志可能会记录两次,设计时需权衡。 - 代码清晰度:意图需明确,确保团队成员理解该模式仅用于日志记录而非错误恢复。
- “仅副作用”模式:
-
代码示例:
static void Main() {try{UnreliableMethod();}catch (Exception e) when (Log(e)) // Log 返回 false,不捕获{// 永远不会执行到这里} }static bool Log(Exception e) {Console.WriteLine($"{DateTime.UtcNow}: {e.GetType()} {e.Message}");return false; } -
面试准备建议:
- 重要性:⭐(了解即可,体现对语言边界的理解)
- 面试回答要点:
- 异常过滤器可用于产生“副作用”,如日志记录,同时返回
false让异常继续传播。 - 相比
catch再throw,这种方式不改变原始异常堆栈,对调试友好。 - 实际项目中需注意:这种模式可能让不熟悉的人困惑,应谨慎使用并做好注释。
- 在 Unity 中,可类比为在崩溃上报工具中记录异常但不中断程序执行(视情况而定)。
- 异常过滤器可用于产生“副作用”,如日志记录,同时返回
10.4.4 单个、有针对性的日志过滤器
-
核心概念:异常过滤器允许根据异常的特定属性(而非仅类型)来精确决定是否捕获,实现更细粒度的异常处理。
-
关键点:
- 超越类型过滤:
- 传统的
catch (IOException e)本质上相当于catch (Exception tmp) when (tmp is IOException)的语法糖。 - C# 6 异常过滤器是其通用化扩展,允许在
when中使用任意条件。
- 传统的
- 按属性过滤:
SqlException.Number:根据具体错误号区分处理(如死锁、超时)。WebException.Status:区分ConnectFailure、NameResolutionFailure等。- 获取 HTTP 状态码等可能需要额外解析,但依旧可按需过滤。
- 严重警告:
- 不要根据异常消息(
Exception.Message)进行过滤。 - 原因:消息可能因本地化、.NET 版本或框架更新而变化,不可靠。
- 不要根据异常消息(
- 最佳实践:
- 依据异常类型和稳定的属性值(如错误码、状态枚举)编写过滤器条件。
- 将异常消息仅用于日志记录或展示,不作为程序决策依据。
- 超越类型过滤:
-
代码示例(基于属性过滤):
try {// 数据库操作 } catch (SqlException e) when (e.Number == 1205) // 死锁错误号 {// 重试逻辑 } catch (SqlException e) when (e.Number == -2) // 超时错误号 {// 超时处理 }try {// Web 请求 } catch (WebException e) when (e.Status == WebExceptionStatus.ConnectFailure) {// 连接失败处理 } -
面试准备建议:
- 重要性:⭐⭐(体现对异常处理最佳实践的理解)
- 面试回答要点:
- 异常过滤器不仅能按类型过滤,还能按异常的属性(如错误码)精确过滤,使异常处理逻辑更清晰。
- 可以用多个
catch块捕获同一异常类型,但采用不同的when条件分别处理。 - 明确指出“不要依赖异常消息过滤”是面试加分项:消息文本不稳定,应使用错误码、状态枚举等。
- 在 Unity 中,如果使用
UnityWebRequest等 API,可根据responseCode或特定异常属性决定是否重试或降级。
📦10.4.5 异常过滤器与直接抛异常的区别,及第 10 章小结
-
核心概念:异常过滤器
catch when与catch后if (!condition) throw存在执行时机和堆栈信息的细微差异,但该特性属于可选增强,非革命性变化。 -
关键点:
- 行为差异:
when条件执行时机:在第 1 通路上层finally块执行之前。throw重抛时机:在catch块内执行,此时上层的finally块已执行完毕。- 这会影响依赖
finally执行顺序的逻辑(如权限提升/恢复)。
- 堆栈完整性:
when不捕获异常时,异常从未被真正捕获,原始堆栈完全保留。throw重抛虽能保留大部分堆栈,但捕获和重抛点的栈帧信息可能不同,影响调试准确性。
- 实用价值:相比表达式体成员、内插字符串、空值条件运算符等,异常过滤器对日常开发影响较小,但能提升特定场景的代码清晰度和堆栈保真度。
- 第 10 章作者总结:
- 最常用的两大特性:
using static和空值条件运算符?.,显著增强可读性。 - 对象/集合初始化器增强,使复杂初始化可在一条表达式中完成,与 C# 3 特性一脉相承,提升便捷度。
- 最常用的两大特性:
- 行为差异:
-
代码示例(行为对比):
// 使用异常过滤器:条件在 finally 之前执行,堆栈原始保留 try { ... } catch (Exception e) when (condition) {// 处理 }// 直接 throw:条件在 finally 之后执行,堆栈信息略有损失 try { ... } catch (Exception e) {if (!condition)throw;// 处理 } -
面试准备建议:
- 重要性:⭐(理解差异即可,非核心考点)
- 面试回答要点:
- 异常过滤器的主要优势在于保留原始调用栈和不影响
finally执行时序。 - 直接用
throw重抛异常,堆栈信息会有细微变化,可能混淆调试。 - 本章最重要的两个特性是
using static和空值条件运算符?.,在 Unity 面试中应重点掌握。 - 对异常过滤器能说出其存在、基本语法及与
catch-throw的区别,已足够应对大多数面试。
- 异常过滤器的主要优势在于保留原始调用栈和不影响
