[深入解析C#] 第 10 章:简洁代码的特性“盛宴”

[深入解析C#] 第 10 章:简洁代码的特性“盛宴”

10.1 using static 指令

10.1.1 引入静态成员

  • 核心概念using static 指令允许直接导入某个类型的静态成员,从而省略类型名前缀,简化代码。

  • 关键点

    • 语法using static 类型名;,之后可直接使用该类型的静态字段、属性、方法、枚举值、嵌套类型。
    • 可导入的成员
      • 静态字段和属性(如 Math.PIPI
      • 静态方法(如 Math.Cos(..)Cos(..)
      • 枚举值(如 BindingFlags.PublicPublic
      • 嵌套类型(如 Outer.Types.InnerInner
    • 适用于非静态类:即使类型本身不是静态类,也可导入其静态成员,如 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,适用于 DictionaryStringBuilderConcurrentDictionary 等类型。
      • 与集合初始化器的区别:集合初始化器调用 Add 方法,重复键会抛出异常;索引器初始化器调用 setter,重复键会覆盖旧值,更容易掩盖错误。面试中若被问到字典初始化,可主动提及这一点,体现对细节的关注。
      • 适用场景:类型没有 Add 方法,或需要覆盖已有值,或要混合初始化其他属性。
      • 在 Unity 中,初始化自定义配置对象、序列化容器或某些没有 Add 的集合类时可能会用到。
      • 建议加上常量字符串避免键值拼写错误,提高可维护性。

📦10.2.2 在集合初始化器中使用扩展方法(C# 6)

  • 核心概念:C# 6 允许集合初始化器通过扩展方法来满足 Add 方法的需求,使原本不支持集合初始化器的类型(或需要特定重载时)能利用扩展方法完成初始化。

  • 关键点

    • 传统限制回顾
      • 类型必须实现 IEnumerable(C# 6 未放宽此限制)。
      • 必须有合适的 Add 实例方法(单参数或多参数)。
    • C# 6 增强:集合初始化器查找 Add 方法时,会考虑扩展方法,且支持可选参数。
    • 常见应用场景
      1. 添加通用重载:为 List<T> 添加 Add(IEnumerable<T>),使其能直接用 AddRange 风格的添加,尤其便于初始化只读集合属性。
      2. 创建专属 Add 方法:为特定字典类型添加以值对象直接推导键的 Add 方法,简化代码(如 dict.Add(person) 自动提取 person.Name)。
      3. 暴露显式接口实现:某些类型(如 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# 语言演进理解的一个例子展示。

📦10.2.3 测试代码与产品代码中的权衡(对象/集合初始化器)

  • 核心概念:对象初始化器和集合初始化器常用于静态只读集合和测试代码。测试代码可容忍更大的便利性,而产品代码需更谨慎地权衡正确性与可读性。
  • 关键点
    • 静态只读集合:一次性初始化且之后不修改的集合,使用初始化器非常合适。
    • 测试代码的灵活性
      • 可以为测试程序集自由添加 Add 扩展方法,不影响产品代码。
      • 即使出现重复键等小错误,测试本身通常会暴露问题,影响可控。
      • 测试代码中可以在“便利”和“绝对安全”之间做出让步。
    • 产品代码的谨慎性
      • 公共 API 需要更严格的正确性保证,尽量避免容易误用的模式。
      • 使用索引器初始化器替代集合初始化器时要警惕键覆盖问题。
    • 延伸思考:链式调用(如 LINQ)遇到 null 会崩溃,下一节将介绍 C# 6 提供的安全导航机制。
  • 面试准备建议
    • 重要性:⭐(理念层面,理解设计取舍)
    • 面试回答要点
      • 测试代码与产品代码对“完美”的要求不同:测试允许以轻微风险换取简洁性,产品则要确保健壮性和可维护性。
      • 可举例:在单元测试中为 Dictionary 编写便捷的 Add 扩展方法很常见,但公共库中要慎重暴露。
      • 这种权衡思维在 Unity 开发中同样适用:编辑器工具或测试脚本可更随意地使用语法糖,游戏核心逻辑或公共 API 应保持清晰和安全。
      • 面试官若问“如何平衡代码简洁与安全”,可结合该理念回答:根据代码影响范围(内部/外部,测试/生产)决定采用何种写法。

10.3 空值条件运算符

❗️10.3.1 空值条件运算符 ?. 的基本用法

  • 核心概念?. 运算符在访问成员或元素时,若左操作数为 null,则整个表达式短路并返回 null,而不是抛出 NullReferenceException

  • 关键点

    • 解决什么问题:替代冗长的链式 != null 检查,使深层属性访问的代码更简洁安全。
    • 语法与行为
      • a?.B:若 anull,结果为 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 结果映射为 truefalse 才能用于条件判断,常用空合并运算符 ?? 或与布尔常量比较。

  • 关键点

    • 问题来源:链式调用 ?. 在任意位置因 null 中断时返回 null,导致整个表达式为 bool?,不能直接用于 ifWhere 等需要 bool 的地方。
    • 三种可能结果
      1. 所有访问成功:结果为 truefalse
      2. 途中遇到 null:整个表达式结果为 null
    • 映射方案
      • 不执行(null 视为 false):
        • expression ?? false
        • expression == true
      • 执行(null 视为 true):
        • expression ?? true
        • expression != false
    • 个人推荐:使用 ?? 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 明确转换为 truefalse 才能用于 ifWhere 子句。
      • 常用 ?? false 忽略 null(视为假),?? truenull 视为真。
      • 强调“代码意图应一目了然”,推荐 ?? 语法,避免混淆 == 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 时才执行内插字符串和日志记录,避免无用操作。
    • 其他适用场景
      • LINQ 的 FirstOrDefault 等返回 null 的方法。
      • LINQ to XML 中查询可选元素/属性:book.Element("author")?.Attribute("name")?.Value
      • 反射 API 等返回 null 的情况。
    • 扩展性:即便现有 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 逻辑,恶意代码可能在过滤器中被执行。极罕见但应知晓。
    • 多次捕获同一异常类型
      • 有了过滤器后,同一 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

img

  • 面试准备建议
    • 重要性:⭐⭐(中等,熟悉者加分)
    • 面试回答要点
      • 异常过滤器用 when 条件精确控制捕获时机,避免 catchthrow 丢失堆栈信息。
      • 同一 try 块可有多个同类型 catch,互斥过滤,逻辑清晰。
      • 了解 CLR 双通路模型的基本过程:先找处理者(含过滤器执行),再展开栈执行 finally
      • 适用场景:条件捕获、重试、以日志为目的的“触碰而不捕获”。
      • 在 Unity 中:处理网络请求时按状态码过滤、资源加载失败按文件扩展名分类处理;编辑器脚本中记录异常后继续抛出供上层处理。
      • 若被问“如何不丢失原始堆栈地判断异常是否处理”,可回答使用异常过滤器替代 catch + if + throw

10.4.2 使用异常过滤器实现重试操作

  • 核心概念:利用异常过滤器的 when 条件,在重试次数未耗尽时捕获异常并执行重试逻辑,次数耗尽后异常自动向上传播,无需显式 throw

  • 关键点

    • 重试的必要性:云服务、数据库等远程操作可能因临时故障失败,重试可提高健壮性。
    • 设计考量
      • 避免多层重试:若调用栈中多个抽象层各自重试,会导致故障延迟暴露。顶层控制者应决定重试策略,底层重试应可配置甚至关闭。
      • 生产级重试需考虑:退避策略(指数退避、随机抖动)、最大重试次数、可重试的异常类型等。
    • 异常过滤器的优势
      • 条件 attempts > 0 直接写在 when 中,逻辑清晰。
      • 次数耗尽时,过滤器返回 false,异常不被捕获,原始堆栈完整保留。
      • 不需要在 catch 块内再 throw,避免栈信息丢失。
    • 代码模式
      • 外层 while (true) 循环,内部 try 执行操作。
      • catch 带过滤器,捕获所有异常但仅在重试次数 > 0 时进入。
      • 循环内部无法到达的代码会导致编译错误,需保证所有路径都有 returnthrow
  • 代码示例(简化重试循环):

    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)、资源加载失败尝试备用路径、文件写入冲突重试。
      • 可提及:生产级实现应加上指数退避与最大延迟、只重试特定异常类型(如 IOExceptionWebException),过滤掉逻辑错误异常。

📦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 让异常继续传播。
      • 相比 catchthrow,这种方式不改变原始异常堆栈,对调试友好。
      • 实际项目中需注意:这种模式可能让不熟悉的人困惑,应谨慎使用并做好注释。
      • 在 Unity 中,可类比为在崩溃上报工具中记录异常但不中断程序执行(视情况而定)。

10.4.4 单个、有针对性的日志过滤器

  • 核心概念:异常过滤器允许根据异常的特定属性(而非仅类型)来精确决定是否捕获,实现更细粒度的异常处理。

  • 关键点

    • 超越类型过滤
      • 传统的 catch (IOException e) 本质上相当于 catch (Exception tmp) when (tmp is IOException) 的语法糖。
      • C# 6 异常过滤器是其通用化扩展,允许在 when 中使用任意条件。
    • 按属性过滤
      • SqlException.Number:根据具体错误号区分处理(如死锁、超时)。
      • WebException.Status:区分 ConnectFailureNameResolutionFailure 等。
      • 获取 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 whencatchif (!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 的区别,已足够应对大多数面试。