[深入解析C#] 第 14 章:C# 7 的代码简洁之道
14.1 局部方法
-
核心概念:在方法内部定义的方法,允许将仅在单一方法中使用的辅助逻辑封装在局部作用域,共享外部变量,避免代码重复和额外类成员。
-
关键点:
- 基本特性:
- 声明在任何可包含语句的成员中(方法、构造器、属性、索引器、事件、终结器等),甚至可以嵌套。
- 可以访问和修改外部方法中的局部变量(闭包捕获)。
- 声明顺序灵活,可以在调用位置之后定义。
- 限制(相比普通方法):
- 不能有访问修饰符(
public/private等)。 - 不能是
extern、virtual、new、override、static、abstract。 - 不能应用 attribute(如
[MethodImpl])。 - 同一作用域内不允许有同名的局部方法,不支持方法重载。
- 不能有访问修饰符(
- 与普通方法相同的特性:
- 可以有返回值或
void。 - 可以是
async、unsafe。 - 可以实现为迭代器块(
yield return)。 - 可以有参数(包括可选参数和
params)。 - 可以是泛型方法,并能使用闭合类型形参。
- 可以作为委托的目标(方法组转换)。
- 可以有返回值或
- 闭包实现差异(相对于 lambda):
- 局部方法在 IL 层面通常实现为普通方法(若不需要捕获变量),或结构体/类闭包,编译器可选择更高效的分配方式(可能堆分配更少),性能优于 delegate/lambda。
- 典型应用:
- 提取重复的辅助逻辑,尤其是需要在多处调用并修改外部变量的场景。
- 实现递归辅助方法(如树遍历),而无需暴露给外部。
- 在迭代器块中进行参数验证(因为迭代器代码会被延迟执行,直接在原方法体开头判断参数并抛出异常时,局部方法可以用来立即执行校验)。
- 基本特性:
-
代码示例:
static void Main() { int x = 10; PrintAndIncrementX(); PrintAndIncrementX(); Console.WriteLine($"x = {x}"); // x = 12 void PrintAndIncrementX() { Console.WriteLine($"x = {x}"); x++; } } -
面试准备建议:
- 重要性:⭐⭐(中等,常用特性,体现现代 C# 编码风格)
- 面试回答要点:
- 局部方法是 C# 7 引入的,用于在方法内部定义辅助函数,提升封装性和可读性。
- 与 lambda 的区别:局部方法在编译时更高效(避免 delegate 分配),且支持迭代器、泛型、可选参数等,更灵活。
- 典型场景:用于迭代器块内的参数验证(因为迭代器方法中的代码直到第一次
MoveNext才执行,如果直接在方法开头校验参数则达不到立即验证的效果,可通过一个普通方法包裹,局部方法则更优雅)。 - 在 Unity 中,可用于复杂的计算逻辑(如寻路算法中的递归辅助方法)、协程内部的小工具函数,避免类的成员膨胀。
- 注意:不能声明为
static,但 C# 8 起支持static局部方法(可阻止意外捕获外部变量),面试时可提及展现版本知识。
14.1.1 局部方法中的变量访问
-
核心概念:局部方法可以捕获并使用外部变量,但必须遵守严格的作用域、声明顺序和确定赋值规则,且不能捕获
ref参数或为只读字段赋值。这些规则保证了局部方法在编译时能被安全地展开为普通方法,同时避免生命周期和内存安全问题。 -
关键点:
- 作用域限制:局部方法只能访问其声明所在代码块内的局部变量。若需要在循环中捕获循环变量,局部方法必须定义在循环体内。
- 声明顺序要求:局部方法的声明必须位于其所捕获的变量声明之后,否则会编译错误(CS0841)。
- 不能捕获
ref参数:局部方法不能直接捕获外部方法的ref参数(包括结构体实例方法中的隐含this,因为结构体的this是引用)。如需访问,可通过显式参数以ref或值传递方式再次传入。 - 确定赋值规则:
- 在调用局部方法时,其读取的捕获变量必须已被确定赋值,否则编译错误。
- 如果局部方法在所有执行路径上对捕获变量赋值,那么调用该方法后,该变量被视为已确定赋值,可在外部方法后续代码中使用。
- 只读字段限制:即使在构造函数内,局部方法也不能为
readonly字段赋值(字段初始化只能由构造函数本身直接执行)。 - 设计初衷:这些规则确保局部方法在 IL 层面可被转换为不带闭包的普通静态方法(若不需要捕获)或高效闭包结构,而不会引入 CLR 层面的新概念。
-
代码示例:
// 合法:局部方法在变量声明后、在循环内捕获循环变量 static void ValidLoop() { for (int i = 0; i < 10; i++) { PrintI(); void PrintI() => Console.WriteLine(i); } } // 非法:局部方法在变量声明前捕获 // void PrintI() => Console.WriteLine(i); // CS0841 // int i = 10; // 处理 ref 参数的合法方式 static void ValidRef(ref int p) { PrintAndIncrement(ref p); void PrintAndIncrement(ref int x) => Console.WriteLine(x++); } // 确定赋值示例 static void DefinitelyAssign() { int i; AssignI(); // 调用后 i 确定赋值 Console.WriteLine(i); // 合法 void AssignI() => i = 10; } -
面试准备建议:
- 重要性:⭐⭐(中等,体现对闭包和编译原理的理解)
- 面试回答要点:
- 局部方法捕获变量遵循严格的“声明在变量之后”和“作用域内”规则,避免因生命周期混乱导致的 bug。
- 对比 lambda 表达式:lambda 可捕获任意范围内的变量,但会延长变量生命周期并产生 GC 分配;局部方法编译器行为更优,可能生成值类型闭包或静态方法,性能更好。
- 不能捕获
ref参数是由于生命周期和内存安全考虑,可通过显式传递参数解决;结构体中无法捕获this同理。 - 在 Unity 中,当需要在迭代器(如
IEnumerator)或async方法内部提取重复逻辑时,局部方法比 lambda 更合适,且能避免额外委托分配。 - 可提及 C# 8 引入的
static局部方法,它明确不捕获任何变量,进一步保证意图清晰和性能优化。
14.1.2 局部方法中的变量访问规则
-
核心概念:局部方法可以捕获并使用外部变量,但必须遵守严格的作用域、声明顺序和确定赋值规则,且不能捕获
ref参数或为只读字段赋值。这些规则保证了局部方法在编译时能被安全地展开为普通方法,同时避免生命周期和内存安全问题。 -
关键点:
- 作用域限制:局部方法只能访问其声明所在代码块内的局部变量。若需要在循环中捕获循环变量,局部方法必须定义在循环体内。
- 声明顺序要求:局部方法的声明必须位于其所捕获的变量声明之后,否则会编译错误(CS0841)。
- 不能捕获
ref参数:局部方法不能直接捕获外部方法的ref参数(包括结构体实例方法中的隐含this,因为结构体的this是引用)。如需访问,可通过显式参数以ref或值传递方式再次传入。 - 确定赋值规则:
- 在调用局部方法时,其读取的捕获变量必须已被确定赋值,否则编译错误。
- 如果局部方法在所有执行路径上对捕获变量赋值,那么调用该方法后,该变量被视为已确定赋值,可在外部方法后续代码中使用。
- 只读字段限制:即使在构造函数内,局部方法也不能为
readonly字段赋值(字段初始化只能由构造函数本身直接执行)。 - 设计初衷:这些规则确保局部方法在 IL 层面可被转换为不带闭包的普通静态方法(若不需要捕获)或高效闭包结构,而不会引入 CLR 层面的新概念。
-
代码示例:
// 合法:局部方法在变量声明后、在循环内捕获循环变量 static void ValidLoop() { for (int i = 0; i < 10; i++) { PrintI(); void PrintI() => Console.WriteLine(i); } } // 非法:局部方法在变量声明前捕获 // void PrintI() => Console.WriteLine(i); // CS0841 // int i = 10; // 处理 ref 参数的合法方式 static void ValidRef(ref int p) { PrintAndIncrement(ref p); void PrintAndIncrement(ref int x) => Console.WriteLine(x++); } // 确定赋值示例 static void DefinitelyAssign() { int i; AssignI(); // 调用后 i 确定赋值 Console.WriteLine(i); // 合法 void AssignI() => i = 10; } -
面试准备建议:
- 重要性:⭐⭐(中等,体现对闭包和编译原理的理解)
- 面试回答要点:
- 局部方法捕获变量遵循严格的“声明在变量之后”和“作用域内”规则,避免因生命周期混乱导致的 bug。
- 对比 lambda 表达式:lambda 可捕获任意范围内的变量,但会延长变量生命周期并产生 GC 分配;局部方法编译器行为更优,可能生成值类型闭包或静态方法,性能更好。
- 不能捕获
ref参数是由于生命周期和内存安全考虑,可通过显式传递参数解决;结构体中无法捕获this同理。 - 在 Unity 中,当需要在迭代器(如
IEnumerator)或async方法内部提取重复逻辑时,局部方法比 lambda 更合适,且能避免额外委托分配。 - 可提及 C# 8 引入的
static局部方法,它明确不捕获任何变量,进一步保证意图清晰和性能优化。
14.1.3 使用指南
-
核心概念:局部方法适用于方法内部重复逻辑的抽取,以及仅被单一方法使用的私有辅助方法的内聚。它强调代码属于方法实现细节,而非类型公共接口。
-
关键点:
- 典型场景:
- 同一方法内多处重复的逻辑。
- 仅在一个方法中使用的私有方法,可将其移入调用处,增强内聚。
- 重构策略:
- 先将私有方法保持签名不变移到调用方法内部。
- 再检查其参数:若所有调用都传递的是当前方法的局部变量,可将参数替换为直接捕获变量,甚至完全删除参数。
- 迭代器/异步方法参数校验优化:
- 问题:带
yield或async的方法,内部代码会延迟执行,参数校验若写在方法体内将无法立即生效。 - 传统方案:拆成两个方法,外层立即校验,内层用
yield/await实现逻辑。 - 局部方法方案:外层方法校验参数,局部方法返回迭代器/异步任务并捕获已验证的参数(或显式传递),既保留立即校验,又将实现内聚在一起。
- 性能提示:显式将参数传给局部方法(而非让局部方法捕获外层变量)可以减少编译器生成的闭包对象,降低堆分配。
- 问题:带
- 可读性建议:
- 特性较新,作者使用谨慎:倾向于只在宿主方法末尾定义局部方法,避免在循环或嵌套块中定义,也不嵌套局部方法。
- 不要在可读性上过度牺牲,特性应服务于代码清晰度,而非炫技。
- 典型场景:
-
代码示例(迭代器参数校验重构对比):
// 传统分离方法 public static IEnumerable<TResult> Select<TSource, TResult>( this IEnumerable<TSource> source, Func<TSource, TResult> selector) { Preconditions.CheckNotNull(source, nameof(source)); Preconditions.CheckNotNull(selector, nameof(selector)); return SelectImpl(source, selector); } private static IEnumerable<TResult> SelectImpl<TSource, TResult>( IEnumerable<TSource> source, Func<TSource, TResult> selector) { foreach (TSource item in source) yield return selector(item); } // 使用局部方法内聚 public static IEnumerable<TResult> Select<TSource, TResult>( this IEnumerable<TSource> source, Func<TSource, TResult> selector) { Preconditions.CheckNotNull(source, nameof(source)); Preconditions.CheckNotNull(selector, nameof(selector)); return Impl(source, selector); // 显式传参,避免闭包分配 IEnumerable<TResult> Impl( IEnumerable<TSource> s, Func<TSource, TResult> sel) { foreach (TSource item in s) yield return sel(item); } } -
面试准备建议:
- 重要性:⭐⭐(中等,体现对代码组织和性能的意识)
- 面试回答要点:
- 局部方法主要解决重复逻辑抽取和单一用途私有方法的内聚问题,使代码意图更明确。
- 在迭代器(
yield)和async方法中,可用外层方法+局部方法模式实现“立即参数校验,延迟逻辑执行”,替代旧的手动拆分私有方法。 - 性能上,显式给局部方法传参可避免闭包对象分配,比直接捕获外层变量更高效;但要结合实际可读性权衡。
- 在 Unity 中,编写协程(
IEnumerator)时可利用该模式在校验参数后立即抛出异常,而不必等到首次迭代才发现错误,提升调试体验。 - 风格上应保守使用:优先定义在方法末尾,避免嵌套,确保代码清晰而非炫技。
14.2 out 变量
❗️14.2.1 out 参数的内联变量声明
-
核心概念:C# 7 允许在方法调用时直接声明
out参数变量,无需预先声明,从而减少样板代码,使表达式主体和方法链更流畅。 -
关键点:
- 内联声明语法:
int.TryParse(text, out int value)—— 变量value在调用处声明。 - 支持隐式类型:可使用
out var让编译器推断类型。 - 抛弃变量:用
_忽略不需要的 out 值,如out _。 - 作用域:变量作用域为闭合块(如方法体、
if块等),与模式变量规则一致。 - 确定赋值规则:
- 若方法在所有路径上都被调用,则 out 变量在调用后确定赋值。
- 结合
&&短路逻辑时,仅当左侧为true才会调用右侧方法,因此右侧 out 变量仅在左侧成功后才确定赋值。
- 限制:
- 不能用于表达式树。
- 不能用于字段、属性、构造器初始化器,C# 7.3 之前也不能用于查询表达式。
- 内联声明语法:
-
代码示例:
// 传统写法(C# 6 及之前) static int? ParseInt32(string text) { int value; return int.TryParse(text, out value) ? value : (int?) null; } // C# 7 内联声明 static int? ParseInt32(string text) => int.TryParse(text, out int value) ? value : (int?) null; // 多个 out 与短路逻辑 static int? ParseAndSum(string text1, string text2) => int.TryParse(text1, out int value1) && int.TryParse(text2, out int value2) ? value1 + value2 : (int?) null; // 若第一个 TryParse 失败,value2 未被赋值,不可用 -
面试准备建议:
- 重要性:⭐⭐⭐(高频使用,必须掌握)
- 面试回答要点:
out内联声明简化了方法调用,特别是与TryParse、TryGetValue等模式结合时,消除额外的变量声明语句。- 能解释变量作用域和确定赋值规则:内联变量在调用所在闭合块中可见;仅当方法实际执行时变量才被赋值。
- 对比旧式写法,指出 C# 7 的改进使表达式主体成员更易实现。
- 在 Unity 中,常用场景包括:
int.TryParse、Dictionary.TryGetValue、物理射线检测 (Physics.Raycast使用out RaycastHit)、Animator.TryGetAnimatorParameter等,可直接内联声明变量,减少代码行数,提升可读性。 - 注意:在
&&或||条件中使用多个 out 变量时,需留意短路导致的未赋值风险。
📦14.2.2 解除 out 变量与模式变量的初始化器限制(C# 7.3)
-
核心概念:C# 7.3 解除了此前禁止在字段/属性初始化器、构造器初始化器(
base/this)以及查询表达式中使用 out 变量和模式变量的限制,使这些变量可在更多上下文中使用。 -
关键点:
- 解除的限制:
- 允许在构造器初始化器中声明和使用
out变量(如: base(text, int.TryParse(text, out int parseResult)))。 - 构造器体内可以访问该
out变量(其确定赋值状态取决于方法执行路径)。 - 同理,模式变量现在也可用于这些上下文。
- 允许在构造器初始化器中声明和使用
- 实际影响:
- 减少了仅为了绕开限制而不得不创建额外方法或临时变量的情况。
- 让构造器链式调用时可以直接利用
TryParse等方法的输出,代码更紧凑。
- 依然存在的限制:
- 表达式树中仍不能使用 out 变量或模式变量。
- 解除的限制:
-
代码示例:
class ParsedInt32 : ParsedText { public int? Value { get; } public ParsedInt32(string text) : base(text, int.TryParse(text, out int parseResult)) // C# 7.3 允许 { Value = Valid ? parseResult : (int?) null; // 在体内使用 } } -
面试准备建议:
- 重要性:⭐(了解即可,属于语言演进的补丁)
- 面试回答要点:
- C# 7.3 移除了 out 变量和模式变量在初始化器和构造器初始化器中的使用限制,这是对语言一致性的完善。
- 如果被问及 C# 版本差异,可以此为例子展示细节了解。
14.3 数字字面量的改进
📦14.3.1 二进制整型字面量
-
核心概念:C# 7 新增
0b/0B前缀,用于直接书写二进制整数,使位标志和特定二进制模式更加直观可读。 -
关键点:
- 语法:
0b或0B开头,后跟0和1组成的二进制序列。 - 使用场景:定义位掩码、硬件寄存器、协议标志、枚举组合等需要精确控制二进制的场合。
- 与十六进制/十进制对比:十进制不直观,十六进制需心算转位模式;二进制直接“所见即所得”。
- 可读性增强:无需计算或查表即可看出每一位的值。
- 语法:
-
代码示例:
byte b1 = 135; // 十进制,不直观 byte b2 = 0x83; // 十六进制,需转换 byte b3 = 0b10000111; // 二进制,一目了然:第7位和第0,1,2位为1 -
面试准备建议:
- 重要性:⭐(简单特性,了解即可)
- 面试回答要点:
- 二进制字面量用
0b前缀,提升位运算代码的可读性。 - 在 Unity 中,可能用于定义自定义标志枚举(如
[Flags]枚举)或与某些原生插件交互时的配置值,能以更自然的方式表达位组合。
- 二进制字面量用
📦14.3.2 下划线分隔符
-
核心概念:在数字字面量中可使用下划线
_作为分隔符,提升长数字的可读性,不影响数值。适用于十进制、十六进制、二进制及浮点数字面量。 -
关键点:
- 基本用法:
1_000_000(十进制)、0xff_00_ff_00(十六进制)、0b1000_0111(二进制)、123_456.78m(小数)。 - 可读性优势:可按“千分位”、字节、位等逻辑分组,使数值意图一目了然。
- 灵活性:允许连续多个下划线或非标准分组,编译器不检查分组合理性,但不推荐乱用。
- 限制:
- 不能出现在字面量开头或结尾(如
_123、123_)。 - 不能出现在小数点前后(如
123_.45、123._45)。 - C# 7.0/7.1 中,
0x/0b前缀后不能紧跟下划线;C# 7.2 解除此限制,允许0x_ff、0b_1000。
- 不能出现在字面量开头或结尾(如
- 最佳实践:采用有意义的分组,如
2_147_483_647、0b1000_0111,避免无意义的1________5。
- 基本用法:
-
代码示例:
int maxInt32 = 2_147_483_647; decimal largeSalary = 123_456_789.12m; ulong alternatingBytes = 0xff_00_ff_00_ff_00_ff_00; ulong alternatingWords = 0xffff_0000_ffff_0000; byte flags = 0b1000_0111; // 高位和低四位一目了然 // C# 7.2 起允许 ulong also = 0x_ffff_0000; byte alsoBin = 0b_1000_0111; -
面试准备建议:
- 重要性:⭐(简单特性,了解即可)
- 面试回答要点:
- 下划线分隔符是 C# 7 提供的纯可读性增强,对数值无任何影响。
- 最适合长整数、位掩码、大金额等需要分组的场景。
- 在 Unity 中,可用于定义清晰的配置常量,如
const int MaxHealth = 10_000;,提高代码可维护性。
14.4 throw表达式
-
核心概念:C# 7 允许在特定上下文中将
throw作为表达式使用,简化条件抛出异常或表达式体成员中的异常逻辑,减少对额外语句的需求。 -
关键点:
- 允许
throw表达式的位置:- 表达式主体成员(如
=> throw new ...) - Lambda 表达式主体(如
x => throw new ...) ??运算符的右侧操作数?:条件运算符的第二或第三操作数(但不能同时)
- 表达式主体成员(如
- 依然受限的位置:
- 不能单独作为赋值语句(
int x = throw ...无意义) - 不能直接用作方法实参(
Console.WriteLine(throw ...)非法)
- 不能单独作为赋值语句(
- 设计意图:让异常抛出可以与新的表达式语法自然结合,减少纯包装方法或多余的
if-else块,提升代码紧凑度和可读性。 - 本质仍是语句:throw 表达式的“结果类型”是任何类型,在需要表达式的地方使用,但它会立即抛出异常,实际上不会返回任何值。
- 允许
-
代码示例:
// 表达式主体方法 public void Unimplemented() => throw new NotImplementedException(); // ?? 运算符 public static T CheckNotNull<T>(T value, string paramName) where T : class => value ?? throw new ArgumentNullException(paramName); // ?: 运算符 public string Name => initialized ? data["name"] : throw new Exception("Not initialized"); -
面试准备建议:
- 重要性:⭐⭐(常用语法糖,提升简洁度)
- 面试回答要点:
- throw 表达式是 C# 7 的改进,让异常抛出可以无缝融入表达式体语法、空合并运算符和条件运算符。
- 强调它只是语法糖,生成 IL 与传统 throw 语句相同。
- 在 Unity 中,可用于自定义的
NotNull检查工具方法、表达式体属性中的参数校验等,减少样板代码。 - 注意不能滥用:避免将复杂逻辑挤进单一表达式,影响调试与可读性。
14.5 default 字面量(C# 7.1)
-
核心概念:C# 7.1 允许在编译器能推断目标类型的上下文中,直接使用
default替代default(T),简化代码,尤其对长类型名的可选参数效果显著。 -
关键点:
- 语法:将
default(T)简写为default,由编译器根据目标类型自动推断。 - 主要应用场景:
- 可选参数默认值:
CancellationToken cancellationToken = default替代冗长的default(CancellationToken)。 - 条件表达式:
return valid ? value : default;。 - 数组初始化器:
new[] { default, 5 }推断为int[],new[] { default, "text" }推断为string[]。
- 可选参数默认值:
- 行为特征:
default字面量本身没有类型,类似null字面量,可隐式转换为任何类型。- 必须能从上下文推断目标类型,否则编译错误(如
var x = default;非法)。 - 若目标类型是引用类型或原生类型,
default被视为常量表达式,可用于attribute。
- 潜在混淆:
default字面量始终表示类型的默认值(如int的0),而非方法可选参数声明的默认值。- 示例:
void PrintValue(int value = 10)调用PrintValue(default)输出0,而非10。 - 建议避免在可能引起歧义的地方使用
default,以明确意图。
- 语法:将
-
代码示例:
// 可选参数简化 public async Task<string> FetchValueAsync( string key, CancellationToken cancellationToken = default) // 条件返回值 static T GetValueOrDefault<T>(IList<T> list, int index) => index >= 0 && index < list.Count ? list[index] : default; // 数组类型推断 var intArray = new[] { default, 5 }; // int[] var stringArray = new[] { default, "text" }; // string[] // 注意:default 指的是类型默认值,非参数默认值 void PrintValue(int value = 10) => Console.WriteLine(value); PrintValue(default); // 打印 0,不是 10 -
面试准备建议:
- 重要性:⭐⭐(常用简洁写法,提升代码优雅度)
- 面试回答要点:
default字面量是 C# 7.1 引入的语法糖,主要用于消除default(T)的冗余,尤其当类型名很长时。- 知道其类型推断规则:必须有足够上下文推断类型,
var x = default;非法。 - 能指出与可选参数默认值搭配时的语义陷阱,面试时体现对细节的关注。
- 在 Unity 中,常用于泛型工具方法返回默认值,或简化
CancellationToken等长类型参数的可选参数声明,使异步操作代码更整洁。
📦14.6 非尾部命名实参
-
核心概念:C# 7.2 允许命名实参出现在位置实参之前或之间,只要命名实参的位置与其对应的形参位置一致,从而避免歧义并提升代码可读性。
-
关键点:
- 解决的问题:当方法参数中有字面量(如
null、true、数值)时,其含义不易理解。C# 7.2 之前,必须把后续参数全部改为命名实参或引入额外变量,现在可直接对关键参数使用命名实参。 - 合法调用规则:
- 尾部命名实参:
M(5, z: 15, y: 10),命名实参在所有位置实参之后,可打乱顺序。 - 非尾部命名实参:
M(5, y: 10, 15),命名实参y:10的位置与形参y的自然位置一致,未引起错位,合法。 - 非法调用:
M(y: 10, 5, 15),第一个实参是命名实参但对应y,后续位置实参无法明确对应x还是z,编译器禁止。
- 尾部命名实参:
- 设计原则:非尾部命名实参必须处于“如果它是位置实参也会出现的同一个位置”,杜绝任何可能的参数对应歧义。
- 解决的问题:当方法参数中有字面量(如
-
代码示例:
void M(int x, int y, int z) { } // 合法调用 M(5, z: 15, y: 10); // 尾部命名实参,顺序可任意 M(5, y: 10, 15); // 非尾部命名实参,y:10 在 y 的自然位置 // 非法调用 // M(y: 10, 5, 15); // 错误:命名实参打乱了位置对应关系 // 提升可读性的典型应用 client.UploadCsv(table, schema: null, csvData, options); // 明确哪个参数为 null -
面试准备建议:
- 重要性:⭐(了解即可,提升代码可读性的细节)
- 面试回答要点:
- C# 7.2 非尾部命名实参允许在不改变参数顺序的前提下,对中间某个字面量参数添加名称,提升可读性。
- 记住基本原则:命名实参必须出现在它作为位置实参的“自然”位置。
- 在 Unity 中,当调用带有大量可选参数或字面量的 API 时(如物理、动画系统的方法),可用此特性明确意图,例如
Physics.Raycast(origin, direction, maxDistance: 100f, layerMask: myMask)。
📦14.7 私有受保护的访问权限(C# 7.2)
此部分重要程度较低,如果需要就问问AI吧,就不涉及了。
14.8 C# 7.3 的一些小改进
14.8.1 泛型类型约束(C# 7.3 新增 Enum、Delegate、unmanaged)
-
核心概念:C# 7.3 允许泛型类型参数显式约束为
Enum(枚举)、Delegate(委托)或unmanaged(非托管类型),使泛型方法/类型能够对更具体的类别进行操作,增强类型安全性和表达能力。 -
关键点:
Enum约束:- 通常与
struct组合:where T : struct, Enum。 - 限制
T为具体的枚举值类型,排除System.Enum本身。 - 常用场景:编写可处理任意枚举类型的工具方法(如解析、转换、位运算)。
- 通常与
Delegate约束:- 可直接使用:
where T : Delegate。 - 允许
System.Delegate、MulticastDelegate及任何用delegate关键字声明的具体委托类型。 - 无法进一步约束“必须是具体委托类型”(类似枚举约束中的
struct)。
- 可直接使用:
unmanaged约束:- 限制
T为非托管类型:不可为引用类型、不可包含托管字段的值类型(如int、float、Guid、自定义纯值类型结构体)。 - 允许编写更高效、可直接操作内存的泛型代码(如与指针、
Span<T>交互)。
- 限制
- 使用限制:
- 约束为
Enum时,System.Enum自身不满足(因不满足struct约束)。 Delegate约束可接受Delegate、MulticastDelegate等抽象类型。unmanaged约束不能用于string等引用类型或包含引用字段的结构体。
- 约束为
-
代码示例:
// Enum 约束 static void EnumMethod<T>() where T : struct, Enum { } EnumMethod<DayOfWeek>(); // 合法 // EnumMethod<Enum>(); // 非法:不满足 struct // Delegate 约束 static void DelegateMethod<T>() where T : Delegate { } DelegateMethod<Action>(); // 合法 DelegateMethod<Delegate>(); // 合法 DelegateMethod<MulticastDelegate>(); // 合法 // unmanaged 约束 static void UnmanagedMethod<T>() where T : unmanaged { } UnmanagedMethod<int>(); // 合法 UnmanagedMethod<Guid>(); // 合法(Guid 是值类型且字段均为非托管) // UnmanagedMethod<string>(); // 非法:string 是引用类型 -
面试准备建议:
- 重要性:⭐⭐(中等,提升类型安全的实用特性)
- 面试回答要点:
- C# 7.3 新增了
Enum、Delegate、unmanaged三种泛型约束,让泛型编程更加精确。 Enum约束通常配合struct使用,用于编写枚举工具方法;Delegate约束用于处理各类委托;unmanaged约束用于高性能、非托管内存操作场景。- 能举例说明在 Unity 中可能的用途:
Enum约束可用于通用枚举解析(如 Editor 工具);unmanaged约束在编写与原生代码交互或使用Span<T>操作时保证类型安全。 - 注意指出
unmanaged约束对类型有严格要求,必须所有字段也都是非托管类型,这对自定义结构体的设计有影响。
- C# 7.3 新增了
📦14.8.2 重载决议改进
-
核心概念:C# 7.3 将部分方法有效性检查(如类型约束、静态/实例调用方式)提前到重载决议过程中,消除了一些原本会导致歧义或编译失败的情况,使重载选择更智能、更符合直觉。
-
关键点:
- 提前检查的条件:
- 泛型类型实参必须满足类型形参的所有约束。
- 静态方法不能以实例方法的形式调用。
- 实例方法不能以静态方法的形式调用。
- 改进效果:
- 如果某个重载候选因上述条件根本不可能有效,编译器会在重载决议阶段就将其排除,而不是先选出它、再报错。
- 示例:两个重载分别约束
T : struct和T : class,调用时传入int,编译器现在能正确选择struct约束的方法,而非报错或产生歧义。
- 设计目的:使泛型重载更加可用,减少开发者为满足重载决议而添加的额外强制类型转换或临时变量。
- 提前检查的条件:
-
代码示例(约束检查提前):
static void Method<T>(object x) where T : struct => Console.WriteLine($"{typeof(T)} is a struct"); static void Method<T>(string x) where T : class => Console.WriteLine($"{typeof(T)} is a reference type"); // C# 7.3 前可能报错,C# 7.3 可正确选择第一个方法 Method<int>("text"); // 输出: System.Int32 is a struct -
面试准备建议:
- 重要性:⭐(了解即可,展现对编译器演进的理解)
- 面试回答要点:
- 如果被问到“为什么某个泛型重载在旧版本中报错但在新版本中正常”,可解释说 C# 7.3 改进了重载决议,将约束检查提前,从而排除不可行的候选方法。
- 在实际编码中较少遇到,但对于设计泛型 API 的开发者来说,这能减少不必要的重载歧义。
- 提及静态/实例方法调用方式的检查也一同提前,使得误用能更早被发现。
14.8.3 字段的attribute支持自动实现的属性
-
核心概念:C# 7.3 允许直接对自动实现属性的隐式背后字段应用 attribute,使用
[field: AttributeName]语法,消除了必须手动声明字段和完整属性的样板代码。 -
关键点:
- 语法:在自动实现属性前添加
[field: Attribute],该 attribute 会编译到编译器生成的私有字段上。 - 解决的问题:
- C# 7.3 之前,若想为支持字段添加 attribute(如
[NonSerialized]、[SerializeField]),必须放弃自动实现属性,手动展开属性及字段。 - 现在可同时享受自动属性的简洁与字段级 attribute 的控制。
- C# 7.3 之前,若想为支持字段添加 attribute(如
field目标修饰符:field:并不是新关键字,而是 C# 一直存在的 attribute 目标修饰符,只是此前未对自动实现属性开放。Mono 编译器曾非正式支持,C# 7.3 将其标准化。- 常见应用:
[field: NonSerialized]标记不需序列化的自动属性。[field: SerializeField]在 Unity 中序列化私有自动属性。- 任何需要标记背后字段的框架或库特性。
- 语法:在自动实现属性前添加
-
代码示例(新旧对比):
// C# 7.3 之前:手动展开 [Demo] private string name; public string Name { get => name; set => name = value; } // C# 7.3:直接标记自动属性的字段 [field: Demo] public string Name { get; set; } -
面试准备建议:
- 重要性:⭐⭐(实用特性,特别是对 Unity 开发者)
- 面试回答要点:
- 能解释
[field: ...]语法将 attribute 应用于自动属性的隐藏背后字段。 - 对比新旧写法,突出减少样板代码的优势。
- Unity 重点:在 Unity 中,经常需要用
[SerializeField]序列化私有字段,以前只能手动声明字段;现在可以写[field: SerializeField] public string Name { get; set; },兼顾封装与序列化。这是面试中展示 Unity 和 C# 结合经验的加分点。 - 注意:
[field: SerializeField]序列化的是自动属性的背后字段,但属性本身仍通过 getter/setter 访问,Unity 的序列化系统会直接操作字段,绕过了属性的逻辑,使用时需要清楚这一点。
- 能解释

浙公网安备 33010602011771号